踩到一個關於分佈式鎖的非比尋常的BUG!
- 2022 年 5 月 5 日
- 筆記
你好呀,我是歪歪。
提到分佈式鎖,大家一般都會想到 Redis。
想到 Redis,一部分同學會說到 Redisson。
那麼說到 Redisson,就不得不掰扯掰扯一下它的「看門狗」機制了。
所以你以為這篇文章我要給你講「看門狗」嗎?
不是,我主要是想給你彙報一下我最近研究的由於引入「看門狗」之後,給 Redisson 帶來的兩個看起來就菊花一緊的 bug :
-
看門狗不生效的 BUG。 -
看門狗導致死鎖的 BUG。
為了能讓你絲滑入戲,我還是先簡單的給你鋪墊一下,Redisson 的看門狗到底是個啥東西。

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

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/redisson/issues/2515

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

在這個 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/redisson/issues/3714

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

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

另外,我提醒一下,最終提交的代碼是這樣的,兩個方法入參是不一樣的:

為什麼從 threadId 修改為 null 呢?
留個思考題吧,就是從重入的角度考慮的,可以自己去研究一下,很簡單的。
看門狗導致死鎖的BUG
這個 BUG 解釋起來就很簡單了。
看看這個 issue:
//github.com/redisson/redisson/issues/1966

在這裡把復現的步驟都寫的清清楚楚的。
測試程序是這樣的,通過定時任務 1s 觸發一次,但是任務會執行 2s,這樣就會導致鎖的重入:

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

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

線程中斷
在寫文章的時候,我還發現一個有意思的,但對於 Redisson 無解的 bug。
就是這裡:

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

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

開啟了一個子線程,在子線程裏面執行了 tryLock 的方法,然後主線程裏面調用了子線程的 interrupt 方法。
你說這個時候子線程應該怎麼辦?
按理來說,線程被中斷了,是不是看門狗也不應該工作呢?
是的,所以這樣的代碼就出現了:

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

這時間要是再長一點,就會觸發看門狗機制。
一旦觸發看門狗機制,觸發 renewExpiration 方法的線程就會變成定時任務的線程。
你外面的子線程 interrupt 了,和我定時任務的線程有什麼關係?
比如,我把這幾行代碼移動到這裡:

其實沒有任何卵用:

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

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

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

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

