1. 程式人生 > 程式設計 >Redis 實現分散式鎖

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
複製程式碼

這種處理其實仍然有問題,因為 setnxexpire 不是原子操作, 執行 expire 語句之前可能發生異常。死鎖仍然會出現。

set and expire

為瞭解決非原子性操作被中斷的問題,在 Redis 2.8

中加入了 setnxexpire 組合在一起的原子指令。

>set lock:distributed true ex 5 nx
OK
...
other code
...
>del lock:distributed
複製程式碼

這種方式保證了加鎖並設定有效時間操作的原子性,但是依然有問題。

假設我們在加鎖與釋放鎖之間的業務程式碼執行時間超過了設定的有效時間,此時鎖會因為超時被釋放。會導致兩種情況:

  1. 其他節點 B 獲取鎖之後,執行超時節點 A 執行完成,釋放了 B 的鎖。
  2. 其它節點獲取到了鎖,執行臨界區程式碼時就可能會出現併發問題。

解決鎖被其他執行緒釋放問題

因為在加鎖時,各個節點使用的同一個 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 分散式鎖要關注三個關鍵問題:

  1. 獲取鎖與設定超時時間實現為原子操作(Redis2.8 開始已支援)
  2. 設定隨機字串保證釋放鎖時能保證只釋放自己持有的鎖(給對應的 key 設定隨機值)
  3. 判斷與釋放鎖必須實現為原子操作(lua 指令碼實現)

多節點 Redis 分散式鎖

為了保證專案的高可用性,專案一般都配置了 Redis 叢集,以防在單節點 Redis 宕機之後,所有客戶端都無法獲得鎖。

在叢集環境下,Redis 存在 failover 機制。當 Master 節點宕機之後,會開始非同步的主從複製(replication),這個過程可能會出現以下情況:

  1. 客戶端 A 獲取了 Master 節點的鎖。
  2. Master 節點宕機了,儲存鎖的 key 暫未同步到 Slave 上。
  3. Slave 節點升級為 Master 節點。
  4. 客戶端 B 從新的 Master 節點上獲取到了同一資源的鎖。

在這種情況下,鎖的安全性就會被打破,Redis 作者 antirez 針對此問題設計了 Redlock 演演算法。

Redlock 演演算法

Redlock 演演算法獲取鎖時客戶端執行步驟:

  1. 獲取當前時間(start)。
  2. 依次向 N 個 Redis 節點請求鎖。請求鎖的方式與從單節點 Redis 獲取鎖的方式一致。為了保證在某個 Redis 節點不可用時該演演算法能夠繼續執行,獲取鎖的操作都需要設定超時時間,需要保證該超時時間遠小於鎖的有效時間。這樣才能保證客戶端在向某個 Redis 節點獲取鎖失敗之後,可以立刻嘗試下一個節點。
  3. 計算獲取鎖的過程總共消耗多長時間(consumeTime = end - start)。如果客戶端從大多數 Redis 節點(>= N/2 + 1) 成功獲取鎖,並且獲取鎖總時長沒有超過鎖的有效時間,這種情況下,客戶端會認為獲取鎖成功,否則,獲取鎖失敗。
  4. 如果最終獲取鎖成功,鎖的有效時間應該重新設定為鎖最初的有效時間減去 consumeTime
  5. 如果最終獲取鎖失敗,客戶端應該立刻向所有 Redis 節點發起釋放鎖的請求。

在釋放鎖時,需要向所有 Redis 節點發起釋放鎖的操作,不管節點是否獲取鎖成功。因為可能存在客戶端向 Redis 節點獲取鎖時成功,但節點通知客戶端時通訊失敗,客戶端會認為該節點加鎖失敗。

Redlock 演演算法實現了更高的可用性,也不會出現 failover 時失效的問題。但是如果有節點崩潰重啟,仍然對鎖的安全性有影響。假設共有 5 個 Redis 節點 A、B、C、D、E:

  1. 客戶端 A 獲取了 A、B、C 節點的鎖,但 D 與 E 節點的鎖獲取失敗。
  2. 節點 C 崩潰重啟,但是客戶端 A 在 C 上加的鎖沒有持久化下來,重啟後丟失
  3. 節點 C 重啟後,客戶端 B 鎖住了 C、D、E,獲取鎖成功。

在這種情況下,客戶端 A 與 B 都獲取了訪問同一資源的鎖。

這裡第 2 步中節點 C 鎖丟失的問題可能由多種原因引起。預設情況下,RedisAOF 持久化方式是每秒寫一次磁碟(fsync),這情況下就有可能丟失 1 秒的資料。我們也可以設定每次操作都觸發 fsync,這會影響效能,不過即使這樣設定,也有可能由於作業系統的問題導致操作寫入失敗。

為瞭解決節點重啟導致的鎖失效問題,antirez 提出了延遲重啟的概念,即當一個節點崩潰之後並不立即重啟,而是等待與分散式鎖相關的 key 的有效時間都過期之後再重啟,這樣在該節點重啟後也不會對現有的鎖造成影響。

一些插曲

關於 Redlock 的安全性問題,在分散式系統專家 Martin Kleppmann 和 Redis 的作者 antirez 之間發生過一場爭論,這個問題引發了激烈的討論。關於這場爭論的內容可以關注 基於Redis的分散式鎖到底安全嗎 這篇文章。 最後得出的結論是 Redlock 在效率要求的應用中是合理的,所以在 Java 專案中可以使用 RedlockJava 版本 Redission 來控制多節點訪問共享資源。但是仍有極端情況會造成 Redlock 的不安全,我們應該知道它在安全性上有哪些不足以及會造成什麼後果。如果需要進一步的追求正確性,可以使用 Zookeeper 分散式鎖。

相關連結