踩到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;}RFuture
