Redis 實現分散式鎖
在單節點情況下,實現執行緒安全需要靠同步狀態來控制。而在分散式應用中,使程式正確執行不被併發問題影響,就需要分散式鎖來控制。
在單節點中,需要用一個併發執行緒都能訪問到的資源的狀態變化來控制同步。在分散式應用中,使用應用所有節點都能訪問到的 Redis
中的某個 key
來控制併發問題。
單節點 Redis 分散式鎖
setnx
setnx
指令會在 key
不存在的情況下放入 redis
,如果存在則不會設定。
>setnx lock:distributed true
OK
...
other code
...
>del lock:distributed
複製程式碼
這種方式的問題在於,執行到 other code 時,程式出現異常,導致 del
key
沒有被釋放,這樣會陷入死鎖。
setnx then expire
為瞭解決死鎖,乍一看可以使用 expire
來給 key
設定超時時間。
>setnx lock:distributed true
OK
>expire lock:distributed 5
...
other code
...
>del lock:distributed
複製程式碼
這種處理其實仍然有問題,因為 setnx
與 expire
不是原子操作, 執行 expire
語句之前可能發生異常。死鎖仍然會出現。
set and expire
為瞭解決非原子性操作被中斷的問題,在 Redis 2.8
setnx
與 expire
組合在一起的原子指令。
>set lock:distributed true ex 5 nx
OK
...
other code
...
>del lock:distributed
複製程式碼
這種方式保證了加鎖並設定有效時間操作的原子性,但是依然有問題。
假設我們在加鎖與釋放鎖之間的業務程式碼執行時間超過了設定的有效時間,此時鎖會因為超時被釋放。會導致兩種情況:
- 其他節點 B 獲取鎖之後,執行超時節點 A 執行完成,釋放了 B 的鎖。
- 其它節點獲取到了鎖,執行臨界區程式碼時就可能會出現併發問題。
解決鎖被其他執行緒釋放問題
因為在加鎖時,各個節點使用的同一個 key
key
設定一個隨機值,刪除的時候需要判斷 key
當前的 value
是不是等於隨機值。
val = Random.nextInt();
if( redis.set(key,val,true,5) ){
...
other code
...
value = redis.get(key);
if(val == value){
redis.delete(key);
}
}
複製程式碼
上述程式碼實現了根據隨機值刪除的邏輯,但是獲取 value
直到 delete
指令並非是原子指令,仍然可能有併發問題。這時候需要使用 lua
指令碼處理,因為 lua
指令碼可以保證連續多個指令原子執行。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
複製程式碼
這種方式可以避免鎖被其他執行緒釋放的問題。
臨界區併發問題
臨界區程式碼出現併發問題的本質是業務程式碼執行時間大於鎖過期時間。
我們可以定時重新整理加鎖時間,保證業務程式碼在鎖過期時間內執行完成。
private volatile boolean isFlushExpiration = true;
while(redis.set(lock,NOT_EXIST,SECONDS,20)){
Thread thread = new Thread(new FlushExpirationTherad());
thread.setDeamon(true);
thread.start();
...
other code
...
}
isFlushExpiration = false;
String deleteScript = "if redis.call("get",KEYS[1]) == ARGV[1] then"
+ "return redis.call("del",KEYS[1])"
+ "else return 0 end";
redis.eval(deleteScript,1,key,val);
private class FlushExpirationTherad implements Runnable{
@Override
public void run(){
while(isFlushExpiration){
String checkAndExpireScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else return 0 end";
redis.eval(checkAndExpireScript,"20");
// 每隔十秒檢查是否完成
Thread.sleep(10);
}
}
}
複製程式碼
這種實現是用一個執行緒定期監控客戶端是否執行完成。也可以由服務端實現心跳檢測機制來保證業務完成(Zookeeper
)。
所以實現單節點 Redis
分散式鎖要關注三個關鍵問題:
- 獲取鎖與設定超時時間實現為原子操作(
Redis2.8
開始已支援) - 設定隨機字串保證釋放鎖時能保證只釋放自己持有的鎖(給對應的
key
設定隨機值) - 判斷與釋放鎖必須實現為原子操作(
lua
指令碼實現)
多節點 Redis 分散式鎖
為了保證專案的高可用性,專案一般都配置了 Redis
叢集,以防在單節點 Redis
宕機之後,所有客戶端都無法獲得鎖。
在叢集環境下,Redis
存在 failover
機制。當 Master
節點宕機之後,會開始非同步的主從複製(replication
),這個過程可能會出現以下情況:
- 客戶端 A 獲取了
Master
節點的鎖。 -
Master
節點宕機了,儲存鎖的key
暫未同步到Slave
上。 -
Slave
節點升級為Master
節點。 - 客戶端 B 從新的
Master
節點上獲取到了同一資源的鎖。
在這種情況下,鎖的安全性就會被打破,Redis
作者 antirez
針對此問題設計了 Redlock
演演算法。
Redlock 演演算法
Redlock
演演算法獲取鎖時客戶端執行步驟:
- 獲取當前時間(start)。
- 依次向 N 個
Redis
節點請求鎖。請求鎖的方式與從單節點Redis
獲取鎖的方式一致。為了保證在某個Redis
節點不可用時該演演算法能夠繼續執行,獲取鎖的操作都需要設定超時時間,需要保證該超時時間遠小於鎖的有效時間。這樣才能保證客戶端在向某個Redis
節點獲取鎖失敗之後,可以立刻嘗試下一個節點。 - 計算獲取鎖的過程總共消耗多長時間(consumeTime = end - start)。如果客戶端從大多數
Redis
節點(>= N/2 + 1) 成功獲取鎖,並且獲取鎖總時長沒有超過鎖的有效時間,這種情況下,客戶端會認為獲取鎖成功,否則,獲取鎖失敗。 - 如果最終獲取鎖成功,鎖的有效時間應該重新設定為鎖最初的有效時間減去
consumeTime
。 - 如果最終獲取鎖失敗,客戶端應該立刻向所有
Redis
節點發起釋放鎖的請求。
在釋放鎖時,需要向所有 Redis
節點發起釋放鎖的操作,不管節點是否獲取鎖成功。因為可能存在客戶端向 Redis
節點獲取鎖時成功,但節點通知客戶端時通訊失敗,客戶端會認為該節點加鎖失敗。
Redlock
演演算法實現了更高的可用性,也不會出現 failover
時失效的問題。但是如果有節點崩潰重啟,仍然對鎖的安全性有影響。假設共有 5 個 Redis
節點 A、B、C、D、E:
- 客戶端 A 獲取了 A、B、C 節點的鎖,但 D 與 E 節點的鎖獲取失敗。
- 節點 C 崩潰重啟,但是客戶端 A 在 C 上加的鎖沒有持久化下來,重啟後丟失
- 節點 C 重啟後,客戶端 B 鎖住了 C、D、E,獲取鎖成功。
在這種情況下,客戶端 A 與 B 都獲取了訪問同一資源的鎖。
這裡第 2 步中節點 C 鎖丟失的問題可能由多種原因引起。預設情況下,
Redis
的AOF
持久化方式是每秒寫一次磁碟(fsync),這情況下就有可能丟失 1 秒的資料。我們也可以設定每次操作都觸發fsync
,這會影響效能,不過即使這樣設定,也有可能由於作業系統的問題導致操作寫入失敗。
為瞭解決節點重啟導致的鎖失效問題,antirez
提出了延遲重啟的概念,即當一個節點崩潰之後並不立即重啟,而是等待與分散式鎖相關的 key
的有效時間都過期之後再重啟,這樣在該節點重啟後也不會對現有的鎖造成影響。
一些插曲
關於 Redlock
的安全性問題,在分散式系統專家 Martin Kleppmann 和 Redis
的作者 antirez 之間發生過一場爭論,這個問題引發了激烈的討論。關於這場爭論的內容可以關注 基於Redis的分散式鎖到底安全嗎 這篇文章。
最後得出的結論是 Redlock
在效率要求的應用中是合理的,所以在 Java
專案中可以使用 Redlock
的 Java
版本 Redission
來控制多節點訪問共享資源。但是仍有極端情況會造成 Redlock
的不安全,我們應該知道它在安全性上有哪些不足以及會造成什麼後果。如果需要進一步的追求正確性,可以使用 Zookeeper
分散式鎖。