踩到一個關於分散式鎖的非比尋常的BUG!

why技術發表於2022-05-05

你好呀,我是歪歪。

提到分散式鎖,大家一般都會想到 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 做分散式鎖的時候,如果指定過期時間到了,把鎖給釋放了。但是任務還未執行完成,導致任務再次被執行,這種情況你會怎麼處理呢?

這個時候,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 說起:

https://github.com/redisson/redisson/issues/2515

在這個 issues 裡面,他給到了一段程式碼,然後說他預期的結果是在看門狗續命期間,如果出現程式和 Redis 的連線問題,導致鎖自動過期了,那麼我再次申請同一把鎖,應該是讓看門狗再次工作才對。

但是實際的情況是,即使前一把鎖由於連線異常導致過期了,程式再成功申請到一把新鎖,但是這個新的鎖,30s 後就自動過期了,即看門狗不會工作。

這個 issues 對應的 pr 是這個:

https://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:

https://github.com/redisson/redisson/issues/3714

這個問題是說如果我的鎖由於某些原因沒了,當我在程式裡面再次獲取到它之後,看門狗應該繼續工作。

聽起來,說的是同一個問題對不對?

是的,就是說的同一個問題。

但是這個問題,提交的程式碼是這樣的:

在看門狗這裡,如果看門狗續命失敗,說明鎖不存在了,即 res 返回為 false,那麼也主動執行一下 cancelExpirationRenewal 方法,方便為後面的加鎖成功的執行緒讓路,以免耽誤別人開啟看門狗機制。

這樣就能有雙重保障了,在 unlock 和看門狗裡面都會觸發 cancelExpirationRenewal 的邏輯,而且這兩個邏輯也並不會衝突。

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

為什麼從 threadId 修改為 null 呢?

留個思考題吧,就是從重入的角度考慮的,可以自己去研究一下,很簡單的。

看門狗導致死鎖的BUG

這個 BUG 解釋起來就很簡單了。

看看這個 issue:

https://github.com/redisson/redisson/issues/1966

在這裡把復現的步驟都寫的清清楚楚的。

測試程式是這樣的,通過定時任務 1s 觸發一次,但是任務會執行 2s,這樣就會導致鎖的重入:

他這裡提到一個命令:

CLIENT PAUSE 5000

主要還是模擬 Redis 處理請求超時的情況,就是讓 Redis 假死 5s,這樣程式發過來的請求就會超時。

這樣,重入的邏輯就會發生混亂。

看一下這個 bug 修復的對應的關鍵程式碼之一:

不管 opStatus 返回為 false 還是 true,都執行 cancelExpirationRenewal 邏輯。

問題的解決之道,還是在於對 MAP 的操作。

另外,多提一句。

也是在這次提交中,把維護重入的邏輯封裝到了 ExpirationEntry 這個物件裡面,比起之前的寫法優雅了很多,有興趣的可以把原始碼拉下來進行一下對比,感受一下什麼叫做優雅的重構:

執行緒中斷

在寫文章的時候,我還發現一個有意思的,但對於 Redisson 無解的 bug。

就是這裡:

我第一眼看到這一段程式碼就很奇怪,這樣奇怪的寫法,背後肯定是有故事的。

這背後對應的故事,藏在這個 issue 裡面:

https://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 吧:

相關文章