如何使用分布式鎖


如何使用分布式鎖

原文鏈接:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

我在 Redis 網站上偶然的發現了一個被稱為 Redlock 的算法。這個算法在 Redis 專題上宣稱實現了可容錯的分布式鎖(或者又叫租賃),並在向正在使用分布式系統的用戶請求反饋。這個算法本能地在我的腦海里敲起了警鍾,所以我花時間想了一段時間並記了下來。

由於他們已經有了超過 10 個關於 Redlock 的依賴實現,我不知道誰准備好依賴這個算法,我認為這是值得公開分享我的筆記。我不會講 Redis 的其他方面,其他一些方面在其他地方早就討論過了。

在我深入 Redlock 細節之前,我要說下我是很喜歡 Redis 的,並且我過去曾成功的將它用於生產。我認為它能很好的適合某些場景,如果你想共享一些瞬時的,近似的,服務於服務之間的數據快速變化等。如果你因為一些原因丟失了相關數據也沒什么大問題。舉例來說,一個好的使用案例是維護每個 IP 地址的請求計數(為了限速目的)以及為設置每個用戶 ID 不同的 IP 地址(為了檢測濫用)。

然而,Redis 最近開始進軍數據管理區域,它對強一致性以及持久性的期望越來越高 — — 這讓我很擔心,因為 Redis 並不是為此設計的。可論證的,分布式鎖是這些領域的其中之一。讓我們更仔細的研究細節吧。

你使用分布式鎖是為了什么?

鎖的目的就是在一系列的節點,它們可能嘗試去做相同的工作,鎖確保了最終只會執行一個(至少是同一時刻只執行一個)。這個工作可能會寫一些數據到共享存儲系統中,並執行一些計算,調用外部 API 等等。在高層,這里有兩個理由來解釋為什么在分布式系統中你可能會想要鎖:高效或正確性。為了區分這兩個情況,你可以回答如果鎖失敗將會發生什么:

  • 高效:用鎖來避免一些不必要的多次做相同的工作(例如一些昂貴的計算開銷)。如果鎖失敗了,那么兩個節點最后就會做相同的工作,結果就是要略提高了開銷(你最后要比其他方式多花費 5 美分給到 AWS)或者略微麻煩(比如一個用戶最后會受到相同的通知兩次)。
  • 正確性:用鎖來防止並發進程互相干擾,並且會破壞你的系統的當前狀態。如果鎖失敗了,那么兩節點間就會並發的工作在相同的數據上,結果就是破壞的文件,數據丟失,永久的不一致,就好比給病人用葯量不對,或是其他嚴重的問題。

這兩個情況都需要鎖,但是你必須非常清晰這兩個,哪一個是你要處理的。

我同意如果你為了提高效率為目的而正在使用鎖,那它是不必要,用 Redlock 帶來的開銷和復雜性,有 5 個 Redis 服務器正在運行並且檢查是否有多個人占用了你的鎖。你最好的選擇是只使用單個 Redis 實例,主服務器崩潰了就會使用異步復制到備份實例。

如果你使用了單個 Redis 實例,如果你的 Redis 節點突然斷電,當然會釋放一些鎖,或會發生其他錯誤的事情。但是如果你只使用鎖來當作一個效率的優化方案,並且不經常發生斷電,那么這都不是什么大問題。這里說的 “不是大問題” 的場景恰恰是 Redis 的閃光點。至少如果你正在依賴單獨的 Redis 實例,它是非常清楚對每個人來說系統的鎖看起來都是近似的,僅用於非關鍵用途。

在另一方面,Redlock 算法,它使用了 5 個備份和多數投票,咋眼一看,它是非常適合你的鎖對於正確性是非常重要的。我在下面幾節中來論證它是不適合這個目的的。文章剩下的部分,我將假設你的鎖對於正確性來講是非常重要為前提,如果兩個節點之間並發,它們都會占有相同的鎖,這是很嚴重的 bug。

使用鎖保護資源

我們暫時先把 Redlock 的細節放在一邊,來討論下如何在通用情況下使用分布式鎖(依賴於使用的鎖算法細節)。要記住在分布式系統中的鎖與多線程應用程序中的鎖不同,這是很重要的。這是一個更復雜的問題,因為不同的節點和網絡能以各種方式失敗。

舉個例子,假設你有一個應用程序,一個客戶端要在共享存儲系統中更新一個文件(如 HDFS 或 S3)。這個客戶端首先會占有鎖,然后讀取文件,並做一些改變,寫回到被修改的文件,最終釋放搜索。這個鎖會防止在並發執行讀-修改-寫回 這個周期的兩個客戶端,其中一個會丟失更新。代碼看起來就像這樣:

// 壞代碼
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if(!lock) {
        throw 'Failed to acquire lock';
    }
    
    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

很不辛,即使你有一個完美的鎖住服務,上面的代碼還是壞的。下面的圖標展示了你最后的數據是怎么被破壞的:

在這個,這個正在占據鎖的客戶端會因為其他因素而暫停一段時間 — — 比如發生了 GC。這時鎖超時了(所以它是租賃),一般情況下這是好的方式(否則當發生崩潰的時候,這個占有鎖的客戶端就永遠不會釋放鎖而發生死鎖)。但是,如果 GC 暫停的時間要比租賃超期時間要長,那么客戶端就不會意識到它的鎖已經超時了,它就會繼續往下執行並做一些不安全的改變。

這個問題不是理論上的:HBase 就曾有過這個問題。通常,GC 發生引起的暫停是很短暫的,但是 “停止整個世界” 的 GC 有時候會要幾分鍾 — — 只要比超期時間長久足夠了。甚至是被稱為 “並發” 垃圾回收器(如 HotSpot JVM CMS)就不會在應用程序代碼里面完全並行 — — 甚至是有時候不得不停止整個運行。

你不能通過在回寫存儲之前插入一個鎖超期檢查來試圖修復這個問題。要記住,GC 能在任何時刻停止一個正在運行的線程,包括在你極不方便的時刻(在你檢查和寫操作之間)。

如果你覺得你的編程語言在運行時不會有長時間的 GC 而感到沾沾自喜,那么這里有很多其他的理由可能會讓你的進程停歇。可能你的進程嘗試讀取一個地址,這個地址沒有加載到內存中,所以它會得到一個頁面錯誤並等待頁面從磁盤加載。也許你的磁盤最終是 EBS,所以在 Amazon 擁擠的網絡中讀取一個變量,會不知覺轉換成同步網絡。這其中可能還會有很多其他進程會爭搶 CPU,並且你會在你的調度樹中會碰見黑節點。可能有一些會不小心地向進程發送 SIGSTOP。不管怎樣,你的進程都將會停歇。

如果你仍然不相信我關於進程暫停的討論,那么你可以想像一下在網絡中,一個文件寫請求在到達存儲服務之前可能會發生延遲。如以太網和IP這樣的網絡包可以任意的延遲數據包,並且的確這么做了:在 Github 上的一個著名事件中,網絡中的數據包被延遲了接近 90 秒。這就是以為這一個應用程序進程可能會發送一個寫請求,並且它可能會在當租賃時間已經過期之后的時候的一分鍾里到達存儲服務器。

甚至是在管理良好的網絡中,這種情況也會發生。你不能簡單的對於這種情況作出假設,這就是為什么上面的代碼基本上是不安全的,無論是否你使用了鎖服務。

使用柵欄(fencing)能讓鎖更安全

修復這個問題實際上有個很簡單的方案:你需要對每個到存儲服務的寫請求都包含一個柵欄密鑰。這樣的話,一個柵欄密鑰可以是簡單的數字自增即可(如由鎖服務自增)每次客戶端占有鎖時會每次就會自增柵欄令牌。下面的圖標進行了分析:

客戶端1占據鎖租賃並獲得數字 33,但是它隨即進入了長時間停歇的狀態並超期。這個時候客戶端 2 占據鎖並獲得數字 34(數字總是自增的),然后發送寫操作到存儲服務,包括令牌數字 34。之后,客戶端 1 出現在周期內並同樣包括令牌數字 33 發送寫操作給存儲服務器。但是存儲服務器記住了它已經被更高的令牌數字(34)的進程修改過,所以它會取消這個 33 令牌的請求。

要注意,這要求存儲服務器在檢查令牌並拒絕之前的令牌任何寫操作要扮演主動角色。一旦你知道竅門了,就不會顯得特別困難。鎖服務提供生成嚴格單一的自增長令牌,這會讓鎖安全。舉個例子,如果你正在使用像 ZooKeeper 這樣的鎖服務,你可以使用像 zxid 或 znode 版本號這樣的柵欄令牌,那么你的狀態就是很好的。

現在讓我們回到使用 Redlock 的第一個大問題:它沒有任何生成柵欄令牌的功能。這個算法不會生成任何數字,保證每次客戶端占有鎖時自增長。這也就是說如果這個算法一旦不是完美,那么它使用起來就不是安全的,因為你不能在客戶端之間,尤其是其中客戶端發生停歇或網絡包延遲阻止競爭。

它在我看來,怎么改變其中的 Redlock 算法來開始生成柵欄令牌是不容易的。它使用唯一的隨機數沒有提供要求的單調性。在一個 Redis 節點上簡單的保持一個計數器還不夠,因為這個節點有可能會失敗。在一些節點上保持計數器就是說他們將要失去同步。它就像你將需要一個一致的算法生成柵欄令牌。(如果只是簡單的增加計數器就好了)

利用時間來解決一致

在依賴於鎖的正確性的情況下,Redlock 不能生成柵欄令牌事實上早就有足夠的理由不再使用它。但還有一些其他問題也值得討論。

在學術文獻中,對於這種算法最實用的系統模型是不可靠故障檢測器的異步模型。進一步解釋就是說,這個算法對時間沒有任何假設:進程也能會暫停任意長度時間、在網絡中數據包也可能會任意地延遲、時鍾也可能會任意出錯 — — 這個算法永遠不會期望會去做對的事。根據上面我們討論的,這些都是非常合理的假設。

這個算法的唯一目的是使用時鍾來生成超時,來避免如果某個節點出錯無止境等待。但是超時不一定要精確:因為只是一個請求超時了,不意味着其他節點同樣會出錯 — — 也可能是網絡中有很大的延遲,或者是你本地始終是錯的。當使用故障檢測時,超時只是猜測有事情是錯的。(如果他們可以,分布式算法 完全不需要時鍾,但是這樣就不能達成一致了)。占據鎖就像是 "比較-賦值" 操作,它是要求一致的。

要注意 Redis 使用了 gettimeofday ,而不是 monotonic 時鍾來執行鍵的超期gettimeofday的手冊明確說這個返回的時間受制於系統時間的不間斷的跳躍 — — 那就是說,它可能在幾分鍾只有突然的跳到面前,或甚至是在某個時間內跳回來(如果時鍾是通過 NTP 步進的,因為不同於 NTP 服務器,差異很大,或者時鍾是由管理員手動調整的)。因此,如果系統時間正在做太多的事,那它在 Redis 中就很容易發生鍵要比預期很快過期的情況。

在異步模型中,這個算法並不是大問題:這些算法通常不需要做出任何假設,來確保他們的安全屬性。只有活性屬性依賴於超時或一些其他故障檢測器。進一步解釋就是只有當系統時間到處都是時(進程停歇、網絡延遲、時鍾來回跳躍)這個算法的性能也許會下降,但是這個算法絕不做不正確的事情。

然而,Redlock 不是這樣的。它的安全性依賴於很多的時間假設:它假設所有的 Redis 節點在超期之前都近似的占有密鑰;網絡延遲與失效時間相比很少;進程停歇要遠比超期時間短。

用糟糕的時間來破壞 Redlock

我們看到 Redlock 依賴於時間假設的一些例子。系統有 5 個 Redis 節點(A,B,C,D 和 E)、兩個客戶端(1 和2)。如果其中一個 Redis 節點的時鍾向前跳躍會發生什么呢?

  1. 客戶端 1 在節點 A,B,C 上占有鎖,由於網絡問題,D 和 E 不能如期到達。
  2. 在節點 C 的時鍾向前跳躍,導致鎖超期。
  3. 客戶端 2 在節點 C,D,E 獲取鎖。由於網絡問題,A 和 B 不能如期到達
  4. 客戶端 1 和 2 現在都相信他們已經占據鎖

如果 C 節點在持久化時鍾到磁盤之前停機了,並且立即重啟,也會發生類似的問題。因此,Redlock 文檔建議延遲重啟崩潰的節點的時間至少要達到最長鎖的生成時間。但是這樣延遲重啟就再次依賴於合理計算出這個時間,並且在如果時鍾跳躍,就會失敗。

OK,也許你認為時鍾跳躍是不切實際的,因為你對配置 NTP 非常自信,他只會讓時鍾不停的轉動。在這個例子中,我們來這么一個例子,進程停歇如何導致算法失敗:

  1. 客戶端 1 在節點 A,B,C,D,E 請求占有鎖
  2. 當客戶端 1 正在運行中,它進入了停止一切的 GC
  3. 鎖在所有的 Redis 節點超期了
  4. 客戶端 2 在節點 A,B,C,D,E 請求占有鎖
  5. 客戶端 1 完成 GC 並接受從 Redis 節點的表示成功占有鎖的響應(當進程發生停歇的時候,它們保存在客戶端 1 的內核網絡緩沖區)
  6. 客戶端 1 和 2 現在都相信它們自己占有了鎖

注意,盡管通過 Redis 使用C語言編寫的,是不會發生 GC 的,這對我們沒有幫助:在客戶端的任何系統可能因 GC 暫停,都有這個問題。只有防止客戶端1 在客戶端 2 已經獲取鎖之后執行任何操作才能使它安全,就像上面使用柵欄方法的例子一樣。

網時間的網絡延遲能導致進程暫停相同的效果。它也許依賴於你的 TCP 用戶超時 — — 如果你能設置超時要比 Redis TTL 要短,這樣網絡延遲的數據也許可以忽略,但是我們必須查看 TCP 的實現細節才能確定。因此,隨着超時,我們又再次的回來計算時間的合理的准確性上來了。

Redlock 的同步假設

這些例子展示了只有在你假設是一個同步系統模型, Redlock 才會正確工作 — — 那就是說,一個系統必須要一下屬性:

  • 邊界網絡延遲(你能保證數據包延遲時間內總是到達的)
  • 邊界進程暫停(換句話說,強實時約束,通常就是你只能在汽車安全氣囊內找得到)
  • 邊界時鍾錯誤(祈禱(cross)你不會從出錯的 NTP 服務器獲得你的時間)

注意,一個同步模型並不意味着完全同步時鍾:意思是說你可以假設在一個已知的、修復了上限的網絡延遲、暫停和時鍾漂移。Redlock 假設延遲、暫停和漂移都是相對與鎖的生存來說很小。如果時間問題變得和生存時間一樣大,那么這個算法就會失敗。

在一個行為良好的數據中心環境里,時間假設在大多數時間里都是令人滿意的 — — 這在通常的同步系統中是已知的。但是這樣就足夠好了么?只要時間假設錯了,Redlock 就會違反安全屬性,例如在一個客戶端的租賃到期之前給另一個客戶端。如果你依賴於鎖的正確性,“大多數時候” 這種是完全不夠的 — — 你需要它永遠都是堆的。

這里有大量的證據證明它在大多數實際的系統中采用同步系統模型是不安全的。你要牢記 Github 的90秒包延遲事件。Redlock 不太像 Jepsen 那樣好測試。

在另一方面,通常在一個同步系統模型(或者帶故障檢測的異步模型)設計一個一致的算法實際上有工作的機會。 Raft, Viewstamped Replication, Zab 和 Paxos 都屬於這個類。只要算法脫離所有的時間假設。這很難:人們很容易就去假定網絡、進程和時鍾都是非常可靠的。但是在分布式系統中這個可靠性是混亂的,你必須要非常小心你的假設。

總結

我認為 Redlock 算法是個糟糕的選擇,因為它“既不是魚也不是鳥”:它為了有效的優化鎖是沒必要的,它是重量級和昂貴的,但是對於依賴鎖的正確性的情況來看,它不是夠安全的。

通常,關於對時間和系統時鍾做出假設的算法是很危險的(本質上就是假設一個同步系統用邊界網絡延遲和操作執行的邊界時間),如果它不滿足這些假設,它就違反了安全特性。此外,它(Redis)還缺乏生成柵欄令牌的功能(令牌保護了在長時間的網絡延遲或進程進入停歇時候保護系統二次執行)。

如果你只需要在最大努力的基礎上使用鎖(作為一個有效優化,而不是正確性),我建議對 Redis 使用簡單的單節點鎖定算法(條件是如果不存在才會賦值,即獲得一個鎖,這是原子操作,如果值匹配存在即刪除,這就是釋放鎖),文檔很清晰,在你的代碼中的鎖是只是近似的,也有可能會失敗。不要費心設置一個 5 個 Redis 節點的集群。

另一方面,如果你是為了正確性需要鎖,那么請不要使用 Redlock。而是應該使用更合適的一致系統如 Zookeeper,也許是通過一個 Curator recipes 來實現一個鎖。(最起碼,使用具有合理事務保證的數據庫)以及請在鎖下所有的資源請強制使用柵欄令牌。

如果你要了解更多,這個專題在我書的第八章和第九章有我更詳細的解釋,現在可以從圖靈的早期版本中獲得。(上面的圖標就是取自我的書)對於如何使用 Zookeeper,我推薦 Junqueira and Reed’s book 。為了更好的介紹分布式系統理論,我推薦 Cachin, Guerraoui and Rodrigues’ textbook

感謝Kyle KingsburyCamille FournierFlavio JunqueiraSalvatore Sanfilippo審閱本文的草稿 。當然,任何錯誤都是我的。

2016年2月9日更新:Redlock 的原作者Salvatore對本文提出了反駁(參見HN討論)。他說了一些好觀點,但我堅持我的結論。如果我有時間,我可能會在后續的文章中詳細闡述,但請形成您自己的觀點——並請參考下面的參考文獻,其中許多都經過了嚴格的學術同行評審(不像我們的博客文章)。

參考資料

[1] Cary G Gray and David R Cheriton: “Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency,” at 12th ACM Symposium on Operating Systems Principles (SOSP), December 1989. doi:10.1145/74850.74870

[2] Mike Burrows: “The Chubby lock service for loosely-coupled distributed systems,” at 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006.

[3] Flavio P Junqueira and Benjamin Reed: ZooKeeper: Distributed Process Coordination. O’Reilly Media, November 2013. ISBN: 978-1-4493-6130-3

[4] Enis Söztutar: “HBase and HDFS: Understanding filesystem usage in HBase,” at HBaseCon, June 2013.

[5] Todd Lipcon: “Avoiding Full GCs in Apache HBase with MemStore-Local Allocation Buffers: Part 1,” blog.cloudera.com, 24 February 2011.

[6] Martin Thompson: “Java Garbage Collection Distilled,” mechanical-sympathy.blogspot.co.uk, 16 July 2013.

[7] Peter Bailis and Kyle Kingsbury: “The Network is Reliable,” ACM Queue, volume 12, number 7, July 2014. doi:10.1145/2639988.2639988

[8] Mark Imbriaco: “Downtime last Saturday,” github.com, 26 December 2012.

[9] Tushar Deepak Chandra and Sam Toueg: “Unreliable Failure Detectors for Reliable Distributed Systems,” Journal of the ACM, volume 43, number 2, pages 225–267, March 1996. doi:10.1145/226643.226647

[10] Michael J Fischer, Nancy Lynch, and Michael S Paterson: “Impossibility of Distributed Consensus with One Faulty Process,” Journal of the ACM, volume 32, number 2, pages 374–382, April 1985. doi:10.1145/3149.214121

[11] Maurice P Herlihy: “Wait-Free Synchronization,” ACM Transactions on Programming Languages and Systems, volume 13, number 1, pages 124–149, January 1991.doi:10.1145/114005.102808

[12] Cynthia Dwork, Nancy Lynch, and Larry Stockmeyer: “Consensus in the Presence of Partial Synchrony,” Journal of the ACM, volume 35, number 2, pages 288–323, April 1988. doi:10.1145/42282.42283

[13] Christian Cachin, Rachid Guerraoui, and Luís Rodrigues: Introduction to Reliable and Secure Distributed Programming, Second Edition. Springer, February 2011. ISBN: 978-3-642-15259-7, doi:10.1007/978-3-642-15260-3


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM