文章原载于:宜信技术学院
SegmentFault社区专栏:宜信技术学院
近来,分布式的问题被广泛提及,比如分布式事务、分布式框架、ZooKeeper、SpringCloud等等。
本文先回顾锁的概念,再介绍分布式锁,以及如何用Redis来实现分布式锁。
一、锁的基本了解
首先,回顾一下我们工作学习中的锁的概念。
为什么要先讲锁再讲分布式锁呢?
我们都清楚,锁的作用是要解决多线程对共享资源的访问而产生的线程安全问题,而在平时生活中用到锁的情况其实并不多,可能有些朋友对锁的概念和一些基本的使用不是很清楚,所以我们先看锁,再深入介绍分布式锁。
通过一个卖票的小案例来看,比如大家去抢dota2ti9门票,如果不加锁的话会出现什么问题?此时代码如下:
packageThread;importjava.util.concurrent.TimeUnit;publicclassTicket{/***初始库存量**/IntegerticketNum=8;publicvoidreduce(intnum){//判断库存是否够用if((ticketNum-num)=0){try{TimeUnit.MILLISECONDS.sleep();}catch(InterruptedExceptione){e.printStackTrace();}ticketNum-=num;System.out.println(Thread.currentThread().getName()+成功卖出+num+张,剩余+ticketNum+张票);}else{System.err.println(Thread.currentThread().getName()+没有卖出+num+张,剩余+ticketNum+张票);}}publicstaticvoidmain(String[]args)throwsInterruptedException{Ticketticket=newTicket();//开启10个线程进行抢票,按理说应该有两个人抢不到票for(inti=0;i10;i++){newThread(()-ticket.reduce(1),用户+(i+1)).start();}Thread.sleep(L);}}
代码分析:这里有8张ti9门票,设置了10个线程(也就是模拟10个人)去并发抢票,如果抢成功了显示成功,抢失败的话显示失败。按理说应该有8个人抢成功了,2个人抢失败,下面来看运行结果:
我们发现运行结果和预期的情况不一致,居然10个人都买到了票,也就是说出现了线程安全的问题,那么是什么原因导致的呢?
原因就是多个线程之间产生了时间差。
如图所示,只剩一张票了,但是两个线程都读到的票余量是1,也就是说线程B还没有等到线程A改库存就已经抢票成功了。
怎么解决呢?想必大家都知道,加个synchronized关键字就可以了,在一个线程进行reduce方法的时候,其他线程则阻塞在等待队列中,这样就不会发生多个线程对共享变量的竞争问题。
举个例子
比如我们去健身房健身,如果好多人同时用一台机器,同时在一台跑步机上跑步,就会发生很大的问题,大家会打得不可开交。如果我们加一把锁在健身房门口,只有拿到锁的钥匙的人才可以进去锻炼,其他人在门外等候,这样就可以避免大家对健身器材的竞争。代码如下:
publicsynchronizedvoidreduce(intnum){//判断库存是否够用if((ticketNum-num)=0){try{TimeUnit.MILLISECONDS.sleep();}catch(InterruptedExceptione){e.printStackTrace();}ticketNum-=num;System.out.println(Thread.currentThread().getName()+成功卖出+num+张,剩余+ticketNum+张票);}else{System.err.println(Thread.currentThread().getName()+没有卖出+num+张,剩余+ticketNum+张票);}}
运行结果:
果不其然,结果有两个人没有成功抢到票,看来我们的目的达成了。
二、锁的性能优化
2.1缩短锁的持有时间
事实上,按照我们对日常生活的理解,不可能整个健身房只有一个人在运动。所以我们只需要对某一台机器加锁就可以了,比如一个人在跑步,另一个人可以去做其他的运动。
对于票务系统来说,我们只需要对库存的修改操作的代码加锁就可以了,别的代码还是可以并行进行,这样会大大减少锁的持有时间,代码修改如下:
publicvoidreduceByLock(intnum){booleanflag=false;synchronized(ticketNum){if((ticketNum-num)=0){ticketNum-=num;flag=true;}}if(flag){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)+毫秒);}}
这样做的目的是充分利用cpu的资源,提高代码的执行效率。
这里我们对两种方式的时间做个打印:
publicsynchronizedvoidreduce(intnum){//判断库存是否够用if((ticketNum-num)=0){try{TimeUnit.MILLISECONDS.sleep();}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方法,运行结果如下:
packageThread;importjava.util.concurrent.CountDownLatch;publicclassMovie{privatefinalCountDownLatchlatch=newCountDownLatch(1);//魔童哪吒privateIntegerbabyTickets=20;//蜘蛛侠privateIntegerspiderTickets=;publicsynchronizedvoidshowBabyTickets()throwsInterruptedException{System.out.println(魔童哪吒的剩余票数为:+babyTickets);//购买latch.await();}publicsynchronizedvoidshowSpiderTickets()throwsInterruptedException{System.out.println(蜘蛛侠的剩余票数为:+spiderTickets);//购买}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整个对象的锁变为两个全局变量的锁,修改代码如下:
publicvoidshowBabyTickets()throwsInterruptedException{synchronized(babyTickets){System.out.println(魔童哪吒的剩余票数为:+babyTickets);//购买latch.await();}}publicvoidshowSpiderTickets()throwsInterruptedException{synchronized(spiderTickets){System.out.println(蜘蛛侠的剩余票数为:+spiderTickets);//购买}}
执行结果:
魔童哪吒的剩余票数为:20蜘蛛侠的剩余票数为:
现在两场电影的购票不会互相影响了,这就是第二个优化锁的方式:减少锁的粒度。顺便提一句,Java并发包里的ConcurrentHashMap就是把一把大锁变成了16把小锁,通过分段锁的方式达到高效的并发安全。
2.3锁分离
锁分离就是常说的读写分离,我们把锁分成读锁和写锁,读的锁不需要阻塞,而写的锁要考虑并发问题。
三、锁的种类
公平锁:ReentrantLock非公平锁:Synchronized、ReentrantLock、cas悲观锁:Synchronized乐观锁:cas独享锁:Synchronized、ReentrantLock共享锁:Semaphore
这里就不一一讲述每一种锁的概念了,大家可以自己学习,锁还可以按照偏向锁、轻量级锁、重量级锁来分类。
四、Redis分布式锁
了解了锁的基本概念和锁的优化后,重点介绍分布式锁的概念。
上图所示是我们搭建的分布式环境,有三个购票项目,对应一个库存,每一个系统会有多个线程,和上文一样,对库存的修改操作加上锁,能不能保证这6个线程的线程安全呢?
当然是不能的,因为每一个购票系统都有各自的JVM进程,互相独立,所以加synchronized只能保证一个系统的线程安全,并不能保证分布式的线程安全。
所以需要对于三个系统都是公共的一个中间件来解决这个问题。
这里我们选择Redis来作为分布式锁,多个系统在Redis中set同一个key,只有key不存在的时候,才能设置成功,并且该key会对应其中一个系统的唯一标识,当该系统访问资源结束后,将key删除,则达到了释放锁的目的。
4.1分布式锁需要注意哪些点
1)互斥性
在任意时刻只有一个客户端可以获取锁。
这个很容易理解,所有的系统中只能有一个系统持有锁。
2)防死锁
假如一个客户端在持有锁的时候崩溃了,没有释放锁,那么别的客户端无法获得锁,则会造成死锁,所以要保证客户端一定会释放锁。
Redis中我们可以设置锁的过期时间来保证不会发生死锁。
3)持锁人解锁
解铃还须系铃人,加锁和解锁必须是同一个客户端,客户端A的线程加的锁必须是客户端A的线程来解锁,客户端不能解开别的客户端的锁。
4)可重入
当一个客户端获取对象锁之后,这个客户端可以再次获取这个对象上的锁。
4.2Redis分布式锁流程
Redis分布式锁的具体流程:
1)首先利用Redis缓存的性质在Redis中设置一个key-value形式的键值对,key就是锁的名称,然后客户端的多个线程去竞争锁,竞争成功的话将value设为客户端的唯一标识。
2)竞争到锁的客户端要做两件事:
设置锁的有效时间目的是防死锁(非常关键)
需要根据业务需要,不断的压力测试来决定有效期的长短。
分配客户端的唯一标识,目的是保证持锁人解锁(非常重要)
所以这里的value就设置成唯一标识(比如uuid)。
3)访问共享资源
4)释放锁,释放锁有两种方式,第一种是有效期结束后自动释放锁,第二种是先根据唯一标识判断自己是否有释放锁的权限,如果标识正确则释放锁。
4.3加锁和解锁
4.3.1加锁
1)setnx命令加锁
setifnotexists我们会用到Redis的命令setnx,setnx的含义就是只有锁不存在的情况下才会设置成功。
2)设置锁的有效时间,防止死锁expire
加锁需要两步操作,思考一下会有什么问题吗?
假如我们加锁完之后客户端突然挂了呢?那么这个锁就会成为一个没有有效期的锁,接着就可能发生死锁。虽然这种情况发生的概率很小,但是一旦出现问题会很严重,所以我们也要把这两步合为一步。
幸运的是,Redis3.0已经把这两个指令合在一起成为一个新的指令。
来看jedis的官方文档中的源码:
publicStringset(Stringkey,Stringvalue,Stringnxxx,Stringexpx,longtime){this.checkIsInMultiOrPipeline();this.client.set(key,value,nxxx,expx,time);returnthis.client.getStatusCodeReply();}
这就是我们想要的!
4.3.2解锁
检查是否自己持有锁(判断唯一标识);删除锁。
解锁也是两步,同样也要保证解锁的原子性,把两步合为一步。
这就无法借助于Redis了,只能依靠Lua脚本来实现。
ifRedis.call(get,key==argv[1])thenreturnRedis.call(del,key)elsereturn0end
这就是一段判断是否自己持有锁并释放锁的Lua脚本。
为什么Lua脚本是原子性呢?因为Lua脚本是jedis用eval()函数执行的,如果执行则会全部执行完成。
五、Redis分布式锁代码实现
publicclassRedisDistributedLockimplementsLock{//上下文,保存当前锁的持有人idprivateThreadLocalStringlockContext=newThreadLocalString();//默认锁的超时时间privatelongtime=;//可重入性privateThreadownerThread;publicRedisDistributedLock(){}publicvoidlock(){while(!tryLock()){try{Thread.sleep();}catch(InterruptedExceptione){e.printStackTrace();}}}publicbooleantryLock(){returntryLock(time,TimeUnit.MILLISECONDS);}publicbooleantryLock(longtime,TimeUnitunit){Stringid=UUID.randomUUID().toString();//每一个锁的持有人都分配一个唯一的idThreadt=Thread.currentThread();Jedisjedis=newJedis(.0.0.1,);//只有锁不存在的时候加锁并设置锁的有效时间if(OK.equals(jedis.set(lock,id,NX,PX,unit.toMillis(time)))){//持有锁的人的idlockContext.set(id);①//记录当前的线程setOwnerThread(t);②returntrue;}elseif(ownerThread==t){//因为锁是可重入的,所以需要判断当前线程已经持有锁的情况returntrue;}else{returnfalse;}}privatevoidsetOwnerThread(Threadt){this.ownerThread=t;}publicvoidunlock(){Stringscript=null;try{Jedisjedis=newJedis(.0.0.1,);script=inputStream2String(getClass().getResourceAsStream(/Redis.Lua));if(lockContext.get()==null){//没有人持有锁return;}//删除锁③jedis.eval(script,Arrays.asList(lock),Arrays.asList(lockContext.get()));lockContext.remove();}catch(Exceptione){e.printStackTrace();}}/***将InputStream转化成String*
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.1基于数据库的分布式锁
1)实现方式
获取锁的时候插入一条数据,解锁时删除数据。
2)缺点
数据库如果挂掉会导致业务系统不可用。无法设置过期时间,会造成死锁。
6.2基于zookeeper的分布式锁
1)实现方式
加锁时在指定节点的目录下创建一个新节点,释放锁的时候删除这个临时节点。因为有心跳检测的存在,所以不会发生死锁,更加安全。
2)缺点
性能一般,没有Redis高效。
所以:
从性能角度:Rediszookeeper数据库从可靠性(安全)性角度:zookeeperRedis数据库
七、总结
本文从锁的基本概念出发,提出多线程访问共享资源会出现的线程安全问题,然后通过加锁的方式去解决线程安全的问题,这个方法会性能会下降,需要通过:缩短锁的持有时间、减小锁的粒度、锁分离三种方式去优化锁。
之后介绍了分布式锁的4个特点:
互斥性防死锁加锁人解锁可重入性
然后用Redis实现了分布式锁,加锁的时候用到了Redis的命令去加锁,解锁的时候则借助了Lua脚本来保证原子性。
最后对比了三种分布式锁的优缺点和使用场景。
希望大家对分布式锁有新的理解,也希望大家在考虑解决问题的同时要多想想性能的问题。
注:文章封面原图素材来源于网络,若有侵权请留言删除。
社区原文链接: