舒服了,踩到一個關(guān)于分布式鎖的非比尋常的BUG!
提到分布式鎖,大家一般都會想到 Redis。
想到 Redis,一部分同學會說到 Redisson。
那么說到 Redisson,就不得不掰扯掰扯一下它的“看門狗”機制了。
所以你以為這篇文章我要給你講“看門狗”嗎?
不是,我主要是想給你匯報一下我最近研究的由于引入“看門狗”之后,給 Redisson 帶來的兩個看起來就心里一緊的 bug :
看門狗不生效的 BUG。 看門狗導致死鎖的 BUG。
為了能讓你絲滑入戲,我還是先簡單的給你鋪墊一下,Redisson 的看門狗到底是個啥東西。

看門狗描述
你去看 Redisson 的 wiki 文檔,在鎖的這一部分,開篇就提到了一個單詞:watchdog
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers

watchdog,就是看門狗的意思。
它是干啥用的呢?
好的,如果你回答不上來這個問題。那當你遇到下面這個面試題的時候肯定懵逼。
面試官:請問你用 Redis 做分布式鎖的時候,如果指定過期時間到了,把鎖給釋放了。但是任務(wù)還未執(zhí)行完成,導致任務(wù)再次被執(zhí)行,這種情況你會怎么處理呢?
這個時候,99% 的面試官想得到的回答都是看門狗,或者一種類似于看門狗的機制。
如果你說:這個問題我遇到過,但是我就是把過期時間設(shè)置的長一點。
時間到底設(shè)置多長,是你一個非常主觀的判斷,設(shè)置的長一點,能一定程度上解決這個問題,但是不能完全解決。
所以,請回去等通知吧。
或者你回答:這個問題我遇到過,我不設(shè)置過期時間,由程序調(diào)用 unlock 來保證。
好的,程序保證調(diào)用 unlock 方法沒毛病,這是在程序?qū)用婵煽?、可保證的。但是如果你程序運行的服務(wù)器剛好還沒來得及執(zhí)行 unlock 就宕機了呢,這個你不能打包票吧?
這個鎖是不是就死鎖了?
所以......

為了解決前面提到的過期時間不好設(shè)置,以及一不小心死鎖的問題,Redisson 內(nèi)部基于時間輪,針對每一個鎖都搞了一個定時任務(wù),這個定時任務(wù),就是看門狗。
在 Redisson 實例被關(guān)閉前,這個狗子可以通過定時任務(wù)不斷的延長鎖的有效期。
因為你根本就不需要設(shè)置過期時間,這樣就從根本上解決了“過期時間不好設(shè)置”的問題。默認情況下,看門狗的檢查鎖的超時時間是 30 秒鐘,也可以通過修改參數(shù)來另行指定。
如果很不幸,節(jié)點宕機了導致沒有執(zhí)行 unlock,那么在默認的配置下最長 30s 的時間后,這個鎖就自動釋放了。
那么問題來了,面試官緊接著來一個追問:怎么自動釋放呢?
這個時候,你只需要來一個戰(zhàn)術(shù)后仰:程序都沒了,你覺得定時任務(wù)還在嗎?定時任務(wù)都不在了,所以也不會存在死鎖的問題。

搞 Demo
前面簡單介紹了原理,我也還是給你搞個簡單的 Demo 跑一把,這樣更加的直觀。
引入依賴,啟動 Redis 什么的就不說了,直接看代碼。
示例代碼非常簡單,就這么一點內(nèi)容,非常常規(guī)的使用方法:

把項目啟動起來,觸發(fā)接口之后,通過工具觀察 Redis 里面 whyLock 這個 key 的情況,是這樣的:

你可以看到在我的截圖里面,是有過期時間的,也就是我打箭頭的地方。
然后我給你搞個動圖,你仔細看過期時間(TTL)這個地方,有一個從 20s 變回 30s 的過程:

首先,我們的代碼里面并沒有設(shè)置過期時間的動作,也沒有去更新過期時間的這個動作。
那么這個東西是怎么回事呢?

很簡單,Redisson 幫我們做了這些事情,開箱即用,當個黑盒就完事了。
接下來我就是帶你把黑盒變成白盒,然后引出前面提到的兩個 bug。
我的測試用例里面用的是 3.16.0 版本的 Redission,我們先找一下它關(guān)于設(shè)置過期動作的源碼。
首先可以看到,我雖然調(diào)用的是無參的 lock 方法,但是它其實也只是一層皮而已,里面還是調(diào)用了帶入?yún)⒌?lock 方法,只不過給了幾個默認值,其中 leaseTime 給的是 -1:

而有參的 lock 的源碼是這樣的,主要把注意力放到我框起來的這一行代碼中:

tryAcquire 方法是它的核心邏輯,那么這個方法是在干啥事兒呢?
點進去看看,這部分源碼又是這樣的:

其中 tryLockInnerAsync 方法就是執(zhí)行 Redis 的 Lua 腳本來加鎖。
既然是加鎖了,過期時間肯定就是在這里設(shè)置的,也就是這里的 leaseTime:

而這里的 leaseTime 是在構(gòu)造方法里面初始化的,在我的 Demo 里面,用的是配置中的默認值,也就是 30s :

所以,為什么我們的代碼里面并沒有設(shè)置過期時間的動作,但是對應的 key 卻有過期時間呢?
這里的源碼回答了這個問題。
額外提一句,這個時間是從配置中獲取的,所以肯定是可以自定義的,不一定非得是 30s。
另外需要注意的是,到這里,我們出現(xiàn)了兩個不同的 leaseTime。
分別是這樣的:
tryAcquireOnceAsync 方法的入?yún)?leaseTime,我們的示例中是 -1。 tryLockInnerAsync 方法的入?yún)?leaseTime,我們的示例中是默認值 30 * 1000。

在前面加完鎖之后,緊接著就輪到看門狗工作了:

前面我說了,這里的 leaseTime 是 -1,所以觸發(fā)的是 else 分支中的 scheduleExpirationRenewal 代碼。
而這個代碼就是啟動看門狗的代碼。
換句話說,如果這里的 leaseTime 不是 -1,那么就不會啟動看門狗。
那么怎么讓 leaseTime 不是 -1 呢?
自己指定加鎖時間:

說人話就是如果加鎖的時候指定了過期時間,那么 Redission 不會給你開啟看門狗的機制。
這個點是無數(shù)人對看門狗機制不清楚的人都會記錯的一個點,我曾經(jīng)在一個群里面據(jù)理力爭,后來被別人拿著源碼一頓亂捶。

是的,我就是那個以為指定了過期時間之后,看門狗還會繼續(xù)工作的人。
打臉老疼了,希望你不要步后塵。
接著來看一下 scheduleExpirationRenewal 的代碼:

里面就是把當前線程封裝成了一個對象,然后維護到一個 MAP 中。
這個 MAP 很重要,我先把它放到這里,混個眼熟,一會再說它:

你只要記住這個 MAP 的 key 是當前線程,value 是 ExpirationEntry 對象,這個對象維護的是當前線程的加鎖次數(shù)。

然后,我們先看 scheduleExpirationRenewal 方法里面,調(diào)用 MAP 的 putIfAbsent 方法后,返回的 oldEntry 不為空的情況。
這種情況說明是第一次加鎖,會觸發(fā) renewExpiration 方法,這個方法里面就是看門狗的核心邏輯。
而在 scheduleExpirationRenewal 方法里面,不管前面提到的 oldEntry 是否為空,都會觸發(fā) addThreadId 方法:

從源碼中可以看出來,這里僅僅對當前線程的加鎖次數(shù)進行一個維護。
這個維護很好理解,因為要支持鎖的重入嘛,就得記錄到底重入了幾次。
加鎖一次,次數(shù)加一。解鎖一次,次數(shù)減一。
接著看 renewExpiration 方法,這就是看門狗的真面目了:

首先這一坨邏輯主要就是一個基于時間輪的定時任務(wù)。
標號為 ④ 的地方,就是這個定時任務(wù)觸發(fā)的時間條件:internalLockLeaseTime / 3。
前面我說了,internalLockLeaseTime 默認情況下是 30* 1000,所以這里默認就是每 10 秒執(zhí)行一次續(xù)命的任務(wù),這個從我前面給到的動態(tài)里面也可以看出,ttl 的時間先從 30 變成了 20 ,然后一下又從 20 變成了 30。
標號為 ①、② 的地方干的是同一件事,就是檢查當前線程是否還有效。
怎么判斷是否有效呢?
就是看前面提到的 MAP 中是否還有當前線程對應的 ExpirationEntry 對象。
沒有,就說明是被 remove 了。
那么問題就來了,你看源碼的時候非常自然而然的就應該想到這個問題:什么時候調(diào)用這個 MAP 的 remove 方法呢?
很快,在接下來講釋放鎖的地方,你就可以看到對應的 remove。這里先提一下,后面就能呼應上了。
核心邏輯是標號為 ③ 的地方。我?guī)阕屑毧纯?,主要關(guān)注我加了下劃線的地方。
能走到 ③ 這里說明當前線程的業(yè)務(wù)邏輯還未執(zhí)行完成,還需要繼續(xù)持有鎖。
首先看 renewExpirationAsync 方法,從方法命名上我們也可以看出來,這是在重置過期時間:

上面的源碼主要是一個 lua 腳本,而這個腳本的邏輯非常簡單。就是判斷鎖是否還存在,且持有鎖的線程是否是當前線程。如果是當前線程,重置鎖的過期時間,并返回 1,即返回 true。
如果鎖不存在,或者持有鎖的不是當前線程,那么則返回 0,即返回 false。
接著標號為 ③ 的地方,里面首先判斷了執(zhí)行 renewExpirationAsync 方法是否有異常。
那么問題就來了,會有什么異常呢?
這個地方的異常,主要是因為要到 Redis 執(zhí)行命令嘛,所以如果 Redis 出問題了,比如卡住了,或者掉線了,或者連接池沒有連接了等等各種情況,都可能會執(zhí)行不了命令,導致異常。
如果出現(xiàn)異常了,則執(zhí)行下面這行代碼:
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
然后就 return ,這個定時任務(wù)就結(jié)束了。
好,記住這個 remove 的操作,非常重要,先混個眼熟,一會會講。
如果執(zhí)行 renewExpirationAsync 方法的時候沒有異常。這個時候的返回值就是 true 或者 false。
如果是 true,說明續(xù)命成功,則再次調(diào)用 renewExporation 方法,等待著時間輪觸發(fā)下一次。
如果是 false,說明這把鎖已經(jīng)沒有了,或者易主了。那么也就沒有當前線程什么事情了,啥都不用做,默默的結(jié)束就行了。
上鎖和看門狗的一些基本原理就是前面說到這么多。
接著簡單看看 unlock 方法里面是怎么回事兒的。

首先是 unlockInnerAsync 方法,這里面就是 lua 腳本釋放鎖的邏輯:

這個方法返回的是 Boolean,有三種情況。
返回為 null,說明鎖不存在,或者鎖存在,但是 value 不匹配,表示鎖已經(jīng)被其他線程占用。 返回為 true,說明鎖存在,線程也是對的,重入次數(shù)已經(jīng)減為零,鎖可以被釋放。 返回為 false,說明鎖存在,線程也是對的,但是重入次數(shù)還不為零,鎖還不能被釋放。
但是你看 unlockInnerAsync 是怎么處理這個返回值的:

返回值,也就是 opStatus,僅僅是判斷了返回為 null 的情況,拋出異常表明這個鎖不是被當前線程持有的,完事。
它并不關(guān)心返回為 true 或者為 false 的情況。
然后再看我框起來的 ?cancelExpirationRenewal(threadId); 方法:

這里面就有 remove 方法。
而前面鋪墊了這么多其實就是為了引出這個 cancelExpirationRenewal 方法。
縱觀一下加鎖和解鎖,針對 MAP 的操作,看一下下面的這個圖片:

標號為 ① 的地方是加鎖,調(diào)用 MAP 的 put 方法。
標號為 ② 的地方是放鎖,調(diào)用 MAP 的 remove 方法。
記住上面這一段分析,和操作這個 MAP 的時機,下面說的 BUG 都是由于對這個 MAP 的操作不恰當導致的。
看門狗不生效的BUG
前面找了一個版本給大家看源碼,主要是為了讓大家把 Demo 跑起來,畢竟引入 maven 依賴的成本是小很多的。
但是真的要研究源碼,還是得把先把源碼拉下來,慢慢的啃起來。
直接拉項目源碼的好處我在之前的文章里面已經(jīng)說很多次了,對我而言,無外乎就三個目的:
可以保證是最新的源碼 可以看到代碼的提交記錄 可以找到官方的測試用例
好,話不多說,首先我們看看開篇說的第一個 BUG:看門狗不生效的問題。
從這個 issues 說起:
https://github.com/redisson/redisson/issues/2515

在這個 issues 里面,他給到了一段代碼,然后說他預期的結(jié)果是在看門狗續(xù)命期間,如果出現(xiàn)程序和 Redis 的連接問題,導致鎖自動過期了,那么我再次申請同一把鎖,應該是讓看門狗再次工作才對。
但是實際的情況是,即使前一把鎖由于連接異常導致過期了,程序再成功申請到一把新鎖,但是這個新的鎖,30s 后就自動過期了,即看門狗不會工作。
這個 issues 對應的 pr 是這個:
https://github.com/redisson/redisson/pull/2518

在這個 pr 里面,提供了一個測試用例,我們可以直接在源碼里面找到:
org.redisson.RedissonLockExpirationRenewalTest
這就是拉源碼的好處。
在這個測試用例里面,核心邏輯是這樣的:

首先需要說明的是,在這個測試用例里面,把看門狗的 lockWatchdogTimeout 參數(shù)修改為 1000 ms:

也就是說看門狗這個定時任務(wù),每 333ms 就會觸發(fā)一次。

然后我們看標號為 ① 的地方,先申請了一把鎖,然后 Redis 發(fā)生了一次重啟,重啟導致這把鎖失效了,比如還沒來得及持久化,或者持久化了,但是重啟的時間超過了 1s,這鎖就沒了。
所以,在調(diào)用 unlock 方法的時候,肯定會拋出 IllegalMonitorStateException 異常,表示這把鎖沒了。
到這里一切正常,還能理解。
但是看標號為 ② 的地方。
加鎖之后,業(yè)務(wù)邏輯會執(zhí)行 2s,肯定會觸發(fā)看門狗續(xù)命的操作。
在這個 bug 修復之前,在這里調(diào)用 unlock 方法也會拋出 IllegalMonitorStateException 異常,表示這把鎖沒了:

先不說為啥吧,至少這妥妥的是一個 Bug 了。

因為按照正常的邏輯,這個鎖應該一直被續(xù)命,然后直到調(diào)用 unlock 才應該被釋放。
好,bug 的演示你也看到了,也可以復現(xiàn)了。你猜是什么原因?
答案其實我在前面應該給你寫出來了,就看這波前后呼應你能不能反應過來了。
首先前提是兩次加鎖的線程是同一個,然后我前面不是特意強調(diào)了 oldEntry 這個玩意嗎:

上面這個 bug 能出現(xiàn),說明第二次 lock 的時候 oldEntry 在 MAP 里面是存在的,因此誤以為當前看門狗正在工作,直接進入重入鎖的邏輯即可。
為什么第二次 lock 的時候 oldEntry 在 MAP 里面是存在的呢?
因為第一次 unlock 的時候,沒有從 MAP 里面把當前線程的 ExpirationEntry 對象移走。
為什么沒有移走呢?
看一下這個哥們測試的 Redisson 版本:

在這個版本里面,釋放鎖的邏輯是這樣的:

誒,不對呀,這不是有 cancelExpirationRenewal(threadId) 的邏輯嗎?
沒錯,確實有。
但是你看什么情況下會執(zhí)行這個邏輯。
首先是出現(xiàn)異常的情況,但是在我們的測試用例中,兩次調(diào)用 unlock 的時候 Redis 是正常的,不會拋出異常。
然后是 opStatus 不為 null 的時候會執(zhí)行該邏輯。
也就是說 opStatus 為 null 的時候,即當前鎖沒有了,或者易主了的時候,不會觸發(fā) cancelExpirationRenewal(threadId) 的邏輯。
巧了,在我們的場景里面,第一次調(diào)用 unlock 方法的時候,就是因為 Redis 重啟導致鎖沒有了,因此這里返回的 opStatus 為 null,沒有觸發(fā) cancelExpirationRenewal 方法的邏輯。
導致我第二次在當前線程中調(diào)用 lock 的時候,走到下面這里的時候,oldEntry 不為空:

所以,走了重入的邏輯,并沒有啟動看門狗。
由于沒有啟動看門狗,導致這個鎖在 1000ms 之后就自動釋放了,可以被別的線程搶走拿去用。
隨后當前線程業(yè)務(wù)邏輯執(zhí)行完成,第二次調(diào)用 unlock,當然就會拋出異常了。
這就是 BUG 的根因。
找到問題就好了,一行代碼就能解決:

只要調(diào)用了 unlock 方法,不管怎么樣,先調(diào)用 cancelExpirationRenewal(threadId) 方法,準沒錯。
這就是由于沒有及時從 MAP 里面移走當前線程對應的對象,導致的一個 BUG。
再看看另外一個的 issue:
https://github.com/redisson/redisson/issues/3714

這個問題是說如果我的鎖由于某些原因沒了,當我在程序里面再次獲取到它之后,看門狗應該繼續(xù)工作。
聽起來,說的是同一個問題對不對?
是的,就是說的同一個問題。
但是這個問題,提交的代碼是這樣的:

在看門狗這里,如果看門狗續(xù)命失敗,說明鎖不存在了,即 res 返回為 false,那么也主動執(zhí)行一下 cancelExpirationRenewal 方法,方便為后面的加鎖成功的線程讓路,以免耽誤別人開啟看門狗機制。
這樣就能有雙重保障了,在 unlock 和看門狗里面都會觸發(fā) cancelExpirationRenewal 的邏輯,而且這兩個邏輯也并不會沖突。

另外,我提醒一下,最終提交的代碼是這樣的,兩個方法入?yún)⑹遣灰粯拥模?/p>
為什么從 threadId 修改為 null 呢?
留個思考題吧,就是從重入的角度考慮的,可以自己去研究一下,很簡單的。
看門狗導致死鎖的BUG
這個 BUG 解釋起來就很簡單了。
看看這個 issue:
https://github.com/redisson/redisson/issues/1966

在這里把復現(xiàn)的步驟都寫的清清楚楚的。
測試程序是這樣的,通過定時任務(wù) 1s 觸發(fā)一次,但是任務(wù)會執(zhí)行 2s,這樣就會導致鎖的重入:

他這里提到一個命令:
CLIENT PAUSE 5000
主要還是模擬 Redis 處理請求超時的情況,就是讓 Redis 假死 5s,這樣程序發(fā)過來的請求就會超時。
這樣,重入的邏輯就會發(fā)生混亂。
看一下這個 bug 修復的對應的關(guān)鍵代碼之一:

不管 opStatus 返回為 false 還是 true,都執(zhí)行 cancelExpirationRenewal 邏輯。
問題的解決之道,還是在于對 MAP 的操作。
另外,多提一句。
也是在這次提交中,把維護重入的邏輯封裝到了 ExpirationEntry 這個對象里面,比起之前的寫法優(yōu)雅了很多,有興趣的可以把源碼拉下來進行一下對比,感受一下什么叫做優(yōu)雅的重構(gòu):

線程中斷
在寫文章的時候,我還發(fā)現(xiàn)一個有意思的,但對于 Redisson 無解的 bug。
就是這里:

我第一眼看到這一段代碼就很奇怪,這樣奇怪的寫法,背后肯定是有故事的。
這背后對應的故事,藏在這個 issue 里面:
https://github.com/redisson/redisson/issues/2714

翻譯過來,說的是當 tryLock 方法被中斷時,看門狗還是會不斷地更新鎖,這就造成了無限鎖,也就是死鎖。
我們看一下對應的測試用例:

開啟了一個子線程,在子線程里面執(zhí)行了 tryLock 的方法,然后主線程里面調(diào)用了子線程的 interrupt 方法。
你說這個時候子線程應該怎么辦?
按理來說,線程被中斷了,是不是看門狗也不應該工作呢?
是的,所以這樣的代碼就出現(xiàn)了:

但是,你細品,這幾行代碼并沒有完全解決看門狗的問題。只能在一定概率上解決第一次調(diào)用后 renewExpiration 方法后,還沒來得及啟動定時任務(wù)之前的這一小段時間。
所以,測試案例里面的 sleep 時間,只有 5ms:

這時間要是再長一點,就會觸發(fā)看門狗機制。
一旦觸發(fā)看門狗機制,觸發(fā) renewExpiration 方法的線程就會變成定時任務(wù)的線程。
你外面的子線程 interrupt 了,和我定時任務(wù)的線程有什么關(guān)系?
比如,我把這幾行代碼移動到這里:

其實沒有任何卵用:

因為線程變了。
對于這個問題,官方的回答是這樣的:

大概意思就是說:嗯,你說的很有道理,但是 Redisson 的看門狗工作范圍是整個實例,而不是某個指定的線程。
意外收獲
最后,再來一個意外收獲:

你看 addThreadId 這個方法重構(gòu)了一次。
但是這次重構(gòu)就出現(xiàn)問題了。
原來的邏輯是當 counter 是 null 的時候,初始化為 1。不為 null 的時候,就執(zhí)行 counter++,即重入。
重構(gòu)之后的邏輯是當 counter 是 null 的時候,先初始化為 1,然后緊接著執(zhí)行 counter++。
那豈不是 counter 直接就變成了 2,和原來的邏輯不一樣了?
是的,不一樣了。
搞的我 Debug 的時候一臉懵逼,后來才發(fā)現(xiàn)這個地方出現(xiàn)問題了。

那就不好意思了,意外收獲,混個 pr 吧:


荒腔走板
這期沒啥好荒的了,我在《五一躺平的感覺怎么說呢?太特么爽了!》這篇推文里面都已經(jīng)荒過了,沒看過的可以去看看我沒有學習的、五一的快樂生活。
雖然快樂吧,但是我感覺自己還沒休息好呢,五一唰的一下就沒了。
還沒開始上班,我就已經(jīng)隱隱感覺到有點累了。
因為 8 號放假的那天剛好輪到我值班,你可以理解為那天需要我加班,你知道這意味著啥嗎?
這意味著我得連續(xù)上 9 天班。
你說遭不遭得?。?/p>
遭不住,對不對。
所以,即使五一已經(jīng)過去了,我還是想向發(fā)明五一調(diào)休的人才問好:我衷心的謝謝你全家。

