关于后端:舒服了踩到一个关于分布式锁的非比寻常的BUG

32次阅读

共计 7866 个字符,预计需要花费 20 分钟才能阅读完成。

提到分布式锁,大家个别都会想到 Redis。
想到 Redis,一部分同学会说到 Redisson。
那么说到 Redisson,就不得不掰扯掰扯一下它的“看门狗”机制了。
所以你认为这篇文章我要给你讲“看门狗”吗?
不是,我次要是想给你汇报一下我最近钻研的因为引入“看门狗”之后,给 Redisson 带来的两个看起来就菊花一紧的 bug:

看门狗不失效的 BUG。
看门狗导致死锁的 BUG。

为了能让你丝滑入戏,我还是先简略的给你铺垫一下,Redisson 的看门狗到底是个啥货色。

看门狗形容
你去看 Redisson 的 wiki 文档,在锁的这一部分,开篇就提到了一个单词:watchdog

github.com/redisson/re…

watchdog,就是看门狗的意思。
它是干啥用的呢?
好的,如果你答复不上来这个问题。那当你遇到上面这个面试题的时候必定懵逼。
面试官:请问你用 Redis 做分布式锁的时候,如果指定过期工夫到了,把锁给开释了。然而工作还未执行实现,导致工作再次被执行,这种状况你会怎么解决呢?
这个时候,99% 的面试官想得到的答复都是看门狗,或者一种相似于看门狗的机制。
如果你说:这个问题我遇到过,然而我就是把过期工夫设置的长一点。
工夫到底设置多长,是你一个十分主观的判断,设置的长一点,能肯定水平上解决这个问题,然而不能齐全解决。
所以,请回去等告诉吧。
或者你答复:这个问题我遇到过,我不设置过期工夫,由程序调用 unlock 来保障。
好的,程序保障调用 unlock 办法没故障,这是在程序层面可控、可保障的。然而如果你程序运行的服务器刚好还没来得及执行 unlock 就宕机了呢,这个你不能打包票吧?
这个锁是不是就死锁了?
所以 ……

为了解决后面提到的过期工夫不好设置,以及一不小心死锁的问题,Redisson 外部基于工夫轮,针对每一个锁都搞了一个定时工作,这个定时工作,就是看门狗。
在 Redisson 实例被敞开前,这个狗子能够通过定时工作一直的缩短锁的有效期。
因为你基本就不须要设置过期工夫,这样就从根本上解决了“过期工夫不好设置”的问题。默认状况下,看门狗的查看锁的超时工夫是 30 秒钟,也能够通过批改参数来另行指定。
如果很可怜,节点宕机了导致没有执行 unlock,那么在默认的配置下最长 30s 的工夫后,这个锁就主动开释了。
那么问题来了,面试官紧接着来一个诘问:怎么主动开释呢?
这个时候,你只须要来一个战术后仰:程序都没了,你感觉定时工作还在吗?定时工作都不在了,所以也不会存在死锁的问题。

搞 Demo
后面简略介绍了原理,我也还是给你搞个简略的 Demo 跑一把,这样更加的直观。
引入依赖,启动 Redis 什么的就不说了,间接看代码。
示例代码非常简单,就这么一点内容,十分惯例的应用办法:

把我的项目启动起来,触发接口之后,通过工具察看 Redis 外面 whyLock 这个 key 的状况,是这样的:

你能够看到在我的截图外面,是有过期工夫的,也就是我打箭头的中央。
而后我给你搞个动图,你认真看过期工夫(TTL)这个中央,有一个从 20s 变回 30s 的过程:

首先,咱们的代码外面并没有设置过期工夫的动作,也没有去更新过期工夫的这个动作。
那么这个货色是怎么回事呢?

很简略,Redisson 帮咱们做了这些事件,开箱即用,当个黑盒就完事了。
接下来我就是带你把黑盒变成白盒,而后引出后面提到的两个 bug。
我的测试用例外面用的是 3.16.0 版本的 Redission,咱们先找一下它对于设置过期动作的源码。
首先能够看到,我尽管调用的是无参的 lock 办法,然而它其实也只是一层皮而已,外面还是调用了带入参的 lock 办法,只不过给了几个默认值,其中 leaseTime 给的是 -1:

而有参的 lock 的源码是这样的,次要把注意力放到我框起来的这一行代码中:

tryAcquire 办法是它的外围逻辑,那么这个办法是在干啥事儿呢?
点进去看看,这部分源码又是这样的:

其中 tryLockInnerAsync 办法就是执行 Redis 的 Lua 脚本来加锁。
既然是加锁了,过期工夫必定就是在这里设置的,也就是这里的 leaseTime:

而这里的 leaseTime 是在构造方法外面初始化的,在我的 Demo 外面,用的是配置中的默认值,也就是 30s :

所以,为什么咱们的代码外面并没有设置过期工夫的动作,然而对应的 key 却有过期工夫呢?
这里的源码答复了这个问题。
额定提一句,这个工夫是从配置中获取的,所以必定是能够自定义的,不肯定非得是 30s。
另外须要留神的是,到这里,咱们呈现了两个不同的 leaseTime。
别离是这样的:

tryAcquireOnceAsync 办法的入参 leaseTime,咱们的示例中是 -1。
tryLockInnerAsync 办法的入参 leaseTime,咱们的示例中是默认值 30 * 1000。

在后面加完锁之后,紧接着就轮到看门狗工作了:

后面我说了,这里的 leaseTime 是 -1,所以触发的是 else 分支中的 scheduleExpirationRenewal 代码。
而这个代码就是启动看门狗的代码。
换句话说,如果这里的 leaseTime 不是 -1,那么就不会启动看门狗。
那么怎么让 leaseTime 不是 -1 呢?
本人指定加锁工夫:

说人话就是如果加锁的时候指定了过期工夫,那么 Redission 不会给你开启看门狗的机制。
这个点是有数人对看门狗机制不分明的人都会记错的一个点,我已经在一个群外面据理力争,起初被他人拿着源码一顿乱捶。

是的,我就是那个认为指定了过期工夫之后,看门狗还会持续工作的人。
打脸老疼了,心愿你不要步后尘。
接着来看一下 scheduleExpirationRenewal 的代码:

外面就是把以后线程封装成了一个对象,而后保护到一个 MAP 中。
这个 MAP 很重要,我先把它放到这里,混个眼生,一会再说它:

你只有记住这个 MAP 的 key 是以后线程,value 是 ExpirationEntry 对象,这个对象保护的是以后线程的加锁次数。

而后,咱们先看 scheduleExpirationRenewal 办法外面,调用 MAP 的 putIfAbsent 办法后,返回的 oldEntry 为空的状况。
这种状况阐明是第一次加锁,会触发 renewExpiration 办法,这个办法外面就是看门狗的外围逻辑。
而在 scheduleExpirationRenewal 办法外面,不论后面提到的 oldEntry 是否为空,都会触发 addThreadId 办法:

从源码中能够看进去,这里仅仅对以后线程的加锁次数进行一个保护。
这个保护很好了解,因为要反对锁的重入嘛,就得记录到底重入了几次。
加锁一次,次数加一。解锁一次,次数减一。
接着看 renewExpiration 办法,这就是看门狗的真面目了:

首先这一坨逻辑次要就是一个基于工夫轮的定时工作。
标号为 ④ 的中央,就是这个定时工作触发的工夫条件:internalLockLeaseTime / 3。
后面我说了,internalLockLeaseTime 默认状况下是 30* 1000,所以这里默认就是每 10 秒执行一次续命的工作,这个从我后面给到的动静外面也能够看出,ttl 的工夫先从 30 变成了 20,而后一下又从 20 变成了 30。
标号为 ①、② 的中央干的是同一件事,就是查看以后线程是否还无效。
怎么判断是否无效呢?
就是看后面提到的 MAP 中是否还有以后线程对应的 ExpirationEntry 对象。
没有,就阐明是被 remove 了。
那么问题就来了,你看源码的时候十分自然而然的就应该想到这个问题:什么时候调用这个 MAP 的 remove 办法呢?
很快,在接下来讲开释锁的中央,你就能够看到对应的 remove。这里先提一下,前面就能响应上了。
外围逻辑是标号为 ③ 的中央。我带你认真看看,次要关注我加了下划线的中央。
能走到 ③ 这里阐明以后线程的业务逻辑还未执行实现,还须要持续持有锁。
首先看 renewExpirationAsync 办法,从办法命名上咱们也能够看进去,这是在重置过期工夫:

下面的源码次要是一个 lua 脚本,而这个脚本的逻辑非常简单。就是判断锁是否还存在,且持有锁的线程是否是以后线程。如果是以后线程,重置锁的过期工夫,并返回 1,即返回 true。
如果锁不存在,或者持有锁的不是以后线程,那么则返回 0,即返回 false。
接着标号为 ③ 的中央,外面首先判断了执行 renewExpirationAsync 办法是否有异样。
那么问题就来了,会有什么异样呢?
这个中央的异样,次要是因为要到 Redis 执行命令嘛,所以如果 Redis 出问题了,比方卡住了,或者掉线了,或者连接池没有连贯了等等各种状况,都可能会执行不了命令,导致异样。
如果出现异常了,则执行上面这行代码:

EXPIRATION_RENEWAL_MAP.remove(getEntryName());

而后就 return,这个定时工作就完结了。
好,记住这个 remove 的操作,十分重要,先混个眼生,一会会讲。
如果执行 renewExpirationAsync 办法的时候没有异样。这个时候的返回值就是 true 或者 false。
如果是 true,阐明续命胜利,则再次调用 renewExporation 办法,期待着工夫轮触发下一次。
如果是 false,阐明这把锁曾经没有了,或者易主了。那么也就没有以后线程什么事件了,啥都不必做,默默的完结就行了。
上锁和看门狗的一些基本原理就是后面说到这么多。
接着简略看看 unlock 办法外面是怎么回事儿的。

首先是 unlockInnerAsync 办法,这外面就是 lua 脚本开释锁的逻辑:

这个办法返回的是 Boolean,有三种状况。

返回为 null,阐明锁不存在,或者锁存在,然而 value 不匹配,示意锁曾经被其余线程占用。
返回为 true,阐明锁存在,线程也是对的,重入次数曾经减为零,锁能够被开释。
返回为 false,阐明锁存在,线程也是对的,然而重入次数还不为零,锁还不能被开释。

然而你看 unlockInnerAsync 是怎么解决这个返回值的:

返回值,也就是 opStatus,仅仅是判断了返回为 null 的状况,抛出异样表明这个锁不是被以后线程持有的,完事。
它并不关怀返回为 true 或者为 false 的状况。
而后再看我框起来的 cancelExpirationRenewal(threadId); 办法:

这外面就有 remove 办法。
而后面铺垫了这么多其实就是为了引出这个 cancelExpirationRenewal 办法。
纵观一下加锁和解锁,针对 MAP 的操作,看一下上面的这个图片:

标号为 ① 的中央是加锁,调用 MAP 的 put 办法。
标号为 ② 的中央是放锁,调用 MAP 的 remove 办法。
记住下面这一段剖析,和操作这个 MAP 的机会,上面说的 BUG 都是因为对这个 MAP 的操作不失当导致的。
看门狗不失效的 BUG
后面找了一个版本给大家看源码,次要是为了让大家把 Demo 跑起来,毕竟引入 maven 依赖的老本是小很多的。
然而真的要钻研源码,还是得把先把源码拉下来,缓缓的啃起来。
间接拉我的项目源码的益处我在之前的文章外面曾经说很屡次了,对我而言,无外乎就三个目标:

能够保障是最新的源码
能够看到代码的提交记录
能够找到官网的测试用例

好,话不多说,首先咱们看看开篇说的第一个 BUG:看门狗不失效的问题。
从这个 issues 说起:

github.com/redisson/re…

在这个 issues 外面,他给到了一段代码,而后说他预期的后果是在看门狗续命期间,如果呈现程序和 Redis 的连贯问题,导致锁主动过期了,那么我再次申请同一把锁,应该是让看门狗再次工作才对。
然而理论的状况是,即便前一把锁因为连贯异样导致过期了,程序再胜利申请到一把新锁,然而这个新的锁,30s 后就主动过期了,即看门狗不会工作。
这个 issues 对应的 pr 是这个:

github.com/redisson/re…

在这个 pr 外面,提供了一个测试用例,咱们能够间接在源码外面找到:

org.redisson.RedissonLockExpirationRenewalTest

这就是拉源码的益处。
在这个测试用例外面,外围逻辑是这样的:

首先须要阐明的是,在这个测试用例外面,把看门狗的 lockWatchdogTimeout 参数批改为 1000 ms:

也就是说看门狗这个定时工作,每 333ms 就会触发一次。

而后咱们看标号为 ① 的中央,先申请了一把锁,而后 Redis 产生了一次重启,重启导致这把锁生效了,比方还没来得及长久化,或者长久化了,然而重启的工夫超过了 1s,这锁就没了。
所以,在调用 unlock 办法的时候,必定会抛出 IllegalMonitorStateException 异样,示意这把锁没了。
到这里一切正常,还能了解。
然而看标号为 ② 的中央。
加锁之后,业务逻辑会执行 2s,必定会触发看门狗续命的操作。
在这个 bug 修复之前,在这里调用 unlock 办法也会抛出 IllegalMonitorStateException 异样,示意这把锁没了:

先不说为啥吧,至多这妥妥的是一个 Bug 了。

因为依照失常的逻辑,这个锁应该始终被续命,而后直到调用 unlock 才应该被开释。
好,bug 的演示你也看到了,也能够复现了。你猜是什么起因?
答案其实我在后面应该给你写进去了,就看这波前后响应你能不能反馈过去了。
首先前提是两次加锁的线程是同一个,而后我后面不是特意强调了 oldEntry 这个玩意吗:

下面这个 bug 能呈现,阐明第二次 lock 的时候 oldEntry 在 MAP 外面是存在的,因而误以为以后看门狗正在工作,间接进入重入锁的逻辑即可。
为什么第二次 lock 的时候 oldEntry 在 MAP 外面是存在的呢?
因为第一次 unlock 的时候,没有从 MAP 外面把以后线程的 ExpirationEntry 对象移走。
为什么没有移走呢?
看一下这个哥们测试的 Redisson 版本:

在这个版本外面,开释锁的逻辑是这样的:

诶,不对呀,这不是有 cancelExpirationRenewal(threadId) 的逻辑吗?
没错,的确有。
然而你看什么状况下会执行这个逻辑。
首先是出现异常的状况,然而在咱们的测试用例中,两次调用 unlock 的时候 Redis 是失常的,不会抛出异样。
而后是 opStatus 不为 null 的时候会执行该逻辑。
也就是说 opStatus 为 null 的时候,即以后锁没有了,或者易主了的时候,不会触发 cancelExpirationRenewal(threadId) 的逻辑。
巧了,在咱们的场景外面,第一次调用 unlock 办法的时候,就是因为 Redis 重启导致锁没有了,因而这里返回的 opStatus 为 null,没有触发 cancelExpirationRenewal 办法的逻辑。
导致我第二次在以后线程中调用 lock 的时候,走到上面这里的时候,oldEntry 不为空:

所以,走了重入的逻辑,并没有启动看门狗。
因为没有启动看门狗,导致这个锁在 1000ms 之后就主动开释了,能够被别的线程抢走拿去用。
随后以后线程业务逻辑执行实现,第二次调用 unlock,当然就会抛出异样了。
这就是 BUG 的根因。
找到问题就好了,一行代码就能解决:

只有调用了 unlock 办法,不论怎么样,先调用 cancelExpirationRenewal(threadId) 办法,准没错。
这就是因为没有及时从 MAP 外面移走以后线程对应的对象,导致的一个 BUG。
再看看另外一个的 issue:

github.com/redisson/re…

这个问题是说如果我的锁因为某些起因没了,当我在程序外面再次获取到它之后,看门狗应该持续工作。
听起来,说的是同一个问题对不对?
是的,就是说的同一个问题。
然而这个问题,提交的代码是这样的:

在看门狗这里,如果看门狗续命失败,阐明锁不存在了,即 res 返回为 false,那么也被动执行一下 cancelExpirationRenewal 办法,不便为前面的加锁胜利的线程让路,免得耽搁他人开启看门狗机制。
这样就能有双重保障了,在 unlock 和看门狗外面都会触发 cancelExpirationRenewal 的逻辑,而且这两个逻辑也并不会抵触。

另外,我揭示一下,最终提交的代码是这样的,两个办法入参是不一样的:

为什么从 threadId 批改为 null 呢?
留个思考题吧,就是从重入的角度思考的,能够本人去钻研一下,很简略的。
看门狗导致死锁的 BUG
这个 BUG 解释起来就很简略了。
看看这个 issue:

github.com/redisson/re…

在这里把复现的步骤都写的清清楚楚的。
测试程序是这样的,通过定时工作 1s 触发一次,然而工作会执行 2s,这样就会导致锁的重入:

他这里提到一个命令:

CLIENT PAUSE 5000

次要还是模仿 Redis 解决申请超时的状况,就是让 Redis 假死 5s,这样程序发过来的申请就会超时。
这样,重入的逻辑就会产生凌乱。
看一下这个 bug 修复的对应的要害代码之一:

不论 opStatus 返回为 false 还是 true,都执行 cancelExpirationRenewal 逻辑。
问题的解决之道,还是在于对 MAP 的操作。
另外,多提一句。
也是在这次提交中,把保护重入的逻辑封装到了 ExpirationEntry 这个对象外面,比起之前的写法优雅了很多,有趣味的能够把源码拉下来进行一下比照,感受一下什么叫做优雅的重构:

线程中断
在写文章的时候,我还发现一个有意思的,但对于 Redisson 无解的 bug。
就是这里:

我第一眼看到这一段代码就很奇怪,这样奇怪的写法,背地必定是有故事的。
这背地对应的故事,藏在这个 issue 外面:

github.com/redisson/re…

翻译过去,说的是当 tryLock 办法被中断时,看门狗还是会一直地更新锁,这就造成了有限锁,也就是死锁。
咱们看一下对应的测试用例:

开启了一个子线程,在子线程外面执行了 tryLock 的办法,而后主线程外面调用了子线程的 interrupt 办法。
你说这个时候子线程应该怎么办?
按理来说,线程被中断了,是不是看门狗也不应该工作呢?
是的,所以这样的代码就呈现了:

然而,你细品,这几行代码并没有齐全解决看门狗的问题。只能在肯定概率上解决第一次调用后 renewExpiration 办法后,还没来得及启动定时工作之前的这一小段时间。
所以,测试案例外面的 sleep 工夫,只有 5ms:

这工夫要是再长一点,就会触发看门狗机制。
一旦触发看门狗机制,触发 renewExpiration 办法的线程就会变成定时工作的线程。
你里面的子线程 interrupt 了,和我定时工作的线程有什么关系?
比方,我把这几行代码移动到这里:

其实没有任何卵用:

因为线程变了。
对于这个问题,官网的答复是这样的:

大略意思就是说:嗯,你说的很有情理,然而 Redisson 的看门狗工作范畴是整个实例,而不是某个指定的线程。
意外播种
最初,再来一个意外播种:

你看 addThreadId 这个办法重构了一次。
然而这次重构就呈现问题了。
原来的逻辑是当 counter 是 null 的时候,初始化为 1。不为 null 的时候,就执行 counter++,即重入。
重构之后的逻辑是当 counter 是 null 的时候,先初始化为 1,而后紧接着执行 counter++。
那岂不是 counter 间接就变成了 2,和原来的逻辑不一样了?
是的,不一样了。
搞的我 Debug 的时候一脸懵逼,起初才发现这个中央呈现问题了。

那就不好意思了,意外播种,混个 pr 吧:

正文完
 0