在單節點狀況下,實現線程安全須要靠同步狀態來控制。而在分佈式應用中,使程序正確執行不被併發問題影響,就須要分佈式鎖來控制。html
在單節點中,須要用一個併發線程都能訪問到的資源的狀態變化來控制同步。在分佈式應用中,使用應用全部節點都能訪問到的 Redis
中的某個 key
來控制併發問題。java
setnx
setnx
指令會在 key
不存在的狀況下放入 redis
,若是存在則不會設置。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
語句以前可能發生異常。死鎖仍然會出現。bash
set and expire
爲了解決非原子性操做被中斷的問題,在 Redis 2.8
中加入了 setnx
與 expire
組合在一塊兒的原子指令。併發
>set lock:distributed true ex 5 nx
OK
...
other code
...
>del lock:distributed
複製代碼
這種方式保證了加鎖並設置有效時間操做的原子性,可是依然有問題。dom
假設咱們在加鎖與釋放鎖之間的業務代碼執行時間超過了設置的有效時間,此時鎖會由於超時被釋放。會致使兩種狀況:異步
由於在加鎖時,各個節點使用的同一個 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, val, 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,1,key,val,"20");
// 每隔十秒檢查是否完成
Thread.sleep(10);
}
}
}
複製代碼
這種實現是用一個線程按期監控客戶端是否執行完成。也能夠由服務端實現心跳檢測機制來保證業務完成(Zookeeper
)。
因此實現單節點 Redis
分佈式鎖要關注三個關鍵問題:
Redis2.8
開始已支持)key
設置隨機值)lua
腳本實現)爲了保證項目的高可用性,項目通常都配置了 Redis
集羣,以防在單節點 Redis
宕機以後,全部客戶端都沒法得到鎖。
在集羣環境下,Redis
存在 failover
機制。當 Master
節點宕機以後,會開始異步的主從複製(replication
),這個過程可能會出現如下狀況:
Master
節點的鎖。Master
節點宕機了,存儲鎖的 key
暫未同步到 Slave
上。Slave
節點升級爲 Master
節點。Master
節點上獲取到了同一資源的鎖。在這種狀況下,鎖的安全性就會被打破,Redis
做者 antirez
針對此問題設計了 Redlock
算法。
Redlock
算法獲取鎖時客戶端執行步驟:
Redis
節點請求鎖。請求鎖的方式與從單節點 Redis
獲取鎖的方式一致。爲了保證在某個 Redis
節點不可用時該算法可以繼續運行,獲取鎖的操做都須要設置超時時間,須要保證該超時時間遠小於鎖的有效時間。這樣才能保證客戶端在向某個 Redis
節點獲取鎖失敗以後,能夠馬上嘗試下一個節點。Redis
節點(>= N/2 + 1) 成功獲取鎖,而且獲取鎖總時長沒有超過鎖的有效時間,這種狀況下,客戶端會認爲獲取鎖成功,不然,獲取鎖失敗。consumeTime
。Redis
節點發起釋放鎖的請求。在釋放鎖時,須要向全部 Redis
節點發起釋放鎖的操做,無論節點是否獲取鎖成功。由於可能存在客戶端向 Redis
節點獲取鎖時成功,但節點通知客戶端時通訊失敗,客戶端會認爲該節點加鎖失敗。
Redlock
算法實現了更高的可用性,也不會出現 failover
時失效的問題。可是若是有節點崩潰重啓,仍然對鎖的安全性有影響。假設共有 5 個 Redis
節點 A、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
分佈式鎖。