天天看點

Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

作者:vvvzhao

Redis 分布式鎖使用 SET 指令就可以實作了麼?在分布式領域 CAP 理論一直存在。

分布式鎖的門道可沒那麼簡單,我們在網上看到的分布式鎖方案可能是有問題的。

「碼哥」一步步帶你深入分布式鎖是如何一步步完善,在高并發生産環境中如何正确使用分布式鎖。

在進入正文之前,我們先帶着問題去思考:

  • 什麼時候需要分布式鎖?
  • 加、解鎖的代碼位置有講究麼?
  • 如何避免出現鎖再也無法删除?
  • 逾時時間設定多少合适呢?
  • 如何避免鎖被其他線程釋放
  • 如何實作重入鎖?
  • 主從架構會帶來什麼安全問題?
  • 什麼是 Redlock
  • Redisson 分布式鎖最佳實戰
  • 看門狗實作原理
  • ……

什麼時候用分布式鎖?

碼哥,說個通俗的例子講解下什麼時候需要分布式鎖呢?

診所隻有一個醫生,很多患者前來就診。

醫生在同一時刻隻能給一個患者提供就診服務。

如果不是這樣的話,就會出現醫生在就診腎虧的「肖菜雞」準備開藥時候患者切換成了腳臭的「謝霸哥」,這時候藥就被謝霸哥取走了。

治腎虧的藥被有腳臭的拿去了。

當并發去讀寫一個【共享資源】的時候,我們為了保證資料的正确,需要控制同一時刻隻有一個線程通路。

分布式鎖就是用來控制同一時刻,隻有一個 JVM 程序中的一個線程可以通路被保護的資源。

分布式鎖入門

65 哥:分布式鎖應該滿足哪些特性?

  1. 互斥:在任何給定時刻,隻有一個用戶端可以持有鎖;
  2. 無死鎖:任何時刻都有可能獲得鎖,即使擷取鎖的用戶端崩潰;
  3. 容錯:隻要大多數 Redis的節點都已經啟動,用戶端就可以擷取和釋放鎖。

碼哥,我可以使用 SETNX key value 指令是實作「互斥」特性。

這個指令來自于SET if Not eXists的縮寫,意思是:如果 key 不存在,則設定 value 給這個key,否則啥都不做。Redis 官方位址說的:

指令的傳回值:

  • 1:設定成功;
  • 0:key 沒有設定成功。

如下場景:

敲代碼一天累了,想去放松按摩下肩頸。

168 号技師最搶手,大家喜歡點,是以并發量大,需要分布式鎖控制。

同一時刻隻允許一個「客戶」預約 168 技師。

肖菜雞申請 168 技師成功:

> SETNX lock:168 1
(integer) 1 # 擷取 168 技師成功
           

謝霸哥後面到,申請失敗:

> SETNX lock 2
(integer) 0 # 客戶謝霸哥 2 擷取失敗
           

此刻,申請成功的客戶就可以享受 168 技師的肩頸放松服務「共享資源」。

享受結束後,要及時釋放鎖,給後來者享受 168 技師的服務機會。

肖菜雞,碼哥考考你如何釋放鎖呢?

很簡單,使用 DEL 删除這個 key 就行。

> DEL lock:168
(integer) 1
           

碼哥,你見過「龍」麼?我見過,因為我被一條龍服務過。

Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

肖菜雞,事情可沒這麼簡單。

這個方案存在一個存在造成鎖無法釋放的問題,造成該問題的場景如下:

  1. 用戶端所在節點崩潰,無法正确釋放鎖;
  2. 業務邏輯異常,無法執行 DEL指令。

這樣,這個鎖就會一直占用,鎖在我手裡,我挂了,這樣其他用戶端再也拿不到這個鎖了。

逾時設定

碼哥,我可以在擷取鎖成功的時候設定一個「逾時時間」

比如設定按摩服務一次 60 分鐘,那麼在給這個 key 加鎖的時候設定 60 分鐘過期即可:

> SETNX lock:168 1  // 擷取鎖
(integer) 1
> EXPIRE lock:168 60  // 60s 自動删除
(integer) 1
           

這樣,到點後鎖自動釋放,其他客戶就可以繼續享受 168 技師按摩服務了。

誰要這麼寫,就糟透了。

「加鎖」、「設定逾時」是兩個指令,他們不是原子操作。

如果出現隻執行了第一條,第二條沒機會執行就會出現「逾時時間」設定失敗,依然出現鎖無法釋放。

碼哥,那咋辦,我想被一條龍服務,要解決這個問題

Redis 2.6.X 之後,官方拓展了 SET 指令的參數,滿足了當 key 不存在則設定 value,同時設定逾時時間的語義,并且滿足原子性。

SET resource_name random_value NX PX 30000
           
  • NX:表示隻有 resource_name 不存在的時候才能 SET 成功,進而保證隻有一個用戶端可以獲得鎖;
  • PX 30000:表示這個鎖有一個 30 秒自動過期時間。

這樣寫還不夠,我們還要防止不能釋放不是自己加的鎖。我們可以在 value 上做文章。

繼續往下看……

釋放了不是自己加的鎖

這樣我能穩妥的享受一條龍服務了麼?

No,還有一種場景會導緻釋放别人的鎖:

  1. 客戶 1 擷取鎖成功并設定設定 30 秒逾時;
  2. 客戶 1 因為一些原因導緻執行很慢(網絡問題、發生 FullGC……),過了 30 秒依然沒執行完,但是鎖過期「自動釋放了」;
  3. 客戶 2 申請加鎖成功;
  4. 客戶 1 執行完成,執行 DEL 釋放鎖指令,這個時候就把客戶 2 的鎖給釋放了。

有個關鍵問題需要解決:自己的鎖隻能自己來釋放。

我要如何删除是自己加的鎖呢?

在執行 DEL 指令的時候,我們要想辦法檢查下這個鎖是不是自己加的鎖再執行删除指令。

解鈴還須系鈴人

碼哥,我在加鎖的時候設定一個「唯一辨別」作為 value 代表加鎖的用戶端。SET resource_name random_value NX PX 30000

在釋放鎖的時候,用戶端将自己的「唯一辨別」與鎖上的「辨別」比較是否相等,比對上則删除,否則沒有權利釋放鎖。

僞代碼如下:

// 比對 value 與 唯一辨別
if (redis.get("lock:168").equals(random_value)){
   redis.del("lock:168"); //比對成功則删除
 }
           

有沒有想過,這是 GET + DEL 指令組合而成的,這裡又會涉及到原子性問題。

我們可以通過 Lua 腳本來實作,這樣判斷和删除的過程就是原子操作了。

// 擷取鎖的 value 與 ARGV[1] 是否比對,比對則執行 del
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
           

這樣通過唯一值設定成 value 辨別加鎖的用戶端很重要,僅使用 DEL 是不安全的,因為一個用戶端可能會删除另一個用戶端的鎖。

使用上面的腳本,每個鎖都用一個随機字元串“簽名”,隻有當删除鎖的用戶端的“簽名”與鎖的 value 比對的時候,才會删除它。

這個方案已經相對完美,我們用的最多的可能就是這個方案了。

正确設定鎖逾時

鎖的逾時時間怎麼計算合适呢?

這個時間不能瞎寫,一般要根據在測試環境多次測試,然後壓測多輪之後,比如計算出平均執行時間 200 ms。

那麼鎖的逾時時間就放大為平均執行時間的 3~5 倍。

為啥要放放大呢?

因為如果鎖的操作邏輯中有網絡 IO 操作、JVM FullGC 等,線上的網絡不會總一帆風順,我們要給網絡抖動留有緩沖時間。

那我設定更大一點,比如設定 1 小時不是更安全?

不要鑽牛角,多大算大?

設定時間過長,一旦發生當機重新開機,就意味着 1 小時内,分布式鎖的服務全部節點不可用。

你要讓運維手動删除這個鎖麼?

隻要運維真的不會打你。

有沒有完美的方案呢?不管時間怎麼設定都不大合适。

我們可以讓獲得鎖的線程開啟一個守護線程,用來給快要過期的鎖「續航」。

加鎖的時候設定一個過期時間,同時用戶端開啟一個「守護線程」,定時去檢測這個鎖的失效時間。

如果快要過期,但是業務邏輯還沒執行完成,自動對這個鎖進行續期,重新設定過期時間。

這個道理行得通,可我寫不出。

别慌,已經有一個庫把這些工作都封裝好了他叫 Redisson。

在使用分布式鎖時,它就采用了「自動續期」的方案來避免鎖過期,這個守護線程我們一般也把它叫做「看門狗」線程。

一路優化下來,方案似乎比較「嚴謹」了,抽象出對應的模型如下。

  1. 通過 SET lock_resource_name random_value NX PX expire_time,同時啟動守護線程為快要過期但還沒執行完的用戶端的鎖續命;
  2. 用戶端執行業務邏輯操作共享資源;
  3. 通過 Lua 腳本釋放鎖,先 get 判斷鎖是否是自己加的,再執行 DEL。
Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

這個方案實際上已經比較完美,能寫到這一步已經打敗 90% 的程式猿了。

但是對于追求極緻的程式員來說還遠遠不夠:

  1. 可重入鎖如何實作?
  2. 主從架構崩潰恢複導緻鎖丢失如何解決?
  3. 用戶端加鎖的位置有門道麼?

加解鎖代碼位置有講究

根據前面的分析,我們已經有了一個「相對嚴謹」的分布式鎖了。

于是「謝霸哥」就寫了如下代碼将分布式鎖運用到項目中,以下是僞代碼邏輯:

public void doSomething() {
  redisLock.lock(); // 上鎖
    try {
        // 處理業務
        .....
        redisLock.unlock(); // 釋放鎖
    } catch (Exception e) {
        e.printStackTrace();
    }
}
           

有沒有想過:一旦執行業務邏輯過程中抛出異常,程式就無法執行釋放鎖的流程。

是以釋放鎖的代碼一定要放在 finally{} 塊中。

加鎖的位置也有問題,放在 try 外面的話,如果執行 redisLock.lock() 加鎖異常,但是實際指令已經發送到服務端并執行,隻是用戶端讀取響應逾時,就會導緻沒有機會執行解鎖的代碼。

是以 redisLock.lock() 應該寫在 try 代碼塊,這樣保證一定會執行解鎖邏輯。

綜上所述,正确代碼位置如下 :

public void doSomething() {
    try {
        // 上鎖
        redisLock.lock();
        // 處理業務
        ...
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
      // 釋放鎖
      redisLock.unlock();
    }
}
           

實作可重入鎖

65 哥:可重入鎖要如何實作呢?

當一個線程執行一段代碼成功擷取鎖之後,繼續執行時,又遇到加鎖的代碼,可重入性就就保證線程能繼續執行,而不可重入就是需要等待鎖釋放之後,再次擷取鎖成功,才能繼續往下執行。

用一段代碼解釋可重入:

public synchronized void a() {
    b();
}
public synchronized void b() {
    // pass
}
           

假設 X 線程在 a 方法擷取鎖之後,繼續執行 b 方法,如果此時不可重入,線程就必須等待鎖釋放,再次争搶鎖。

鎖明明是被 X 線程擁有,卻還需要等待自己釋放鎖,然後再去搶鎖,這看起來就很奇怪,我釋放我自己~

Redis Hash 可重入鎖

Redisson 類庫就是通過 Redis Hash 來實作可重入鎖

當線程擁有鎖之後,往後再遇到加鎖方法,直接将加鎖次數加 1,然後再執行方法邏輯。

退出加鎖方法之後,加鎖次數再減 1,當加鎖次數為 0 時,鎖才被真正的釋放。

可以看到可重入鎖最大特性就是計數,計算加鎖的次數。

是以當可重入鎖需要在分布式環境實作時,我們也就需要統計加鎖次數。

加鎖邏輯

我們可以使用 Redis hash 結構實作,key 表示被鎖的共享資源, hash 結構的 fieldKey 的 value 則儲存加鎖的次數。

Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

通過 Lua 腳本實作原子性,假設 KEYS1 = 「lock」, ARGV「1000,uuid」:

---- 1 代表 true
---- 0 代表 false
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;
           

加鎖代碼首先使用 Redis exists 指令判斷目前 lock 這個鎖是否存在。

如果鎖不存在的話,直接使用 hincrby建立一個鍵為 lock hash 表,并且為 Hash 表中鍵為 uuid 初始化為 0,然後再次加 1,最後再設定過期時間。

如果目前鎖存在,則使用 hexists判斷目前 lock 對應的 hash 表中是否存在 uuid 這個鍵,如果存在,再次使用 hincrby 加 1,最後再次設定過期時間。

最後如果上述兩個邏輯都不符合,直接傳回。

解鎖邏輯

-- 判斷 hash set 可重入 key 的值是否等于 0
-- 如果為 0 代表 該可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
-- 計算目前可重入次數
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小于等于 0 代表可以解鎖
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;
           

首先使用 hexists 判斷 Redis Hash 表是否存給定的域。

如果 lock 對應 Hash 表不存在,或者 Hash 表不存在 uuid 這個 key,直接傳回 nil。

若存在的情況下,代表目前鎖被其持有,首先使用 hincrby使可重入次數減 1 ,然後判斷計算之後可重入次數,若小于等于 0,則使用 del 删除這把鎖。

解鎖代碼執行方式與加鎖類似,隻不過解鎖的執行結果傳回類型使用 Long。這裡之是以沒有跟加鎖一樣使用 Boolean ,這是因為解鎖 lua 腳本中,三個傳回值含義如下:

  • 1 代表解鎖成功,鎖被釋放
  • 0 代表可重入次數被減 1
  • null 代表其他線程嘗試解鎖,解鎖失敗.

主從架構帶來的問題

碼哥,到這裡分布式鎖「很完美了」吧,沒想到分布式鎖這麼多門道。

路還很遠,之前分析的場景都是,鎖在「單個」Redis 執行個體中可能産生的問題,并沒有涉及到 Redis 主從模式導緻的問題。

我們通常使用「Cluster 叢集」或者「哨兵叢集」的模式部署保證高可用。

這兩個模式都是基于「主從架構資料同步複制」實作的資料同步,而 Redis 的主從複制預設是異步的。

以下内容來自于官方文檔 https://redis.io/topics/distlock

我們試想下如下場景會發生什麼問題:

  1. 用戶端 A 在 master 節點擷取鎖成功。
  2. 還沒有把擷取鎖的資訊同步到 slave 的時候,master 當機。
  3. slave 被選舉為新 master,這時候沒有用戶端 A 擷取鎖的資料。
  4. 用戶端 B 就能成功的獲得用戶端 A 持有的鎖,違背了分布式鎖定義的互斥。

雖然這個機率極低,但是我們必須得承認這個風險的存在。

Redis 的作者提出了一種解決方案,叫 Redlock(紅鎖)

Redis 的作者為了統一分布式鎖的标準,搞了一個 Redlock,算是 Redis 官方對于實作分布式鎖的指導規範,https://redis.io/topics/distlock,但是這個 Redlock 也被國外的一些分布式專家給噴了。

因為它也不完美,有“漏洞”。

什麼是 Redlock

紅鎖是不是這個?

Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

泡面吃多了你,Redlock 紅鎖是為了解決主從架構中當出現主從切換導緻多個用戶端持有同一個鎖而提出的一種算法。

想用使用 Redlock,官方建議在不同機器上部署 5 個 Redis 主節點,節點都是完全獨立,也不使用主從複制,使用多個節點是為容錯。

一個用戶端要擷取鎖有 5 個步驟:

  1. 用戶端擷取目前時間 T1(毫秒級别);
  2. 使用相同的 key和 value順序嘗試從 N個 Redis執行個體上擷取鎖。
  3. 每個請求都設定一個逾時時間(毫秒級别),該逾時時間要遠小于鎖的有效時間,這樣便于快速嘗試與下一個執行個體發送請求。
  4. 比如鎖的自動釋放時間 10s,則請求的逾時時間可以設定 5~50 毫秒内,這樣可以防止用戶端長時間阻塞。
  5. 用戶端擷取目前時間 T2 并減去步驟 1 的 T1 來計算出擷取鎖所用的時間(T3 = T2 -T1)。當且僅當用戶端在大多數執行個體(N/2 + 1)擷取成功,且擷取鎖所用的總時間 T3 小于鎖的有效時間,才認為加鎖成功,否則加鎖失敗。
  6. 如果第 3 步加鎖成功,則執行業務邏輯操作共享資源,key 的真正有效時間等于有效時間減去擷取鎖所使用的時間(步驟 3 計算的結果)。
  7. 如果因為某些原因,擷取鎖失敗(沒有在至少 N/2+1 個 Redis 執行個體取到鎖或者取鎖時間已經超過了有效時間),用戶端應該在所有的 Redis 執行個體上進行解鎖(即便某些 Redis 執行個體根本就沒有加鎖成功)。

另外部署執行個體的數量要求是奇數,為了能很好的滿足過半原則,如果是 6 台則需要 4 台擷取鎖成功才能認為成功,是以奇數更合理

事情可沒這麼簡單,Redis 作者把這個方案提出後,受到了業界著名的分布式系統專家的質疑。

兩人好比神仙打架,兩人一來一回論據充足的對一個問題提出很多論斷……

Redlock 是與非

Martin Kleppmann 認為鎖定的目的是為了保護對共享資源的讀寫,而分布式鎖應該「高效」和「正确」。

  • 高效性:分布式鎖應該要滿足高效的性能,Redlock 算法向 5 個節點執行擷取鎖的邏輯性能不高,成本增加,複雜度也高;
  • 正确性:分布式鎖應該防止并發程序在同一時刻隻能有一個線程能對共享資料讀寫。

出于這兩點,我們沒必要承擔 Redlock 的成本和複雜,運作 5 個 Redis 執行個體并判斷加鎖是否滿足大多數才算成功。

主從架構崩潰恢複極小可能發生,這沒什麼大不了的。使用單機版就夠了,Redlock 太重了,沒必要。

Martin 認為 Redlock 根本達不到安全性的要求,也依舊存在鎖失效的問題!

Martin 的結論

  1. Redlock 不倫不類:對于偏好效率來講,Redlock 比較重,沒必要這麼做,而對于偏好正确性來說,Redlock 是不夠安全的。
  2. 時鐘假設不合理:該算法對系統時鐘做出了危險的假設(假設多個節點機器時鐘都是一緻的),如果不滿足這些假設,鎖就會失效。
  3. 無法保證正确性:Redlock 不能提供類似 fencing token 的方案,是以解決不了正确性的問題。為了正确性,請使用有「共識系統」的軟體,例如 Zookeeper。

Redis 作者 Antirez 的反駁

在 Redis 作者的反駁文章中,有 3 個重點:

  • 時鐘問題:Redlock 并不需要完全一緻的時鐘,隻需要大體一緻就可以了,允許有「誤差」,隻要誤差不要超過鎖的租期即可,這種對于時鐘的精度要求并不是很高,而且這也符合現實環境。
  • 網絡延遲、程序暫停問題:
    • 用戶端在拿到鎖之前,無論經曆什麼耗時長問題,Redlock 都能夠在第 3 步檢測出來
    • 用戶端在拿到鎖之後,發生 NPC,那 Redlock、Zookeeper 都無能為力
  • 質疑 fencing token 機制。

關于 Redlock 的争論我們下期再見,現在進入 Redisson 實作分布式鎖實戰部分。

Redisson 分布式鎖

基于 SpringBoot starter 方式,添加 starter。

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.16.4</version>
</dependency>

           

不過這裡需要注意 springboot 與 redisson 的版本,因為官方推薦 redisson 版本與 springboot 版本配合使用。

将 Redisson 與 Spring Boot 庫內建,還取決于 Spring Data Redis 子產品。

「碼哥」使用 SpringBoot 2.5.x 版本, 是以需要添加 redisson-spring-data-25。

<dependency>
  <groupId>org.redisson</groupId>
  <!-- for Spring Data Redis v.2.5.x -->
  <artifactId>redisson-spring-data-25</artifactId>
  <version>3.16.4</version>
</dependency>
           

添加配置檔案

spring:
  redis:
    database:
    host:
    port:
    password:
    ssl:
    timeout:
    # 根據實際情況配置 cluster 或者哨兵
    cluster:
      nodes:
    sentinel:
      master:
      nodes:
           

就這樣在 Spring 容器中我們擁有以下幾個 Bean 可以使用:

  • RedissonClient
  • RedissonRxClient
  • RedissonReactiveClient
  • RedisTemplate
  • ReactiveRedisTemplate

失敗無限重試

RLock lock = redisson.getLock("碼哥位元組");
try {

  // 1.最常用的第一種寫法
  lock.lock();

  // 執行業務邏輯
  .....

} finally {
  lock.unlock();
}

           

拿鎖失敗時會不停的重試,具有 Watch Dog 自動延期機制,預設續 30s 每隔 30/3=10 秒續到 30s。

失敗逾時重試,自動續命

// 嘗試拿鎖10s後停止重試,擷取失敗傳回false,具有Watch Dog 自動延期機制, 預設續30s
boolean flag = lock.tryLock(10, TimeUnit.SECONDS);
           

逾時自動釋放鎖

// 沒有Watch Dog ,10s後自動釋放,不需要調用 unlock 釋放鎖。
lock.lock(10, TimeUnit.SECONDS);
           

逾時重試,自動解鎖

// 嘗試加鎖,最多等待100秒,上鎖以後10秒自動解鎖,沒有 Watch dog
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       lock.unlock();
   }
}
           

Watch Dog 自動延時

如果擷取分布式鎖的節點當機,且這個鎖還處于鎖定狀态,就會出現死鎖。

為了避免這個情況,我們都會給鎖設定一個逾時自動釋放時間。

然而,還是會存在一個問題。

假設線程擷取鎖成功,并設定了 30 s 逾時,但是在 30s 内任務還沒執行完,鎖逾時釋放了,就會導緻其他線程擷取不該擷取的鎖。

是以,Redisson 提供了 watch dog 自動延時機制,提供了一個監控鎖的看門狗,它的作用是在 Redisson 執行個體被關閉前,不斷的延長鎖的有效期。

也就是說,如果一個拿到鎖的線程一直沒有完成邏輯,那麼看門狗會幫助線程不斷的延長鎖逾時時間,鎖不會因為逾時而被釋放。

預設情況下,看門狗的續期時間是 30s,也可以通過修改 Config.lockWatchdogTimeout 來另行指定。

另外 Redisson 還提供了可以指定 leaseTime 參數的加鎖方法來指定加鎖的時間。

超過這個時間後鎖便自動解開了,不會延長鎖的有效期。

原理如下圖:

Redis 分布式鎖的正确實作原理演化曆程與 Redisson 實戰總結

有兩個點需要注意:

  • watchDog 隻有在未顯示指定加鎖逾時時間(leaseTime)時才會生效。
  • lockWatchdogTimeout 設定的時間不要太小 ,比如設定的是 100 毫秒,由于網絡直接導緻加鎖完後,watchdog 去延期時,這個 key 在 redis 中已經被删除了。

源碼導讀

在調用 lock 方法時,會最終調用到 tryAcquireAsync。

調用鍊為:lock()->tryAcquire->tryAcquireAsync,詳細解釋如下:

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        RFuture<Long> ttlRemainingFuture;
        //如果指定了加鎖時間,會直接去加鎖
        if (leaseTime != -1) {
            ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else {
            //沒有指定加鎖時間 會先進行加鎖,并且預設時間就是 LockWatchdogTimeout的時間
            //這個是異步操作 傳回RFuture 類似netty中的future
            ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                    TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        }

        //這裡也是類似netty Future 的addListener,在future内容執行完成後執行
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e != null) {
                return;
            }

            // lock acquired
            if (ttlRemaining == null) {
                // leaseTime不為-1時,不會自動延期
                if (leaseTime != -1) {
                    internalLockLeaseTime = unit.toMillis(leaseTime);
                } else {
                    //這裡是定時執行 目前鎖自動延期的動作,leaseTime為-1時,才會自動延期
                    scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }
           

scheduleExpirationRenewal 中會調用 renewExpiration 啟用了一個 timeout 定時,去執行延期動作。

private void renewExpiration() {
        ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ee == null) {
            return;
        }

        Timeout task = commandExecutor.getConnectionManager()
          .newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                // 省略部分代碼
                ....

                RFuture<Boolean> future = renewExpirationAsync(threadId);
                future.onComplete((res, e) -> {
                    ....

                    if (res) {
                        //如果 沒有報錯,就再次定時延期
                        // reschedule itself
                        renewExpiration();
                    } else {
                        cancelExpirationRenewal(null);
                    }
                });
            }
            // 這裡我們可以看到定時任務 是 lockWatchdogTimeout 的1/3時間去執行 renewExpirationAsync
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        ee.setTimeout(task);
    }
           

scheduleExpirationRenewal 會調用到 renewExpirationAsync,執行下面這段 lua 腳本。

他主要判斷就是 這個鎖是否在 redis 中存在,如果存在就進行 pexpire 延期。

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return 0;",
                Collections.singletonList(getRawName()),
                internalLockLeaseTime, getLockName(threadId));
    }

           
  • watch dog 在目前節點還存活且任務未完成則每 10 s 給鎖續期 30s。
  • 程式釋放鎖操作時因為異常沒有被執行,那麼鎖無法被釋放,是以釋放鎖操作一定要放到 finally {} 中;
  • 要使 watchLog 機制生效 ,lock 時 不要設定 過期時間。
  • watchlog 的延時時間 可以由 lockWatchdogTimeout 指定預設延時時間,但是不要設定太小。
  • watchdog 會每 lockWatchdogTimeout/3 時間,去延時。
  • 通過 lua 腳本實作延遲。

總結

完工,我建議你合上螢幕,自己在腦子裡重新過一遍,每一步都在做什麼,為什麼要做,解決什麼問題。

我們一起從頭到尾梳理了一遍 Redis 分布式鎖中的各種門道,其實很多點是不管用什麼做分布式鎖都會存在的問題,重要的是思考的過程。

對于系統的設計,每個人的出發點都不一樣,沒有完美的架構,沒有普适的架構,但是在完美和普适能平衡的很好的架構,就是好的架構。

繼續閱讀