Redlock:Redis集羣分佈式鎖

前言

分佈式鎖是一種非常有用的技術手段。實現高效的分佈式鎖有三個屬性需要考慮:
● 安全屬性:互斥,不管什麼時候,只有一個客戶端持有鎖
● 效率屬性A:不會死鎖
● 效率屬性B:容錯,只要大多數redis節點能夠正常工作,客戶端端都能獲取和釋放鎖。

普通版:單機redis分佈式鎖
說道Redis分佈式鎖大部分人都會想到: setnx+lua或者set+lua,加上過期時間
大多都是使用的下面的keyset方法,具體實現過程這裏就不再概述。

  • 實現比較輕,大多數時候能滿足需求;因爲是單機單實例部署,如果redis服務宕機,那麼所有需要獲取分佈式鎖的地方均無法獲取鎖,將全部阻塞,需要做好降級處理。
  • 當鎖過期後,執行任務的進程還沒有執行完,但是鎖因爲自動過期已經解鎖,可能被其它進程重新加鎖,這就造成多個進程同時獲取到了鎖,這需要額外的方案來解決這種問題。
  • 在集羣模式時由於複製延遲,以及主節點擋掉,會造成鎖丟失或者解鎖延遲現象

事實上這類瑣最大的缺點就是它加鎖時只作用在一個Redis節點上,即使Redis通過sentinel保證高可用,如果這個master節點由於某些原因發生了主從切換,那麼就會出 現鎖丟失的情況:

  • 在Redis的master節點上拿到了鎖;
  • 但是這個加鎖的key還沒有同步到slave節點;
  • master故障,發生故障轉移,slave節點升級爲master節點;
  • 導致鎖丟失。

正因爲如此,Redis作者antirez基於分佈式環境下提出了一種更高級的分佈式鎖的實現方式:Redlock。筆者認爲,Redlock也是Redis所有分佈式鎖實現方式中唯一能讓面試官高潮的方式。

基於單Redis節點的分佈式鎖的算法就描述完了。這裏面有好幾個問題需要重點分析一下。

  • 首先第一個問題,這個鎖必須要設置一個過期時間。否則的話,當一個客戶端獲取鎖成功之後,假如它崩潰了,或者由於發生了網絡分割(network partition)導致它再也無法和Redis節點通信了,那麼它就會一直持有這個鎖,而其它客戶端永遠無法獲得鎖了。antirez在後面的分析中也特別強調了這一點,而且把這個過期時間稱爲鎖的有效時間(lock validity time)。獲得鎖的客戶端必須在這個時間之內完成對共享資源的訪問。

  • 第二個問題,第一步獲取鎖的操作,網上不少文章把它實現成了兩個Redis命令:

SETNX resource_name my_random_value
EXPIRE resource_name 30

雖然這兩個命令和前面算法描述中的一個SET命令執行效果相同,但卻不是原子的。如果客戶端在執行完SETNX後崩潰了,那麼就沒有機會執行EXPIRE了,導致它一直持有這個鎖。

  • 第三個問題,也是antirez指出的,設置一個隨機字符串my_random_value是很有必要的,它保證了一個客戶端釋放的鎖必須是自己持有的那個鎖。假如獲取鎖時SET的不是一個隨機字符串,而是一個固定值,那麼可能會發生下面的執行序列:
    客戶端1獲取鎖成功。
    客戶端1在某個操作上阻塞了很長時間。
    過期時間到了,鎖自動釋放了。
    客戶端2獲取到了對應同一個資源的鎖。
    客戶端1從阻塞中恢復過來,釋放掉了客戶端2持有的鎖。
    之後,客戶端2在訪問共享資源的時候,就沒有鎖爲它提供保護了。

  • 第四個問題,釋放鎖的操作必須使用Lua腳本來實現。釋放鎖其實包含三步操作:’GET’、判斷和’DEL’,用Lua腳本來實現能保證這三步的原子性。否則,如果把這三步操作放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題類似的執行序列:
    客戶端1獲取鎖成功。
    客戶端1訪問共享資源。
    客戶端1爲了釋放鎖,先執行’GET’操作獲取隨機字符串的值。
    客戶端1判斷隨機字符串的值,與預期的值相等。
    客戶端1由於某個原因阻塞住了很長時間。
    過期時間到了,鎖自動釋放了。
    客戶端2獲取到了對應同一個資源的鎖。
    客戶端1從阻塞中恢復過來,執行DEL操縱,釋放掉了客戶端2持有的鎖。
    實際上,在上述第三個問題和第四個問題的分析中,如果不是客戶端阻塞住了,而是出現了大的網絡延遲,也有可能導致類似的執行序列發生。

前面的四個問題,只要實現分佈式鎖的時候加以注意,就都能夠被正確處理。但除此之外,antirez還指出了一個問題,是由failover引起的,卻是基於單Redis節點的分佈式鎖無法解決的。正是這個問題催生了Redlock的出現。

更多專業性問題參考:
https://www.jianshu.com/p/dd66bdd18a56

集羣分佈式鎖

在redis集羣模式下創建鎖和解鎖的方案,用到的redis命令依然和普通模式一樣,唯一不同的在於集羣模式下的數據清理方式,基本命令如下。

SET key value [EX seconds] [PX milliseconds] [NX|XX]
Set the string value of a key
SET 指令可以將字符串的value和key綁定在一起。
EX seconds:設置key的過時時間,單位爲秒。
PX milliseconds:設置key的過期時間,單位爲毫秒。
NX:(if Not eXist)只有鍵key不存在的時候纔會設置key的值
XX:只有鍵key存在的時候纔會設置key的值

NX通常用於實現鎖機制,X

lua腳本

- 獲取鎖(unique_value可以是UUID等)
SET key_name unique_value NX PX 30000
- 釋放鎖(lua腳本中,一定要比較value,防止誤解鎖)
if redis.call("get",KEYS[1]) == ARGV[1] then
 return redis.call("del",KEYS[1])
else
return 0
end

Redlock實現

antirez提出的redlock算法大概是這樣的:
在Redis的分佈式環境中,我們假設最孤立現象(最苛刻環境下):有N個Redis master。這些節點完全互相獨立(正常集羣不會做成這麼孤立),不存在主從複製或者其他集羣協調機制。我們確保將在N個實例上使用與在Redis單實例下相同方法獲取和釋放鎖。現在我們假設有5個Redis master節點,同時我們需要在5臺服務器上面運行這些Redis實例,這樣保證他們不會同時都宕掉。
在這裏插入圖片描述
這裏把上圖中的各個redis主從連線去掉,就變成各個獨立的集羣了(實現孤立場景:集羣之間掉線不通等極端情況)

爲了取到鎖,客戶端應該執行以下操作:

  • 獲取當前Unix時間,以毫秒爲單位。
  • 依次嘗試從5個實例,使用相同的key和具有唯一性的value(例如UUID)獲取鎖。當向Redis請求獲取鎖時,客戶端應該設置一個網絡連接和響應超時時間,這個超時時間應該小於鎖的失效時間。例如你的鎖自動失效時間爲10秒,則超時時間應該在5-50毫秒之間。這樣可以避免服務器端Redis已經掛掉的情況下,客戶端還在死死地等待響應結果。如果服務器端沒有在規定時間內響應,客戶端應該儘快嘗試去另外一個Redis實例請求獲取鎖。
  • 客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數(N/2+1,這裏是3個節點)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖纔算獲取成功
  • 如果取到了鎖,key的真正有效時間等於有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
  • 如果因爲某些原因,獲取鎖失敗(沒有在至少N/2+1個Redis實例取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。

實現代碼

POM依賴

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
 <groupId>org.redisson</groupId>
 <artifactId>redisson</artifactId>
 <version>3.3.2</version>
</dependency>
Config config = new Config();

config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
 .setMasterName("masterName")
 .setPassword("password").setDatabase(0);

RedissonClient redissonClient = Redisson.create(config);
// 還可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
 isLock = redLock.tryLock();//使用默認方式獲取:默認租約時間(leaseTime)是LOCKEXPIRATIONINTERVAL_SECONDS,即30s
 // 因爲上一行獲取到了,這裏獲取不到:如果獲取到了就500ms, 就認爲獲取鎖失敗。10000ms即10s是鎖失效時間。
 isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
 if (isLock) { //如果獲取成功
 //TODO if get lock success, do something;

 }
} catch (Exception e) {

} finally {
 // 無論如何, 最後都要解鎖
 redLock.unlock();
}

key對應唯一值
防止誤刪除鎖:實現分佈式鎖的一個非常重要的點就是set的value要具有唯一性,redisson的value是怎樣保證value的唯一性呢?
答案是UUID+threadId。入口在redissonClient.getLock(“REDLOCK_KEY”),源碼在Redisson.java和RedissonLock.java中:

獲取鎖
設置過期時間:代碼爲redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),兩者的最終核心源碼都是下面這段代碼,只不過前者獲取鎖的默認租約時間(leaseTime)是LOCKEXPIRATIONINTERVAL_SECONDS,即30s:

參考

https://redis.io/topics/distlock
https://www.jianshu.com/p/dd66bdd18a56

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章