Redis實現分散式鎖十連問

語言: CN / TW / HK

前言

分散式鎖就是在多個程序之間達到互斥的目的,常見的方案包括:基於DB的唯一索引、Zookeeper的臨時有序節點、Redis的SETNX來實現;Redis因為其高效能被廣泛使用,本文通過一問一答的方式來了解Redis如何去實現分散式鎖的。

1.Redis怎麼實現分散式鎖

使用Redis提供的SETNX命令保證只有一次能寫入成功

SETNX key value

當且僅當key不存在,則給key設值為value;若給定的key已經存在,則什麼也不做;

127.0.0.1:6379> setnx lock 001
(integer) 1
127.0.0.1:6379> setnx lock 002
(integer) 0

當然也可以使用SET命令,並使用NX關鍵字

set <key> <value> NX

2.如果獲取鎖的節點掛了怎麼辦

如果僅僅使用SETNX命令,當某個節點搶佔到鎖,如果這時候當前節點掛了,那麼導致這個鎖無法釋放,最終會導致死鎖出現;這時候想到的是給key設定一個過期時間,這樣就是節點掛了也會自動刪除;

127.0.0.1:6379> expire lock 5
(integer) 1

以上使用expire命令設定過期時間;

3.如果Set執行完Expire未執行節點掛了

以上問題的原因是因為SETNX命令和Expire不是原子操作,所有有可能在執行完SETNX命令之後節點就掛了,這時候Expire還沒來得及執行,同樣會導致鎖無法釋放,出現死鎖現象;

127.0.0.1:6379> set lock 001 ex 5 nx
OK

如上命令將SETNXExpire命令整合成一個原子操作,保證了同時成功同時失敗;

4.沒有獲取鎖的節點如何阻塞處理

沒有獲取到鎖的節點需要處於阻塞狀態,並且定時去重試,保證第一時間能獲取鎖;

while(true){
   set lock uuid ex 5 nx;   ## 搶佔鎖
   if(獲取鎖){
      break;
   }
   ......
   sleep(1);                ## 防止一直消耗CPU 
}

如果想功能更強大一點可以指定阻塞時間,超過指定阻塞時間就直接獲取鎖失敗;

5.如果解決鎖的可重入問題

可重入就是如果某個執行緒獲取了鎖,那麼當前執行緒再次獲取鎖的時候,應該還是可以進入鎖中的,每重入一次數量加一,出來時減一;本地可以使用threadId或者直接使用ThreadLocal來實現;當然最好是直接把相關資訊儲存在Redis中,Redisson使用lua指令碼來記錄threadId資訊:

if (redis.call('exists', KEYS[1]) == 0) then            ## 如果鎖不存在
redis.call('hincrby', KEYS[1], ARGV[2], 1);             ## 儲存鎖,同時設定threadId
redis.call('pexpire', KEYS[1], ARGV[1]);                ## 設定過期時間
return nil; 
end; 
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then  ## 如果鎖存在並且threadId就是當前執行緒id
redis.call('hincrby', KEYS[1], ARGV[2], 1);             ## 給threadId自增
redis.call('pexpire', KEYS[1], ARGV[1]);                ## 設定過期時間
return nil; 
end; " 
return redis.call('pttl', KEYS[1]);

6.如果過期時間到了,任務剛好執行完會怎麼樣

正常來說我們預估的過期時間相對來說都比執行任務的時間長一些,所以當任務執行完之後會做刪除操作

127.0.0.1:6379> del lock
(integer) 1

有沒有可能A節點獲取的鎖過期時間到了,鎖被刪除,這時候B節點獲取到鎖,又重新執行了set ex nx命令;而剛好A節點任務執行完成,並且執行刪除鎖命令,把B節點的鎖給刪掉,出現鎖被誤刪的情況;

這種情況就需要我們在刪除鎖的時候,檢查當前被刪除的鎖是否就是我們之前獲取的鎖,可以在set的時候執行一個唯一的value,比如直接使用uuid;這樣在刪除的時候我們需要先獲取鎖對應的value值,然後和當前節點物件的value做比較,一致才可以刪除;

string uuid = gen();     ## 生成一個唯一value
set lock uuid ex 5 nx;   ## 搶佔鎖
......                   ## 執行業務   
string value = get lock; ## 獲取當前鎖對應的value值
if(value == uuid) {      ## 對比獲取的value值和uuid是否一致
   del lock              ## 一致執行刪除操作
} else {
   return;               ## 否則不執行刪除操作
}

7.如果過期時間到了,任務還沒執行完怎麼辦

過期時間是一個預估的時間,如果真有某個任務執行的時間很長,而這時候剛好過期時間到了,鎖就會被刪除,導致其他節點又可以獲取鎖了,這樣就出現了多個節點同時獲取鎖的情況;

這種情況一般會這麼解決:

  • 過期時間設定的足夠長,確保任務可以執行完;
  • 啟動一個守護執行緒,為將要過期但未釋放的鎖增加時間,就是給鎖續命;

我們常用的工具包Redisson,內部提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,不斷的延長鎖的有效期;內部使用HashedWheelTimer作為定時器定期檢查;

8.Redis主節點宕機,還未同步從節點怎麼辦

我們知道Redis主從同步是非同步的,如果某個節點獲取了鎖,這時候鎖資訊還未同步到從節點,主節點宕機了,從節點升級為主節點,導致鎖丟失;這種情況Redis作者提出了redlock演算法,大致含義如下:

在Redis的分散式環境中,假設我們有N個Redis主機;這些節點是完全獨立的,因此我們不使用複製或任何其他隱式協調系統;

當且僅當從大多數(N/2+1,這裡是3個節點)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖才算獲取成功。

Redisson提供了RedLock的支援,使用也很簡單:

RLock lock1 = redissonClient1.getLock(resourceName); 
RLock lock2 = redissonClient2.getLock(resourceName); 
RLock lock3 = redissonClient3.getLock(resourceName); 
// 向3個redis例項嘗試加鎖 
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); 

更多:redlock

9.Redis出現叢集腦裂會怎麼樣

叢集腦裂指因為網路問題,導致主節點、從節點以及sentinel處於不同的網路分割槽,因為sentinel的存在會因為某些主節點不存在,而提升從節點為主節點,這時候就存在了不同的主節點,此時不同的客戶端可能連線不同的主節點,兩個客戶端可以同時擁有同一把鎖;

Redis 提供了兩個配置項來限制主庫的請求處理,分別是 min-slaves-to-writemin-slaves-max-lag

  • min-slaves-to-write:設定了主庫能進行資料同步的最少從庫數量
  • min-slaves-max-lag:設定了主從庫間進行資料複製時,從庫給主庫傳送ACK訊息的最大延遲(以秒為單位)

配置項組合後要求主庫連線的從庫中至少有 N 個從庫、主庫進行資料複製時的 ACK 訊息延遲不能超過N秒,否則主庫就不會再接收客戶端的請求。

10.如何實現一個公平鎖

我們知道ReentrantLock通過AQS來公平鎖,AQS內部通過雙向佇列來實現,Redis本身提供了多種資料結構包括列表、有序集合等;Redisson實現公平鎖正是通過Redis內建的資料結構來實現的:

  • 使用列表作為執行緒的等待佇列,新的等待佇列新增到列表的尾部;
  • 使用有序集合存放等待執行緒的順序,分數score是等待執行緒的超時時間戳;

總結

不管使用哪種方式去實現分散式鎖,我們前提需要保證鎖的功能包括:互斥性、可重入性、阻塞性;同時因為分散式的存在我們需要保證系統的高可用、高效能、杜絕一切出現死鎖和同時獲得鎖的情況。