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

这个好:带你了解Redis分布式锁

时间:2023-03-22 17:19:07 科技观察

什么是分布式锁说到Redis,我们第一个想到的功能就是可以缓存数据,它也经常被用作分布式锁。我们都知道,锁在程序中作为同步工具使用,以保证共享资源在同一时刻只能被一个线程访问。Java中的锁我们都不陌生,比如我们经常使用的synchronized和Lock,但是Java的锁只能保证在单机上有效,分布式集群环境无能为力。这时候就需要用到分布式锁了。分布式锁,顾名思义,就是在分布式项目开发中使用的锁,可以用来控制分布式系统之间对共享资源的同步访问。一般来说,分布式锁需要满足以下几个特点:1.交互独占性:在任何时候,对于同一条数据,只有一个应用可以获得分布式锁;2、高可用:在分布式场景下,少量服务器宕机不影响正常使用,这就需要提供分布式锁的服务会部署在集群中;3.防止锁超时:如果客户端不主动释放锁,服务器会在一段时间后自动释放锁,防止客户端宕机或网络不可达时出现死锁;4.独占性:加锁和解锁必须在同一台服务器上进行,即锁持有者可以释放锁。您添加的锁无法被他人解锁;业界可以实现分布式锁效果的工具有很多,但操作无外乎几种:加锁、解锁、防止加锁超时。既然这篇文章讲的是Redis分布式锁,那么我们自然而然地扩展了Redis的知识点。实现锁的命令先介绍几个Redis命令,1.SETNX,用法是SETNX键值SETNX是“SETifNoteXists”的缩写(如果不存在,则SET)。设置成功则返回1,否则返回0。从setnx的用法可以看出,将key的值为lock设置为“Java”后,设置为其他值会失败.看起来很简单,好像独占了锁,但是有一个致命的问题,就是key没有过期时间。这样,除非在获取锁后手动删除key或者设置过期时间,否则其他线程是永远获取不到锁的。这种情况下,我们可以一直给key加一个过期时间,直接让线程在获取锁的时候进行两步操作:SETNXKey1EXPIREKeySeconds这种方案也是有问题的,因为获取锁和设置过期时间分为两个步骤,这不是一个原子操作。有可能获取到锁成功,但是设置时间失败,那岂不是白做了?不过不用担心,Redis官方已经为我们考虑到了这种事情,所以下面的命令2,SETEX,是用来将value和key关联起来的,SETEXkeysecondsvalue,并且设置key的生命周期为秒(以秒为单位)。如果密钥已经存在,SETEX命令将覆盖旧值。这个命令类似于下面两个命令:SETkeyvalueEXPIREkeyseconds#设置生存时间这两个步骤是原子的,会同时完成。Setex的用法3.PSETEX,用法PSETEXkeymillisecondsvalue这个命令和SETEX命令类似,但是它是以毫秒为单位设置key的生存期,而不是像SETEX命令那样以秒为单位。不过从Redis2.6.12版本开始,SET命令可以通过参数实现与SETNX、SETEX、PSETEX这三个命令相同的效果。比如这条命令SETkeyvalueNXEXseconds加上NX和EX参数后,效果就等同于SETEX,这也是Redis锁最常见的写法。如何释放锁释放锁的命令很简单,直接删除key即可,但是我们前面说过,因为分布式锁必须由锁持有者自己释放,所以首先要保证当前释放的线程lock就是holder,没问题就删掉。这样就变成了两步,好像又违反了原子性。我们应该做什么?不要着急,我们可以使用lua脚本来组装两步操作,就像这样:ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0endKEYS[1]是当前key的名字,ARGV[1]可以是当前线程的ID(或者其他不固定的值,可以标识它所属的线程)可以防止线程持有过期锁,或其他线程错误地删除现有锁。了解了代码实现的原理之后,我们就可以编写代码来实现Redis分布式锁的功能了,因为这篇文章的目的主要是讲解原理,并不是教大家怎么写分布式锁,所以我用伪实现了代码。首先是redis加锁工具类,包含了加锁和解锁的基本方法:publicclassRedisLockUtil{privateStringLOCK_KEY="redis_lock";//key持有时间,5msprivatelongEXPIRE_TIME=5;//等待超时,1privatelongTIME_OUT=1000;//redis命令参数相当于nx和px的命令集合privateSetParamsparams=SetParams.setParams().nx().px(EXPIRE_TIME);//redis连接池连接本地redis客户端JedisPooljedisPool=newJedisPool("127.0.0.1",6379);/***Lock**@paramid*线程id,或者其他可以标识当前线程且不重复的字段*@return*/publicbooleanlock(Stringid){Longstart=System.currentTimeMillis();Jedisjedis=jedisPool.getResource();try{for(;;){//SET命令返回OK,证明获取锁成功Stringlock=jedis.set(LOCK_KEY,id,params);if("OK".equals(lock)){returntrue;}//否则循环等待,如果TIME_OUT内没有获取到锁,则获取失败longl=System.currentTimeMillis()-start;if(l>=TIME_OUT){returnfalse;}try{//休眠一会儿,否则循环的重复执行总是失败Thread.sleep(100);}catch(InterruptedExceptione){e.printStackTrace();}}}finally{jedis.close();}}/***Unlock**@paramid*线程的id,或者其他可以标识当前线程且不重复的字段*@return*/publicbooleanunlock(Stringid){jedisjedis=jedisPool.getResource();//删除key的lua脚本Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]then"+"returnredis.call('del',KEYS[1])"+"else"+"return0"+"end";try{Stringresult=jedis.eval(script,Collections.singletonList(LOCK_KEY),Collections.singletonList(id)).toString();return"1".equals(result);}finally{jedis.close();}}}具体的代码功能注释已经写的很清楚了,接下来我们可以写一个demo类来测试一下效果:{for(inti=0;i<100;i++){newThread(()->{Stringid=Thread.currentThread().getId()+"";booleanisLock=demo.lock(id);try{//如果得到锁,共享参数减1if(isLock){NUM--;System.out.println(NUM);}}finally{//释放锁,放到finallydemo.unlock(id);}}).start();}}}我们创建100个线程模拟并发情况,执行后结果如下:代码执行结果可以是看到了,实现了锁的效果,线程安全也能得到保证,当然,上面的代码只是简单的实现了效果,功能肯定是不完整的。一个完善的分布式锁需要考虑的方面还是很多的,实际设计起来也不是那么容易。我们的目的只是为了学习和理解原理。自己动手写一个工业级的分布式锁工具是不现实的,也没有必要。类似原理类似的开源工具(Redisson)还有很多,并且已经被业界同行测试过。就用它吧。虽然实现了功能,但实际上,从设计的角度来看,这样的分布式锁有很大的缺陷,这也是本文要重点讨论的。分布式锁的缺点1.客户端长期阻塞导致锁失效。客户端1获得锁。由于网络问题或GC导致长时间阻塞,业务程序执行前锁失效。此时客户端2也能正常拿到锁,可能会出现线程安全问题。客户端长时间阻塞,那么如何防止出现这样的异常呢?先不说解决方案,介绍完其他缺陷再讨论。2、redis服务器时钟漂移问题如果redis服务器的机器时钟向前跳动,会导致key过早过期。比如client1拿到锁后,key在12:02过期,但是redis服务器本身的时钟比client快2分钟,导致key在12:00失效。此时如果client1还没有释放锁,那么多个client可能同时持有同一个key。锁问题。3、单点实例安全问题如果redis是单主模式,当机器宕机时,所有客户端都无法获取到锁。为了提高可用性,可以向主服务器添加一个从服务器。但是由于redis的主从同步是异步的,可能会出现客户端1设置锁后,master挂掉,slave提升为master的情况。因为异步复制的特性,客户端1设置的锁丢失了。此时客户端Client2也可以成功设置锁,导致客户端1和客户端2同时拥有锁。为了解决Redis的单点问题,redis的作者提出了RedLock算法。RedLock算法该算法的前提是Redis必须部署在多个节点上,可以有效防止单点故障。具体实现思路如下:1、获取当前时间戳(ms);2.先设置key的有效时间(TTL),过了这个时间会自动释放,然后客户端(client)尝试使用相同的key和value设置所有的redis实例,并设置一个超时时间much每次连接一个redis实例都比TTL短,这是为了不让已经关闭的redis服务等待太久。并尝试获取下一个redis实例。例如:如果TTL(即过期时间)为5s,那么获取锁的超时时间可以设置为50ms,这样如果50ms内不能获取到锁,则放弃获取锁,尝试获取下一个锁;所有能拿到锁后的时间减去第一步的时间,以及redis服务器的时钟漂移误差,然后这个时间差应该小于TTL时间和成功设置锁的实例数>=N/2+1(N为Redis的实例数),则加锁成功,例如TTL为5s,需要2s连接redis获取所有的锁,然后减去时钟drift(假设误差在1s左右),那么锁的真正有效持续时间只有2s;4.如果客户端因为某种原因获取锁失败,会开始解锁所有的redis实例。按照这样的算法,如果我们假设有5个Redis实例,那么客户端只需要获取其中3个以上的锁就可以认为是成功的。流程图演示大概是这样的:密钥长期有效,引入算法。从设计上来说,毫无疑问RedLock算法的思路主要是为了有效防止Redis单点故障的问题,同时在设计TTL的时候也考虑到了服务器时钟漂移的误差,这大大提高了分布式锁的安全性。但事实真的如此吗?反正个人觉得效果一般。首先,我们可以看到,在RedLock算法中,会从与Redis实例的连接持续时间中减去锁的有效时间。如果这个过程是由于网络问题导致时间过长,留给锁的有效时间会大大减少。客户端访问共享资源的时间很短,很可能在程序处理过程中锁就过期了。而且锁的有效时间需要减去服务器的时钟漂移,但是减多少呢?如果这个值设置不当,很容易出现问题。然后第二点,虽然这个算法考虑到了使用多个节点来防止Redis单点故障的问题,但是如果一个节点崩溃重启,还是有可能出现多个客户端同时获取锁的情况。假设一共有5个Redis节点:A、B、C、D、E,客户端1和2分别加锁。客户端1成功锁定A、B、C,并成功获取到锁(但D、E未加锁)。C节点的master挂了,锁还没有同步到slave。slave升级为master后,失去了client1加的锁,client2此时获取锁,锁C、D、E,获取锁成功。这样,客户端1和客户端2同时获得了锁,程序安全隐患依然存在。另外,如果其中一个节点发生了时间漂移,也可能会导致锁的安全问题。因此,虽然通过部署多实例提高了可用性和可靠性,但RedLock并没有彻底解决Redis单点故障的隐患,也没有解决长期客户端导致的时钟漂移和锁超时失败等问题。阻塞,锁的安全隐患依然存在。结语可能有人要进一步追问,那么应该怎么做才能保证锁具的绝对安全呢?我只能说,鱼和熊掌不可兼得。我们之所以使用Redis作为分布式锁的工具,很大程度上是因为Redis本身具有高效率和单进程的特点。即使在高并发的情况下,也能很好的保证性能。然而,在很多情况下,性能和安全性并不能得到充分的考虑。如果一定要保证锁的安全,可以使用db、zookeeper等其他中间件进行控制。这些工具可以很好的保证锁的安全性,但是性能上只能说差强人意,不然大家早就用上了。一般来说,如果使用Redis来控制共享资源,对数据安全性要求高,最终的保障方案是实现业务数据的幂等控制,这样即使多个客户端获取锁,也不会影响数据的一致性。当然,并不是所有的场景都适合这样做。具体选择由评委决定。毕竟没有完美的技术,只有适合的才是最好的。