一文帶你精通分散式鎖

語言: CN / TW / HK

theme: channing-cyan

我正在參加「掘金·啟航計劃」

在單機環境下,由於使用環境簡單和通訊可靠,鎖的可見性和原子性很容易可以保證,可以簡單和可靠地實現鎖功能。到了分散式的環境下,由於公共資源和使用方之間的分離,以及使用方和使用方之間的分離,相互之間的通訊由執行緒間的記憶體通訊變為網路通訊。網路通訊的時延和不可靠,加上分散式環境中各種故障的常態化發生,導致實現一個可靠的分散式鎖服務需要考慮更多更復雜的問題。

鎖,核心是協調各個使用方對公共資源使用的一種機制。當存在多個使用方互斥地使用某一個公共資源時,為了避免並行使用導致的修改結果不可控,需要在某個地方記錄一個標記,這個標記能夠被所有使用方看到,當標記不存在時,可以設定標記並且獲得公共資源的使用權,其餘使用者發現標記已經存在時,只能等待標記擁有方釋放後,再去嘗試設定標記。這個標記即可以理解為鎖。

在單機多執行緒的環境下,由於使用環境簡單和通訊可靠,鎖的可見性和原子性很容易可以保證,所以使用系統提供的互斥鎖等方案,可以簡單和可靠地實現鎖功能。到了分散式的環境下,由於公共資源和使用方之間的分離,以及使用方和使用方之間的分離,相互之間的通訊由執行緒間的記憶體通訊變為網路通訊。網路通訊的時延和不可靠,加上分散式環境中各種故障的常態化發生,導致實現一個可靠的分散式鎖服務需要考慮更多更復雜的問題。

目前常見的分散式鎖服務,可以分為以下三大類:

  • 基於資料庫實現的鎖服務:典型代表是 mysql
  • 基於分散式快取實現的鎖服務及其變種:典型代表是使用 Redis 實現的鎖服務和基於 Redis 實現的 RedLock 方案;
  • 基於分散式一致性演算法實現的鎖服務:典型代表為 ZookeeperetcdChubby 等。

本文從上述三大類常見的分散式鎖服務實現方案入手,從分散式鎖服務的各個核心問題(核心架構、鎖資料一致性、鎖服務可用性、死鎖預防機制、易用性、效能)展開,嘗試對比分析各個實現方案的優劣和特點。

基於資料庫實現的鎖服務

基於資料庫的實現方式的核心思想是:在資料庫中建立一個表,表中包含方法名等欄位,並在方法名欄位上建立唯一索引,想要執行某個方法,就使用這個方法名向表中插入資料,成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖。

加解鎖流程

(1)建立一個表: sql DROP TABLE IF EXISTS `method_lock`; CREATE TABLE `method_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', `method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名', `desc` varchar(255) NOT NULL COMMENT '備註資訊', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法'; (2)想要執行某個方法,就使用這個方法名向表中插入資料: sql INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName'); 因為我們對method_name做了唯一性約束,這裡如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。

(3)成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖: delete from method_lock where method_name ='methodName'; 注意: 這只是使用基於資料庫的一種方法,使用資料庫實現分散式鎖還有很多其他的方法。

鎖安全性分析

1、資料庫的可用性和效能將直接影響分散式鎖的可用性及效能,當資料庫發生故障時,鎖就會失效,當然也可以通過資料庫雙機部署、資料同步、主備切換來提高可用性。

2、鎖沒有失效機制,如果客戶端1獲取鎖後,伺服器宕機了,對應的鎖沒有釋放,當服務恢復後一直獲取不到鎖,可以在表中新增一列,用於記錄失效時間,並且需要有定時任務清除這些失效的資料。

總結

1、 鎖服務效能\ 由於鎖資料基於資料庫,且實現一個安全的鎖機制需要應用層編寫大量的程式碼。在併發度不高,且不想引入其他元件的情況下可以使用這種方法。

2、 資料一致性和可用性\ 如果是單點的資料庫,當資料庫掛掉之後鎖就不可以了。

基於分散式快取實現的鎖服務

基於單 Redis 節點的分散式鎖

基於分散式快取實現的鎖服務,思路最為簡單和直觀。和單機環境的鎖一樣,我們把鎖資料存放在分散式環境中的一個唯一結點,所有需要獲取鎖的呼叫方,都去此結點訪問,從而實現對呼叫方的互斥,而存放鎖資料的結點,使用各類分散式快取產品充當。

加解鎖流程

加鎖操作: SET resource_name my_random_value NX PX 30000 - my_random_value 是由客戶端生成的一個隨機字串,它要保證在足夠長的一段時間內在所有客戶端的所有獲取鎖的請求中都是唯一的,用於唯一標識鎖持有方。 - NX 表示只有當 resource_name 對應的 key 值不存在的時候才能 SET 成功。這保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。 - PX 30000 表示這個鎖結點有一個30秒的自動過期時間。(自動過期時間,目的是為了防止持有鎖的客戶端故障後,鎖無法被釋放導致死鎖而設定,從而要求鎖擁有者必須在過期時間之內執行完相關操作並釋放鎖)。

解鎖操作: lua if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end 釋放鎖的操作必須使用Lua指令碼來實現,釋放鎖其實包含三步操作:'GET'、判斷和'DEL',用Lua指令碼來實現能保證這三步的原子性。

鎖安全性分析

1、Redis 結點故障後,由於 Redis 的主從複製(replication)是非同步的,這可能導致在 failover 過程中沒有備份到鎖資料,從而破壞鎖的安全性。 2、程式執行耗時大於鎖過期時間。可以考慮以下情景: - 客戶端1獲取鎖成功 - 客戶端1在某個操作上執行了很長時間 - 過期時間到,鎖自動釋放 - 客戶端2獲取到了對應同一個資源的鎖 - 客戶端1從阻塞中恢復過來,認為自己依舊持有鎖,繼續操作同一個資源,導致互斥性失效

無鎖續期.png 這種就需要客戶端實現鎖續期的機制。在程式執行的過程定時檢查鎖是否快過期,如果快過期就延長鎖的過期時間。

續期.png

在上例中客戶端1如果阻塞了很長時間(例如 Java 執行了長時間的 GC)導致客戶端假死無法進行鎖續期,還是會破壞鎖的安全性。

stw.png

總結

1、 鎖服務效能\ 由於鎖資料基於 Redis 等分散式快取儲存,基於記憶體的資料操作特性使得這類鎖服務擁有著非常好的效能表現。同時鎖服務呼叫方和鎖服務本身只有一次RTT就可以完成互動,使得加鎖延遲也很低。所以,高效能、低延遲是基於分散式快取實現鎖服務的一大優勢。因此,在對效能要求較高,但是可以容忍極端情況下丟失鎖資料安全性的場景下,非常適用。

2、 資料一致性和可用性\ 鎖資料一致性基於上述的分析,基於分散式快取的鎖服務受限於通用分散式快取的定位,無法完全保證鎖資料的安全性,核心的問題為: - 鎖資料寫入的時候,沒有保證同時寫成功多份:任何事後的同步在機制上都是不夠安全的,因此在故障時,鎖資料存在丟失的可能。

基於多 Redis 節點的分散式鎖

基於分散式快取實現鎖服務,在業界還存在各類變種的方案,其核心是利用不同分散式快取產品的額外特性,來改善基礎方案的各類缺點,各類變種方案能提供的安全性和可用性也不盡相同。此處介紹一種業界最出名,同時也是引起過最大爭論的一個鎖服務變種方案- RedLock。它基於 N 個完全獨立的 Redis 節點(通常情況下 N 可以設定成5)

加解鎖流程

執行Redlock演算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操作:

  • 獲取當前時間(毫秒數)
  • 按順序依次向 N 個 Redis 節點執行獲取鎖的操作:這個獲取操作跟前面基於單 Redis 節點的獲取鎖的過程相同,包含隨機字串 my_random_value,也包含過期時間(比如 PX 30000,即鎖的有效時間)。為了保證在某個 Redis 節點不可用的時候演算法能夠繼續執行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個 Redis 節點獲取鎖失敗以後,應該立即嘗試下一個 Redis 節點。這裡的失敗,應該包含任何型別的失敗,比如該 Redis 節點不可用,或者該 Redis 節點上的鎖已經被其它客戶端持有。
  • 計算整個獲取鎖的過程總共消耗了多長時間:如果客戶端從大多數 Redis 節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認為最終獲取鎖成功;否則,認為最終獲取鎖失敗。
  • 如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。
  • 如果最終獲取鎖失敗了(可能由於獲取到鎖的 Redis 節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有 Redis 節點發起釋放鎖的操作。

而釋放鎖的過程比較簡單:客戶端向所有 Redis 節點發起釋放鎖的操作,不管這些節點當時在獲取鎖的時候成功與否。

鎖安全性分析

1、RedLock 的安全性依舊強依賴於系統時間,如果發生時鐘跳躍就會出現問題,假設一共有5個 Redis 節點:A, B, C, D, E: - 客戶端1成功鎖住了 A, B, C,獲取鎖成功(但 D 和 E 沒有鎖住)。 - 節點C時間異常,導致C上的鎖資料提前到期,而被釋放。 - 客戶端2此時嘗試獲取同一把鎖:鎖住了 C, D, E,獲取鎖成功。

2、缺乏鎖資料丟失的識別機制和恢復機制,假設一共有5個 Redis 節點:A, B, C, D, E: - 客戶端1成功鎖住了 A, B, C,獲取鎖成功(但 D 和 E 沒有鎖住)。 - 節點 C 崩潰重啟了,但客戶端1在 C 上加的鎖沒有持久化下來,丟失了。 - 節點 C 重啟後,客戶端2鎖住了 C, D, E,獲取鎖成功。

官方給出的解決方案是延遲重啟,一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,這段時間應該大於鎖的有效時間。這樣的話,這個節點在重啟前所參與的鎖都會過期,它在重啟後就不會對現有的鎖造成影響。這個方案,是在缺乏丟失資料識別的能力下,實現的較“悲觀”的一個替代方案,首先其方案依舊依賴於時間,其次如何確定最大過期時間,也是一個麻煩的事情,因為最大過期時間很可能也一起丟失了(未持久化),再有延遲重啟使得故障結點恢復的時間延長,增加了叢集服務可用性的隱患。怎麼來看,都不算一個優雅的方案。

3、仍未解決程式執行耗時大於鎖過期時間的問題。

總結

1、鎖服務效能

由於RedLock鎖資料仍然基於Redis儲存,所以和基於單點的Redis鎖一樣,具有高效能和低延遲的特性,不過由於引入多數派的思想,加鎖和解鎖時的併發寫,所以在流量消耗來說,比基於單點的Redis鎖消耗要大。從資源角度來說,是用流量換取了比單點Redis稍高的資料一致性和服務可用性。

2、資料一致性和可用性

RedLock的核心價值,在於多數派思想。不過根據上面的分析,它依然不是一個工程上可以完全保證鎖資料一致性的鎖服務。相比於基於單點Redis的鎖服務,RedLock解決了鎖資料寫入時多份的問題,從而可以克服單點故障下的資料一致性問題,但是還是受限於通用儲存的定位,其鎖服務整體機制上的不完備,使得無法完全保證鎖資料的安全性。在繼承自基於單點的Redis鎖服務缺陷(解鎖不具備原子性;鎖服務、呼叫方、資源方缺乏確認機制)的基礎上,其核心的問題為:缺乏鎖資料丟失的識別機制。

RedLock中的每臺Redis,充當的仍舊只是儲存鎖資料的功能,每臺Redis之間各自獨立,單臺Redis缺乏全域性的資訊,自然也不知道自己的鎖資料是否是完整的。在單臺Redis資料的不完整的前提下,沒有識別機制,使得在各種分散式環境的典型場景下(結點故障、網路丟包、網路亂序),沒有完整資料但參與決策,從而破壞資料一致性。

關於Redis分散式鎖的安全性問題,在分散式系統專家Martin Kleppmann和Redis的作者antirez之間就發生過一場爭論,感興趣的可以看一下這篇文章

基於分散式一致性演算法實現的鎖服務

加解鎖流程

獲取鎖

客戶端嘗試建立一個 znode 節點,比如 /lock 。那麼第一個客戶端就建立成功了,相當於拿到了鎖;而其它的 客戶端會建立失敗(znode 已存在),獲取鎖失敗。znode 應該被建立成 ephemeral 的。這是znode的一個特性,它保證如果建立 znode 的那個客戶端崩潰了,那麼相應的 znode 會被自動刪除。這保證了鎖一定會被釋放。這個特性避免了設定鎖的過期時間。

釋放鎖

持有鎖的客戶端訪問共享資源完成後,將 znode 刪掉,這樣其它客戶端接下來就能來獲取鎖了。如上所述的基於ZooKeeper 的分散式鎖的實現,並不是最優的,它會引發 “herd effect”(羊群效應),降低獲取鎖的效能。可以設定鎖節點為順序臨時節點,後面的節點 watch 前面的節點,當前面的節點刪除時喚醒後面的節點從而避免羊群效應。

企業微信截圖_16642672904821.png

鎖安全性分析

看起來這個鎖相當完美,沒有 Redlock 過期時間的問題,而且能在需要的時候讓鎖自動釋放。但是他還是有阻塞了很長時間導致客戶端假死的情況,可以考慮這一種情況:

  • 客戶端1建立了 znode 節點 /lock,獲得了鎖。
  • 客戶端1進入了長時間的 GC pause
  • 客戶端1連線到 ZooKeeperSession 過期了。znode 節點/lock被自動刪除。
  • 客戶端2建立了 znode 節點 /lock,從而獲得了鎖。
  • 客戶端1從 GC pause 中恢復過來,它仍然認為自己持有鎖。

看起來,用 ZooKeeper 實現的分散式鎖也不一定就是安全的。該有的問題它還是有。但是,ZooKeeper 作為一個專門為分散式應用提供方案的框架,它提供了一些非常好的特性,是 Redis 之類的方案所沒有的。像前面提到的 ephemeral 型別的 znode 自動刪除的功能就是一個例子。

總結

本文通過分析三類分散式鎖服務,基本涵蓋了所有分散式鎖服務中涉及到的關鍵技術,以及對應具體的工程實現方案。

基於分散式儲存實現的鎖服務,由於其記憶體資料儲存的特性,所以具有結構簡單,高效能和低延遲的優點。但是受限於通用儲存的定位,其在鎖資料一致性上缺乏嚴格保證,同時其在解鎖驗證、故障切換、死鎖處理等方面,存在各種問題。所以其適用於在對效能要求較高,但是可以容忍極端情況下丟失鎖資料安全性的場景下。

基於分散式一致性演算法實現的鎖服務,其使用類 Paxos 協議保證了鎖資料的嚴格一致性,同時又具備高可用性。在要求鎖資料嚴格一致的場景下,此類鎖服務幾乎是唯一的選擇。但是由於其結構和分散式一致性協議的複雜性,其在效能和加鎖延遲上,比基於分散式儲存實現的鎖服務要遜色。

所以實際應用場景下,需要根據具體需求出發,權衡各種考慮因素,選擇合適的鎖服務實現模型。無論選擇哪一種模型,需要我們清楚地知道它在安全性上有哪些不足,以及它會帶來什麼後果。更特別的,如果是對於鎖資料安全性要求十分嚴格的應用場景,那麼需要更加慎之又慎。。

參考文章

基於Redis的分散式鎖到底安全嗎(上)

基於Redis的分散式鎖到底安全嗎(下)

Distributed Locks with Redis

How to do distributed locking

什麼是分散式鎖?實現分散式鎖的三種方式