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

Redis分布式锁踩坑不慌,这里有超全的解决方案

时间:2023-03-11 21:12:32 科技观察

踩到Redis分布式锁不要惊慌,这里有超全解决方案你有没有怀疑过自己正在使用的分布式锁是否真的靠谱?下面是根据自己踩坑的经验总结的一些心得。你真的需要分布式锁吗?使用分布式锁意味着多个进程一起访问同一个资源。一般会在两种场景下防止重复访问同一个资源:提高效率。例如,多个节点计算同一批任务。如果一个任务已经被一个节点计算,其他节点就不需要重复计算,避免浪费计算资源。不过,重复计算也无妨,不会造成其他更大的损失。也就是说,允许偶尔失败。保证正确性。这种情况下对锁的要求很高,如果重复计算会影响正确性。这不允许失败。分布式锁的引入势必引入第三方基础设施,比如MySQL、Redis、Zookeeper等,如果实现分布式锁的基础设施出现问题,也会对业务造成影响,可以考虑是否在使用分布式锁之前先使用分布式锁。不加锁能实现吗?但这超出了本文的范围。本文假设需要加锁是合理的,偏向于上面的第二种情况。为什么会偏颇?因为没有100%可靠的分发型锁,看完下面的内容你就明白了。从简单的分布式锁实现开始,分布式锁的Redis实现非常普遍。实现和使用第三方库非常简单,至少看起来是这样的。这是最简单和最可靠的Redis实现。最简单的实现非常经典。这里只说两个关键点。锁定和解锁的锁必须相同。一种常见的解决方案是给每把锁一个钥匙(唯一ID),在上锁和解锁时生成。法官。无法永久锁定资源。一个常见的解决方案是给锁一个过期时间。当然还有其他的选择,这个以后再说。复制粘贴实现如下:lockpublicstaticbooleantryLock(Stringkey,StringuniqueId,intseconds){return"OK".equals(jedis.set(key,uniqueId,"NX","EX",seconds));}here其实就是调用SETkeyvaluePXmilliseoncdsNX。如果对这个命令不理解,参考SETkeyvalue[EX秒|PX毫秒][NX|XX][KEEPTTL]:https://redis.io/commands/setunlockpublicstaticbooleanreleaseLock(Stringkey,StringuniqueId){StringluaScript="ifredis.call('get',KEYS[1])==ARGV[1]then"+"returnredis.call('del',KEYS[1])elsereturn0end";returnjedis.eval(luaScript,Collections.singletonList(key),Collections.singletonList(uniqueId)).equals(1L);}这个实现的本质在于简单的lua脚本,先判断唯一ID是否相等再进行操作。可靠吗?这样的实现有什么问题?单点问题。以上实现只需要一个主节点就可以完成。这里的单点指的是单个master。即使是集群,如果成功加锁,将锁从master复制到slave,也会挂掉,同一个资源会被多个节点阻塞。客户端被锁定。执行时间超过了锁的到期时间。上面写到为了避免一直被锁的情况,增加了一个自下而上的过期时间,时间到了自动释放锁。但是,如果这期间没有完成任务怎么办?由于GC或网络延迟,任务时间发生变化。long,很难保证在锁的有效期内完成任务。如何解决这两个问题?尝试更复杂的实现。对于Redlock算法的第一个单点问题,顺着redis的思路,接下来想到的肯定是Redlock。Redlock为了解决单机问题,需要多个(2个以上)redismaster节点。多个主节点相互独立,不存在数据同步。Redlock的实现如下:1)获取当前时间。2)依次获取N个节点的锁。锁定每个节点的实现方法同上。这里有个细节,就是每次获取锁的过期时间都不一样,需要减去获取锁的耗时操作:比如传入锁的过期时间是500ms;1ms,则第一个节点的锁过期时间为499ms;获取第二个节点的锁需要2ms,那么第二个节点的锁的过期时间为497ms;如果锁的过期时间小于等于0,则表示整个获取锁操作超时,整个操作失败。3)判断是否获取锁成功。如果客户端在上述步骤中获取了(N/2+1)个节点锁,且每个锁的过期时间都大于0,则锁获取成功,否则失败。失败时释放锁。4)释放锁。向所有节点发送释放锁的指令,各节点的实现逻辑与上面的简单实现相同。为什么需要对所有节点进行操作?因为在分布式场景下,一个节点获取不到锁并不代表该节点加速失败。可能真的加锁成功了,但是由于网络抖动导致返回超时。以上是对常见的redlock实现的描述。乍一看,像是多主版的简易版。如果真是这样,那就太简单了。接下来分析这个算法在各种场景下是如何被破解的。分布式锁的陷阱高并发场景下的问题以下问题并不是说在低并发场景下不容易出现,而是在高并发场景下出现的概率更高。性能问题。性能问题来自两个方面。获取锁的时间。如果在高并发场景下使用redlock,有N个master节点,一个一个请求需要很长时间,影响性能。这很容易解决。从上面的描述不难发现,多个节点获取锁的操作并不是同步操作,而是异步操作,这样多个节点可以同时获取锁。即使是并行处理,还是要预估获取锁的时间,保证锁的TTL>获取锁的时间+任务处理时间。被锁定的资源太大。锁定方案本身会为了正确性而牺牲并发性,而这种牺牲与资源的大小成正比。这时候可以考虑拆分资源。拆分的方式有两种:从业务角度将锁定的资源拆分成多个段,每个段分别加锁。比如我要对一个商户进行几项操作,在操作前必须先锁定商户。这时候我可以把几个操作拆分成多个独立的步骤,分别加锁,提高并发度。使用bucketing的思想,将一个资源拆分到多个bucket中,如果一个bucket锁定失败,立即尝试下一个。比如批量任务处理的场景,需要处理200万商户的任务。为了提高处理速度,使用了多线程。每个线程要处理100个商户,这100个商户就得加锁。如果不处理,将非常困难。很难保证两个线程同时锁定的商户不重叠。这时候可以把商户按照一个维度分桶,比如某个标签,然后一个任务处理一个桶。处理完这个桶,再处理下一个桶。斗,少竞争。重试有问题。无论是简单实现还是redlock实现,都会有重试逻辑。如果直接实现上面的算法,会出现多个客户端几乎同时获取同一个锁,然后每个客户端锁定部分节点,但没有客户端获取大部分节点。该解决方案也很常见。重试时,让多个节点错开。错开的方式是在重试时间上加上一个随机时间。这并不能根治问题,但可以有效缓解问题,亲身试用有效。节点宕机对于单master节点,没有持久化的场景,宕机会挂掉。这个在实现上一定要支持重复操作,自己做到幂等。对于多主场景,比如redlock,我们来看这样一个场景:假设有5个redis节点:A、B、C、D、E,没有持久化。client1从节点A、B、C成功获取锁,则client1获取锁成功。节点C已关闭。client2从C、D、E成功获取锁,client2也成功获取锁。然后同时client1和client2同时获取锁,redlock被破解。如何解决?最容易想到的解决方案是开启持久化。持久化可以持久化每条redis命令,但是这样对性能影响很大,一般不会用到。如果不采用这种方式,节点挂掉的时候肯定会丢失一小部分数据。也许我们的锁就在里面。另一种选择是延迟启动。即节点宕机修复后,不会立即加入,而是等待一段时间再加入。等待时间大于宕机时刻所有锁的最大TTL。但是这个方案还是不能解决问题。如果在上面的第3步中B和C都down了,那么就只剩下A、D、E三个节点了。从D和E成功获取锁就可以了,还是会出问题。那么只能通过增加主节点总数来缓解这个问题。增加主节点会提高稳定性,但也会增加成本,需要在两者之间权衡。任务执行时间超过锁的TTL。上线前,由于网络延迟,锁过期,任务被多线程执行,导致任务执行时间远超预期。这个问题是所有分布式锁都会面临的问题,包括基于zookeeper和DB的分布式锁。这就是锁过期和客户端不知道锁过期的矛盾。在加锁的时候,我们一般都会给一个锁TTL,这是为了防止加锁后客户端崩溃,无法释放锁。但是这种姿势的所有用法都会面临同一个问题,就是不能保证client的执行时间一定小于锁的TTL。虽然大多数程序员会乐观地认为这种情况不会发生,但我曾经也这么认为,直到我一次次被现实击中。MartinKleppmann也对这一点提出了质疑,直接上他的图:Client1获取锁;Client1启动任务,然后发生STWGC,时间超过了锁的过期时间;Client2获取锁并启动任务;Client1的GC结束,任务继续。此时Client1和Client2都认为自己获取到了锁,都去处理任务,导致出错。MartinKleppmann举了一个GC的例??子,我遇到的是网络延迟。不管是什么情况,不可否认的是,这种情况是无法避免的,而且一旦发生,就很容易迷糊。如何解决?一种解决方案是不设置TTL,而是在获取锁成功后给锁加看门狗。看门狗会启动一个定时任务,在锁还没有释放,快要过期的时候重新开始。这个有点抽象,下面结合redisson源码说一下:}}@Overridepublicvoidlock(longleaseTime,TimeUnitunit){try{lockInterruptibly(leaseTime,unit);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}...}Redisson常用的加锁API就是上面两个,一个是没有通过EnterTTL。这时候redisson自己维护,会主动更新;另一种是自己传入TTL。这种redisson不会自动为我们续费,或者把leaseTime的值传给-1,但是不推荐这种方式,既然已经有现成的API了,何必用这种奇怪的写法呢。接下来分析下不传参的方法的加锁逻辑:publicclassRedissonLockextendsRedissonExpirableimplementsRLock{...publicstaticfinallongLOCK_EXPIRATION_INTERVAL_SECONDS=30;protectedlonginternalLockLeaseTime=TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);@Overridepublicvoidlock(){try{lockInterruptibly();}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}@OverridepublicvoidlockInterruptibly()throwsInterruptedException{lockInterruptibly(-1,null);}@OverridepublicvoidlockInterruptibly(longleaseTime,TimeUnitunit)throwsInterruptedException{longthreadId=Thread.currentThread().getId();Longttl=tryAcquire(leaseTime,unit,threadId);//lockacquiredif(ttl==null){return;}RFuturefuture=subscribe(threadId);commandExecutor.syncSubscription(future);try{while(true){ttl=tryAcquire(leaseTime,unit,threadId);//lockacquiredif(ttl==null){break;}//waitingformessageif(ttl>=0){getEntry(threadId).getLatch().tryAcquire(ttl,TimeUnit.MILLISECONDS);}其他{getEntry(threadId).getLatch().acquire();}}}finally{unsubscribe(future,threadId);}//get(lockAsync(leaseTime,unit));}privateLongtryAcquire(longleaseTime,TimeUnitunit,longthreadId){returnget(tryAcquireAsync(leaseTime,unit,threadId));}privateRFuturetryAcquireAsync(longleaseTime,TimeUnitunit,finallongthreadId){if(leaseTime!=-1){returntryLockInnerAsync(leaseTime,unit,threadId,RedisCommands.EVAL_LONG);}RFuturettlRemainingFuture=tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS,TimeUnit.SECONDS,threadId,RedisCommands.EVAL_LONG);ttlRemainingFuture.addListener(newFutureListener(){@OverridepublicvoidoperationComplete(Futurefuture)throwsException{if(!future.isSuccess()){return;}LongttlRemaining=future.getNow();//lockacquiredif(ttlRemaining==null){scheduleExpirationRenewal(threadId);}}});returnttlRemainingFuture;}privatevoidscheduleExpirationRenewal(finallongthreadId){if(expirationRenewalMap.containsKey(getEntryName())){return;}Timeouttask=commandExecutor.getConnectionManager().newTimeout(newTimerTask(){@Overridepublicvoidrun(Timeouttimeout)throwsException{RFuturefuture=commandExecutor.evalWriteAsync(getName(),LongCodec.INSTANCE,RedisCommands.EVAL_BOOLEAN,"if(redis.call('hexists',KEYS[1],ARGV[2])==1)then"+"redis.call('pexpire',KEYS[1],ARGV[1]);"+"return1;"+"end;"+"return0;",Collections.singletonList(getName()),internalLockLeaseTime,getLockName(threadId));future.addListener(newFutureListener(){@OverridepublicvoidoperationComplete(Futurefuture)throwsException{expirationRenewalMap.remove(getEntryName());if(!future.isSuccess()){log.error("Can'tupdatelock"+getName()+"expiration",future.cause());return;}if(future.getNow()){//rescheduleitselfscheduleExpirationRenewal(threadId);}}});}},internalLockLeaseTime/3,TimeUnit.MILLISECONDS);if(expirationRenewalMap.putIfAbsent(getEntryName(),task)!=null){task.cancel();}}...}可以看到最终加锁的逻辑会进入org.redisson.RedissonLock#中tryAcquireAsync,成功获取锁后,会进入scheduleExpirationRenewal,这里初始化一个定时器,延时时间为internalLockLeaseTime/3。在redisson中,internalLockLeaseTime为30s,即每10s更新一次,每次30s.如果是基于zookeeper的分布式锁,可以使用zookeeper检测节点是否存活,从而实现续约。Zookeeper分布式锁没用过,就不细说了。但是这种方式并不能100%保证同一时间只有一个客户端获取锁。如果更新失败,比如MartinKleppmann提到的STWGC,或者客户端和redis集群失去连接,只要更新失败,就会导致多个客户端同时获取锁。在我的场景下,我降低了锁粒度,redisson的更新机制就足够了。如果想做的更严格一点,就得加一个续费失败就终止任务的逻辑。这种做法在以前的Python代码中都有实现,Java没有遇到过这么严格的情况。这也是MartinKleppmann的解决方案。我觉得这个方案不靠谱,原因后面会说。他的方案是让被加锁的资源维护一套保证,多个客户端不会因为加锁失败而同时访问同一个资源。客户端在获取锁的同时,也获取了一个单调递增的资源令牌。每次写入资源时,都会检查当前的token是否是旧的token,如果是则不允许写入。对于上面的场景,Client1在获取锁的时候分配了33的token,在获取锁的时候分配了34的token。client1GC的时候,Client2已经写了资源,此时最大的token是34,client1从GC开始,回来用33的token写资源,会因为token过期而被拒绝。这种方法需要在资源端提供令牌生成器。对于这个fencing方案,我有几个问题:无法保证交易。示意图中只画了34次访问存储,但在实际场景中,一个任务内可能会有多次访问存储,而且必须是原子的。如果client1在GC之前用33token访问过一次storage,那么GC就发生了。client2获取到锁,访问storage,token为34,此时是否还能保证两个client写入的数据是正确的呢?如果不是,那么这个方案是有缺陷的,除非存储本身有其他机制来保证,比如事务机制;如果可以,那么这里的token就多余了,fencing方案就多余了。对于高并发场景不实用。因为每次只能写入最大的token,所以存储访问是线性的。在高并发场景下,这种方式会极大地限制吞吐量,分布式锁多用于此类场景。很矛盾的设计。这是所有分布式锁的问题。此方案为通用方案,可配合Redlock或其他锁使用。所以我理解这只是一个与Redlock无关的解决方案。系统时钟漂移的问题只是考虑过,在实际项目中并没有遇到,因为理论上是可以的,这里会提到。redis的过期时间取决于系统时钟。如果时钟漂移过大,会影响过期时间的计算。为什么系统时钟会漂移?先简单说一下系统时间。Linux提供两种系统时间:clockrealtime和clockmonotonic。实时时钟也是xtime/walltime。该时间可由用户或NTP更改。gettimeofday就是用这个时间,redis过期计算也是用这个时间。clockmonotonic,直译过来的单调时间,不会被用户改变,会被NTP改变。理想情况下,所有系统的时钟时刻都与NTP服务器同步,但这显然是不可能的。系统时钟漂移的原因有两个:系统时钟与NTP服务器不同步。这个目前没有特别好的解决方案,只能相信运维同学了。时钟实时被人为修改。实现分布式锁时不要使用实时时钟。不过可惜这次用的是redis。我查看了Redis5.0的源代码,它仍然使用实时时钟。Antirez说要改成clockmonotonic,老板还没改。也就是说,人为修改redis服务器的时间,会导致redis出现问题。小结本文从一个简单的基于redis的分布式锁开始,到Redlock较为复杂的实现,介绍了在使用分布式锁的过程中踩过的一些坑和解决方法。作者介绍了一个不忘本职的程序员陈汉礼。先后供职于物流金融集团、物流终端事业群、压力平衡集团。技术栈从Python玩到Java,还是没学会写好业务代码。他梦想使用抽象模型来拯救企业于水火之中。