当前位置: 首页 > 后端技术 > Java

舒服,踩到一个关于分布式锁的异常BUG!_0

时间:2023-04-01 19:17:34 Java

说到分布式锁,大家一般都会想到Redis。想到Redis,有些同学就会说起Redisson。那么说到Redisson,就不得不说说它的“看门狗”机制。那么您认为我会在本文中向您介绍“看门狗”吗?不是,主要是想向大家报告一下我最近在引入redisson带来的“watchdog”之后研究的两个bug,貌似菊花密密麻麻的bug:watchdog不工作的bug。看门狗死锁错误。为了让大家顺利入戏,先简单的给大家铺垫一下,什么是Redisson的看门狗。看门狗描述你去Redisson的wiki文档。锁这部分,开头提到了一个词:watchdogggithub.com/redisson/re...watchdog,意思是看门狗。它是做什么用的?好吧,如果你不能回答这个问题。那么当你遇到下面这些面试题的时候,你一定是一头雾水。面试官:当你用Redis做分布式锁的时候,如果指定的过期时间到了,就释放锁。但是任务还没有执行,导致任务又被执行了。你如何处理这种情况?这时候99%的面试官想要得到的答案就是看门狗,或者类似看门狗的机制。如果你说:我也遇到过这个问题,只是把过期时间设置的长一些而已。设置多长时间是您非常主观的判断。设置长一点可以一定程度上解决这个问题,但不能完全解决。所以,请返回并等待通知。或者你回答:我遇到过这个问题,我没有设置过期时间,程序调用unlock来保证。嗯,程序保证调用unlock方法没有错,这是程序层面可控和保证的。但是如果你的程序运行的服务器刚好在执行解锁之前崩溃了,你就不能保证这一点,对吧?这个锁死锁了吗?所以...为了解决上面提到的过期时间设置不好和意外死锁的问题,Redisson内部基于时间轮,对每把锁都有一个定时任务。这个定时任务就是看门狗。在Redisson实例关闭之前,这条狗可以通过定时任务不断延长锁的有效期。因为你根本不需要设置过期时间,这从根本上解决了“过期时间不好设置”的问题。看门狗检查锁超时时间默认为30秒,也可以通过修改参数指定。如果不幸节点挂了,没有执行解锁,那么在默认配置下最长30s后会自动释放锁。那么问题来了,面试官又追了一个问题:如何自动发布?这时候你只需要来个战术背:程序没了,你看定时任务还在吗?定时任务都没有了,就不会出现死锁问题了。demo之前简单介绍了原理,我做一个简单的demo给大家跑起来,比较直观。引入依赖,先不说启动Redis,直接看代码。示例代码很简单,就这么一点点内容,很常见的使用方法:启动项目,触发接口后,使用工具观察Redis中的keywhyLock的情况,是这样的:可以看到在我的截图,有一个过期时间,就是我打箭头的地方。那我给你拍一张动图。仔细看过期时间(TTL),有一个从20s到30s变化的过程:首先,我们的代码中没有设置过期时间的action,也没有去更新这个action的过期时间。那么这东西是怎么回事呢?很简单,Redisson已经为我们做好了这些事情,开箱即用,而且是作为一个黑盒子来完成的。下面我就带大家把黑盒变成白盒,然后引出上面提到的两个bug。在我的测试用例中,使用的是Redission版本3.16.0。我们先找到它的设置过期动作的源码。首先大家可以看到,虽然我调用的lock方法没有参数,但其实只是一层皮而已。依然是调用带参数的lock方法,只是给了几个默认值,其中leaseTime给的是-1:带参数的lock源码如下。我主要关注我框架的那行代码:tryAcquire方法是它的核心逻辑,那么这个方法是做什么的呢?点击查看,这部分源码又是这样的:tryLockInnerAsync方法是执行Redis的Lua脚本进行加锁。既然是加锁的,这里就必须设置过期时间,这里就是leaseTime:而这里的leaseTime是在构造函数中初始化的。在我的Demo中,使用了配置中的默认值,即30s:那么,为什么我们的代码中没有设置过期时间的action,但是对应的key却有过期时间呢?这里的源代码回答了这个问题。另外,这个时间是从配置中获取的,所以一定是可以自定义的,不一定非要30s。另一件需要注意的事情是,在这里,我们有两个不同的leaseTimes。它们如下:在我们的示例中,tryAcquireOnceAsync方法的leaseTime为-1。tryLockInnerAsync方法的入参是leaseTime,在我们的例子中是默认值30*1000。加完锁后,就轮到看门狗工作了:前面说过,这里的leaseTime为-1,所以触发了else分支中的scheduleExpirationRenewal代码。而这段代码就是启动看门狗的代码。也就是说,如果这里的leaseTime不为-1,那么看门狗就不会启动。那么如何让leaseTime不为-1呢?自己指定锁定时间:用人话来说,如果你在锁定的时候指定了一个过期时间,那么Redission是不会为你开启watchdog机制的。这一点是无数不了解看门狗机制的人都会记错的一点。曾经在群里争论过,结果被别人拿着源码打了一顿。是的,我认为看门狗会在指定到期时间后继续工作。打脸总是很痛,希望大家不要重蹈覆辙。接下来我们看一下scheduleExpirationRenewal的代码:它把当前线程封装成一个对象,然后维护在一个MAP中。这张地图非常重要。让我先把它放在这里让大家熟悉。后面再说:你只要记住这个MAP的key是当前线程,value是ExpirationEntry对象即可。该对象维护当前线程的锁数。.那么,我们先看看scheduleExpirationRenewal方法中的情况,调用了MAP的putIfAbsent方法后,返回的oldEntry为空。这种情况说明是第一次加锁,会触发renewExpiration方法,这是watchdog的核心逻辑。在scheduleExpirationRenewal方法中,无论上述oldEntry是否为空,都会触发addThreadId方法:从源码可以看出,这里只是对当前线程的锁数进行了维护。这个维护很好理解,因为如果要支持锁的重入,就得记录重入了多少次。加一次锁,次数加一。一旦解锁,次数将减少一次。再看看renewExpiration方法,这才是看门狗的真面目:首先,这堆逻辑主要是基于时间轮的定时任务。标④的地方就是这个定时任务触发的时间条件:internalLockLeaseTime/3。之前说过,internalLockLeaseTime默认是30*1000,所以这里默认是每10秒执行一次续命任务。这从我之前给的动态也能看出来。ttl时间从30变成20,然后突然从20变成30。标有①和②的地方做同样的事情,就是检查当前线程是否还有效。如何判断是否有效?就是看上面说的MAP中是否有当前线程对应的ExpirationEntry对象。如果没有,则表示它已被删除。那么问题来了。大家在看源码的时候,自然应该会想到这样一个问题:这个MAP的remove方法是什么时候调用的?很快,在接下来释放锁的地方就可以看到对应的remove了。先在这里提一下,待会儿再附和。核心逻辑在③处。我将带您完成它,重点放在我强调的内容上。这里能够走到③,说明当前线程的业务逻辑还没有执行完,还需要持有锁。首先看renewExpirationAsync方法。从方法名我们也可以看出这是在重置过期时间:上面的源码主要是一个lua脚本,这个脚本的逻辑非常简单。就是判断锁是否还存在,持有锁的线程是否是当前线程。如果是当前线程,重新设置锁的过期时间,返回1,即返回true。如果锁不存在,或者锁没有被当前线程持有,则返回0,即返回false。然后在标有③的地方,首先判断renewExpirationAsync方法的执行是否有异常。那么问题来了,什么会异常呢?这个地方的异常主要是因为需要去Redis执行命令,所以如果Redis出现问题,比如卡死,或者掉线,或者连接池没有连接等等,命令可能不会执行被执行,导致异常。如果出现异常,执行下面一行代码:EXPIRATION_RENEWAL_MAP.remove(getEntryName());然后返回,定时任务结束。嗯,记住这个remove操作很重要,先熟悉一下,后面再说。如果在执行renewExpirationAsync方法时没有异常。此时的返回值是true还是false。如果为true,则表示更新成功,然后再次调用renewExporation方法,等待时间轮下次触发。如果为false,则表示该锁已丢失或已易手。然后就和当前线程没有关系了,也不需要做什么,默默结束就好了。Lock和watchdog的一些基本原理如前所述。然后简单的看看unlock方法里面是怎么回事。首先是unlockInnerAsync方法,是lua脚本释放锁的逻辑:该方法返回Boolean,分三种情况。返回null表示锁不存在,或者锁存在但值不匹配,说明锁已经被其他线程占用。返回true表示锁存在,线程正确,重入次数已经降为零,可以释放锁。返回false表示锁存在,线程正确,但是重入次数不为零,还不能释放锁。但是可以看到unlockInnerAsync是如何处理这个返回值的:返回值,也就是opStatus,只是判断return为null,抛出异常说明锁没有被当前线程持有,就结束了。它不关心它返回的是真还是假。然后看cancelExpirationRenewal(threadId);我陷害的方法:里面有个remove方法。而前面这么多铺垫其实就是为了引出这个cancelExpirationRenewal方法。看看锁定和解锁。MAP的操作,看下图:标①的地方是locking,调用了MAP的put方法。标②的地方就是放锁,调用MAP的remove方法。记住上面的分析和操作这个MAP的时机。下面提到的bug都是因为这个MAP操作不当造成的。前面看门狗不起作用的BUG,我找了一个版本给大家看源码,主要是让大家跑一下demo。毕竟引入maven依赖的代价要小很多。但是如果真的要研究源码,还是得先把源码拉下来,慢慢啃。直接拉取项目源码的好处,在之前的文章中已经多次提到。对我来说,目的只有三个:保证源码最新,看代码提交记录,找官方测试用例好了,不多说了,先来看开头提到的第一个BUG:问题看门狗不生效。从这一期开始:github.com/redisson/re...这一期他给了一段代码,然后说他的预期结果是,如果watchdog更新的时候程序和redis之间有连接问题期间,会导致锁自动过期,所以如果我再次申请同一个锁,应该是让看门狗重新工作。但实际情况是,即使之前的锁因为连接异常而过期,程序成功申请了新锁,但是新锁会在30秒后自动过期,也就是看门狗不会工作。这个issue对应的pr是这样的:github.com/redisson/re...在这个pr中,提供了一个测试用例,我们可以直接在源码中找到:org.redisson.RedissonLockExpirationRenewalTest这个就是拉取的好处源代码。本次测试用例中,核心逻辑如下:首先需要说明的是,本次测试用例中,将看门狗的lockWatchdogTimeout参数改为1000ms:即看门狗的定时任务每333ms会执行一次Trigger。然后我们看标①的地方,先申请锁,然后Redis重启,重启导致锁失效,比如还没来得及持久化,或者已经持久化了,但是重启时间超过1s,锁就没了。所以调用unlock方法的时候,肯定会抛出IllegalMonitorStateException,表示锁没了。到目前为止,一切正常且可以理解。但是请看标有②的地方。加锁后,业务逻辑执行2s,肯定会触发看门狗续命的操作。在修复这个bug之前,这里调用unlock方法也会抛出IllegalMonitorStateException,说明锁没了:先不说为什么,至少这肯定是个bug。因为按照正常的逻辑,这个锁应该一直被更新,然后直到调用unlock时才应该释放。嗯,bug的demo你也看到了,可以复现。你认为是什么原因?其实我早该给你写答案的,就看你能不能反应过来这一波。首先前提是两个加锁线程是一样的,然后我没有特意强调oldEntry这个东西:上面的bug会出现,说明第二次加锁的时候MAP中存在oldEntry,所以我误认为当前的看门狗在工作,直接进入可重入锁的逻辑即可。为什么第二次加锁时MAP中会存在oldEntry?因为第一次解锁时,并没有从MAP中移除当前线程的ExpirationEntry对象。为什么不删除它?看看这哥们测试的Redisson版本:这个版本,释放锁的逻辑是这样的:咦,不对,不是有cancelExpirationRenewal(threadId)的逻辑吗?是的,有。但是你看这个逻辑在什么情况下会被执行。首先是有异常,但是在我们的测试用例中,Redis调用unlock两次是正常的,不会抛出异常。然后当opStatus不为空时执行逻辑。也就是说,当opStatus为null时,即当前锁没有了,或者所有者发生了变化,都不会触发cancelExpirationRenewal(threadId)的逻辑。巧合的是,在我们的场景中,第一次调用unlock方法时,由于Redis重启导致锁没了,所以这里返回的opStatus为null,没有触发cancelExpirationRenewal方法的逻辑。结果我在当前线程第二次调用lock的时候,来的时候oldEntry不为空:so,重入的逻辑没有了,watchdog也没有启动。由于没有激活看门狗,1000ms后锁会自动释放,可以被其他线程抢走使用。那么当前线程的业务逻辑执行完毕,第二次调用unlock当然会抛出异常。这就是BUG的根本原因。只要找到问题所在,一行代码就可以解决:只要调用了unlock方法,不管怎样,先调用cancelExpirationRenewal(threadId)方法就对了。这是没有及时从MAP中移除当前线程对应的对象导致的bug。再看看另一个问题:github.com/redisson/re...问题是,如果我的锁因为某种原因没了,我在程序中再次获取到后,watchdog应该还能继续工作。听起来像是同一个问题,对吧?对,就是说同一个问题。但是对于这个问题,提交的代码如下:在watchdog中,如果watchdog续命失败,说明锁不存在,即res返回false,然后主动执行cancelExpirationRenewal方法,方便后期补充。加锁成功的线程让路,以免耽误别人开启看门狗机制。这样就有了双重保障,unlock和watchdog都会触发cancelExpirationRenewal的逻辑,而且这两个逻辑不会冲突。另外提醒一下,最后提交的代码是这样的,两个方法的入参不一样:为什么要把threadId改成null呢?留下一道思想题,是从重入的角度考虑的,大家可以自己研究一下,很简单。看门狗导致死锁的BUG这个BUG解释起来很简单。看看这个问题:github.com/redisson/re...重现的步骤这里写的很清楚。测试程序是这样的。是定时任务1s触发一次,但是任务会执行2s,会导致锁重入:他这里提到了一个命令:CLIENTPAUSE5000主要是模拟Redis处理请求的超时时间,就是让Redis假死5s,让程序发送的请求超时。这样的话,重入的逻辑就会混乱。看一下这个bug修复对应的关键代码之一:无论opStatus返回false还是true,都会执行cancelExpirationRenewal逻辑。解决问题的关键在于MAP的运行。另外,多说一句。同样在本次提交中,将维护和重入逻辑封装到了ExpirationEntry对象中,相比之前的写法优雅了许多。有兴趣的可以拉下源码对比一下,感受一下什么叫优雅重构:线程中断写文章的时候还发现了一个有趣的bug,Redisson没有解决方案。就是这样:当我第一次看到这段代码时,我感到很奇怪。如此奇怪的写作方式背后一定有一个故事。这背后对应的故事隐藏在本期:github.com/redisson/re...翻译一下,意思是当tryLock方法中断时,watchdog会继续更新锁,从而造成无限锁。那就是僵局。我们看一下对应的测试用例:开启一个子线程,在子线程中执行tryLock方法,然后在主线程中调用子线程的interrupt方法。你觉得这个时候子线程应该做什么呢?按理说,线程中断了,看门狗不应该工作吗?是的,这就是这段代码的来源:但是,如果您仔细观察,这几行代码并不能完全解决看门狗问题。只有在第一次调用renewExpiration方法后,定时任务启动前,才能有一定的概率解决。因此,测试用例中的休眠时间只有5ms:如果这个时间更长,就会触发看门狗机制。一旦触发看门狗机制,触发renewExpiration方法的线程就会成为定时任务的线程。你的外部子线程中断了,跟我定时任务的线程有什么关系?比如我把这几行代码移到这里:其实没用:因为线程变了。对于这个问题,官方的回答是这样的:大概意思是:嗯,你说的有道理,但是Redisson的看门狗的工作范围是整个实例,而不是指定的线程。意想不到的收获最后,又是一个意想不到的收获:可以看到addThreadId方法被重构了一次。但是这个重构有一个问题。原来的逻辑是,当counter为null时,初始化为1,不为null时,执行counter++,即重入。重构后的逻辑是,当counter为null时,先初始化为1,然后立即执行counter++。不就是counter直接变成了2,跟原来的逻辑不一样吗?是的,这是不同的。调试的时候一头雾水,后来发现是这个地方有问题。那不好意思,意外的收获,来混个pr: