当前位置: 首页 > 科技观察

从锁的基本概念到Redis分布式锁的实现

时间:2023-03-15 15:10:35 科技观察

最近分布式问题被广泛提及,分布式事务、分布式框架、ZooKeeper、SpringCloud等,本文先回顾一下锁的概念,然后介绍分布式锁,以及如何使用Redis实现分布式锁。一、锁的基本认识首先,我们来回顾一下我们工作和学习中的锁的概念。为什么先说锁再说分布式锁?我们都知道锁的作用是解决多线程访问共享资源带来的线程安全问题,但是在日常生活中使用锁的情况并不多。有些朋友可能不了解锁的概念和一些基本概念。使用不是很清楚,先看看锁,再深入介绍分布式锁。我们来看一个卖票的小案例。比如大家去抢dota2ti9的票,不锁会有什么问题?此时代码如下:packageThread;importjava.util.concurrent.TimeUnit;publicclassTicket{/***初始库存**/IntegerticketNum=8;publicvoidreduce(intnum){//判断库存是否足够if((ticketNum-num)>=0){try{TimeUnit.MILLISECONDS.sleep(200);}catch(InterruptedExceptione){e.printStackTrace();}ticketNum-=num;System.out.println(Thread.currentThread().getName()+"成功售出"+num+"张票,剩余"+ticketNum+"张票");}else{System.err.println(Thread.currentThread().getName()+"未售出"+num+"张票,remaining"+ticketNum+"tickets");}}publicstaticvoidmain(String[]args)throwsInterruptedException{Ticketticket=newTicket();//启动10个线程抢票。按理说应该有两个人抢不到票for(inti=0;i<10;i++){newThread(()->ticket.reduce(1),"user"+(i+1)).start();}Thread.sleep(1000L);}}代码分析:这里有8张ti9票,设置10个线程(即模拟10个人)并发抢票。抓取成功则显示成功,抓取失败则显示失败。按理说应该有8个人抢到成功,2个人抢到失败。再来看运行结果:我们发现运行结果和预期的情况不一致,10个人都买票了,说明存在线程安全问题。那是什么原因呢?原因是多个线程之间存在时间差。如图,只剩下一张ticket,但是两个线程读取的ticketbalance都是1,也就是说线程B在线程A改变库存之前已经成功抢到票了。如何解决?想必大家都知道,加个synchronized关键字就可以了。当一个线程执行reduce方法时,其他线程都阻塞在等待队列中,这样就不会出现多个线程之间对共享变量的竞争。比如我们去健身房锻炼,如果多人同时使用同一台机器,同时在同一台跑步机上跑步,就会出现很大的问题,大家都会拼命拼搏。如果我们在健身房的门上加一把锁,只有拿到锁钥匙的人才能进去锻炼,其他人则在门外等候,这样就避免了健身器材的争夺。代码如下:publicsynchronizedvoidreduce(intnum){//判断库存是否充足if((ticketNum-num)>=0){try{TimeUnit.MILLISECONDS.sleep(200);}catch(InterruptedExceptione){e.打印堆栈跟踪();}ticketNum-=num;System.out.println(Thread.currentThread().getName()+"成功售出"+num+"张票,剩余"+ticketNum+"张票");}else{System.err.println(Thread.currentThread().getName()+"未售出"+num+"张票,剩余"+ticketNum+"张票");}}运行结果:果然,两人抢票失败,看来我们的目的达到了。2.锁性能优化2.1缩短锁的持有时间其实按照我们日常生活的理解,整个健身房不可能只有一个人在锻炼。所以我们只需要锁定某台机器,比如一个人在跑步,另一个人就可以进行其他运动。对于票务系统,我们只需要对库存修改操作的代码进行加锁,其他代码仍然可以并行执行,这样会大大减少锁的持有时间。代码修改如下:System.out.println(Thread.currentThread().getName()+"成功售出"+num+"张票,剩余"+ticketNum+"张票");}else{System.err.println(Thread.currentThread().getName()+"未售出"+num+"张票,剩余"+ticketNum+"张票");}if(ticketNum==0){System.out.println("花费时间"+(System.currentTimeMillis()-startTime)+"milliseconds");}}这样做的目的是为了充分利用cpu资源,提高代码执行效率。这里我们打印两个方法的时间:publicsynchronizedvoidreduce(intnum){//判断库存是否足够if((ticketNum-num)>=0){try{TimeUnit.MILLISECONDS.sleep(200);}catch(InterruptedExceptione){e.printStackTrace();}ticketNum-=num;if(ticketNum==0){System.out.println("耗时"+(System.currentTimeMillis()-startTime)+"毫秒");}System.out.println(Thread.currentThread().getName()+"成功售出"+num+"张票,剩余"+ticketNum+"张票");}else{System.err.println(Thread.currentThread().getName()+"未售出"+num+"张票,剩余"+ticketNum+"张票");}}果然,只锁定部分代码会大大提高代码的执行效率。因此,在解决了线程安全问题之后,我们还需要考虑加锁后代码执行的效率。2.2降低锁的粒度比如最近上映的两部电影哪吒和蜘蛛侠。我们模拟一个支付购买流程,让方法等待,添加一个CountDownLatch的await方法,运行结果如下:privateIntegerbabyTickets=20;//蜘蛛侠privateIntegerspiderTickets=100;publicsynchronizedvoidshowBabyTickets()throws"哪吒剩余票数为:"+babyTickets);//购买latch.await();}publicsynchronizedvoidshowSpiderTickets()throwsInterruptedException{System.out.println("蜘蛛侠剩余票数票是:"+spiderTickets);//buy}publicstaticvoidmain(String[]args){Moviemovie=newMovie();newThread(()->{try{movie.showBabyTickets();}catch(InterruptedExceptione){e.printStackTrace();}},"用户A").start();newThread(()->{try{movie.showSpiderTickets();}catch(InterruptedExceptione){e.printStackTrace();}},"用户B").start();}}执行结果:哪吒剩余票数为:20张,我们发现买的时候卡住了哪吒门票会影响蜘蛛侠门票的购买。事实上,两部电影是相互独立的,所以我们需要减少锁的数量。粒度,把整个movie对象的锁改成两个一个全局变量锁,修改代码如下:}}publicvoidshowSpiderTickets()throwsInterruptedException{synchronized(spiderTickets){System.out.println("蜘蛛侠剩余票数为:"+spiderTickets);//购买}}执行结果:魔童哪吒剩余票数为:20张蜘蛛数夏的剩余门票是:100现在购买两部电影的门票不会互相影响。这是锁优化的第二种方式:降低锁的粒度。对了,Java并发包中的ConcurrentHashMap是一把大锁变成了16把小锁,通过分段锁实现高效的并发安全。2.3锁分离锁分离通常被称为读写分离。我们把锁分为读锁和写锁。读锁不需要阻塞,而写锁要考虑并发问题。3.锁的种类公平锁:ReentrantLock非公平锁:Synchronized、ReentrantLock、cas悲观锁:Synchronized乐观锁:cas独占锁:Synchronized、ReentrantLock共享锁:Semaphore这里就不一一描述各个锁的概念了,各位可以自己学习,锁也可以按照偏向锁、轻量级锁、重量级锁来分类。4、Redis分布式锁了解了锁的基本概念和锁的优化之后,我们将重点介绍分布式锁的概念。上图是我们搭建的分布式环境。购票项目有3个,对应一个库存,每个系统会有多个线程。像上面这样,如果对库存修改操作进行加锁,那么这6个线程的线程安全性如何呢?当然不可能,因为每个票务系统都有自己的JVM进程,相互独立,所以加synchronized只能保证一个系统的线程安全,不能保证分布式的线程安全。所以需要一个三个系统通用的中间件来解决这个问题。这里我们选择Redis作为分布式锁。多个系统在Redis中设置相同的键。只有key不存在,才能设置成功,key会对应其中一个系统的唯一标识。当系统访问资源后,key被删除,达到释放锁的目的。4.1分布式锁需要注意哪些点1)互斥任何时候只有一个客户端可以获取到锁。这个很好理解,所有系统中只有一个系统可以持有锁。2)防死锁如果一个客户端在持有锁的时候崩溃了,没有释放锁,那么其他客户端就无法获得锁,就会造成死锁,所以需要保证客户端会释放锁。在Redis中,我们可以设置锁的过期时间来保证不会出现死锁。3)持锁的人必须是系铃开铃的人。加锁和解锁必须是同一个客户端。客户端A的线程加的锁必须由客户端A的线程解锁,客户端无法解锁其他锁。客户端锁。4)可重入客户端获得对象锁后,客户端可以再次获得该对象的锁。4.2Redis分布式锁流程Redis分布式锁的具体流程:1)首先,利用Redis缓存的特性,在Redis中设置一个键值对。key是锁的名字,然后client端的多个线程竞争Lock,如果竞争成功,就把value设置为client的唯一标识。2)竞争锁的客户端需要做两件事:设置锁的有效时间。目的是防止死锁(非常关键)。根据业务需要,需要持续进行压力测试,以确定有效期的长短。分配客户端唯一标识的目的是保证锁持有者解锁(很重要),所以这里的值设置为唯一标识(如uuid)。3)访问共享资源4)释放锁。有两种方法可以释放锁。第一种是在有效期结束后自动释放锁。二是先根据唯一标识判断自己是否有释放锁的权限,如果标识正确则释放。锁。4.3加锁和解锁4.3.1加锁1)setnx命令设置锁不存在我们会用到Redis命令setnx,setnx的意思是只有当锁不存在时才设置成功。2)设置锁的有效时间,防止死锁。过期锁需要两个步骤。想一想,会不会有什么问题?如果我们锁定后客户端突然挂了怎么办?那么这个锁就会变成没有有效期的锁,然后就可能发生死锁。虽然这种情况发生的概率很小,但是一旦出现问题,就会非常严重,所以我们不得不将这两个步骤合二为一。幸运的是,Redis3.0已经将这两条指令合并为一条新的指令。看看jedis官方文档中的源码:publicStringset(Stringkey,Stringvalue,Stringnxxx,Stringexpx,longtime){this.checkIsInMultiOrPipeline();this.client.set(key,value,nxxx,expx,time);returnthis。客户。getStatusCodeReply();}这就是我们想要的!4.3.2开锁检查自己是否持有锁(判断唯一标识);删除锁。解锁也是两步,还要保证解锁的原子性,两步合二为一。这是借助Redis无法实现的,只能通过Lua脚本来实现。ifRedis.call("get",key==argv[1])thenreturnRedis.call("del",key)elsereturn0end这是一个判断是否持有锁并释放的Lua脚本。为什么Lua脚本是原子的?因为lua脚本是jedis用eval()函数执行的,如果执行了,就执行完了。5.Redis分布式锁代码实现publicclassRedisDistributedLockimplementsLock{//Context,保存当前锁持有者idprivateThreadLocallockContext=newThreadLocal();//默认锁超时时间privatelongtime=100;//重入privateThreadownerThread;publicRedisDistributedLock(){}publicvoidlock(){while(!tryLock()){try{Thread.sleep(100);}catch(InterruptedExceptione){e.printStackTrace();}}}publicbooleantryLock(){returntryLock(时间,TimeUnit.MILLISECONDS);}publicbooleantryLock(longtime,TimeUnitunit){Stringid=UUID.randomUUID().toString();//给每个锁持有者分配一个唯一的idThreadt=Thread.currentThread();Jedisjedis=newJedis("127.0.0.1",6379);//只有当锁不存在时才加锁,并设置锁的有效时间if("OK".equals(jedis.set("lock",id,"NX","PX",unit.toMillis(time)))){//持有锁的人的idlockContext.set(id);①//记录当前线程setOwnerThread(t);②returntrue;}elseif(ownerThread==t){//因为锁是可重入的,所以需要判断当前线程是否已经持有锁tringscript=null;try{Jedisjedis=newJedis("127.0.0.1",6379);script=inputStream2String(getClass().getResourceAsStream("/Redis.Lua"));if(lockContext.get()==null){//没有人持有锁return;}//删除锁③jedis.eval(script,Arrays.asList("lock"),Arrays.asList(lockContext.get()));lockContext.remove();}catch(异常){e.printStackTrace();}}/***将InputStream转换为字符串*@paramis*@return*@throwsIOException*/publicStringinputStream2String(InputStreamis)throwsIOException{ByteArrayOutputStreambaos=newByteArrayOutputStream();inti=-1;while((i=is.read())!=-1){baos.write(i);}returnbaos.toString();}publicvoidlockInterruptibly()throwsInterruptedException{}publicConditionnewCondition(){returnnull;}}使用上下文全局变量来记录持有锁的人的uuid。解锁时,需要将uuid作为参数传入lua脚本中,判断是否可以解锁。记录当前线程,实现分布式锁的可重入,如果被当前线程持有,则也属于加锁成功。使用eval函数执行Lua脚本,保证解锁的原子性。6.分布式锁比较6.1基于数据库的分布式锁1)实现方式获取锁时插入一条数据,解锁时删除数据。2)缺点数据库挂了,业务系统不可用。不能设置过期时间,会造成死锁。6.2基于Zookeeper的分布式锁1)实现方法加锁时在指定节点的目录下新建一个节点,释放锁时删除这个临时节点。因为心跳检测的存在,不会出现死锁,更安全。2)缺点性能一般,效率不如Redis。所以:从性能的角度:Redis>zookeeper>数据库从可靠性(安全)的角度:zookeeper>Redis>数据库7.总结本文从锁的基本概念出发,提出多线程时会出现的线程安全问题线程访问共享资源,然后通过加锁来解决线程安全问题,这种方式性能会下降,需要通过缩短锁的持有时间,减小锁的粒度,以及分离锁。之后介绍了分布式锁的四大特性:互斥、防死锁、加锁、可重入,然后使用Redis实现分布式锁。加锁时使用Redis命令加锁,解锁时借助Lua脚本保证原子性。最后比较了三种分布式锁的优缺点和使用场景。希望大家对分布式锁有一个新的认识,也希望大家在考虑解决问题的时候多考虑性能问题。【本文为栏目机构易新科技原创文章,微信公众号“易新科技(id:CE_TECH)”点击此处查看作者更多好文