1. 程式人生 > 程式設計 >redLock可靠的分散式鎖

redLock可靠的分散式鎖

本文是對 Martin Kleppmann 的文章 How to do distributed locking 部分內容的翻譯和總結,上次寫 Redlock 的原因就是看到了 Martin 的這篇文章,寫得很好,特此翻譯和總結。感興趣的同學可以翻看原文,相信會收穫良多。

開篇作者認為現在 Redis 逐漸被使用到資料管理領域,這個領域需要更強的資料一致性和耐久性,這使得他感到擔心,因為這不是 Redis 最初設計的初衷(事實上這也是很多業界程式設計師的誤區,越來越把 Redis 當成資料庫在使用),其中基於 Redis 的分散式鎖就是令人擔心的其一。

Martin 指出首先你要明確你為什麼使用分散式鎖,為了效能還是正確性?為了幫你區分這二者,在這把鎖 fail 了的時候你可以詢問自己以下問題:

  1. 要效能的: 擁有這把鎖使得你不會重複勞動(例如一個 job 做了兩次),如果這把鎖 fail 了,兩個節點同時做了這個 Job,那麼這個 Job 增加了你的成本。
  2. 要正確性的: 擁有鎖可以防止併發操作汙染你的系統或者資料,如果這把鎖 fail 了兩個節點同時操作了一份資料,結果可能是資料不一致、資料丟失、file 衝突等,會導致嚴重的後果。

上述二者都是需求鎖的正確場景,但是你必須清楚自己是因為什麼原因需要分散式鎖。

如果你只是為了效能,那沒必要用 Redlock,它成本高且複雜,你只用一個 Redis 例項也夠了,最多加個從防止主掛了。當然,你使用單節點的 Redis 那麼斷電或者一些情況下,你會丟失鎖,但是你的目的只是加速效能且斷電這種事情不會經常發生,這並不是什麼大問題。並且如果你使用了單節點 Redis,那麼很顯然你這個應用需要的鎖粒度是很模糊粗糙的,也不會是什麼重要的服務。

那麼是否 Redlock 對於要求正確性的場景就合適呢?Martin 列舉了若干場景證明 Redlock 這種演演算法是不可靠的。

用鎖保護資源

這節裡 Martin 先將 Redlock 放在了一邊而是僅討論總體上一個分散式鎖是怎麼工作的。在分散式環境下,鎖比 mutex 這類複雜,因為涉及到不同節點、網路通訊並且他們隨時可能無徵兆的 fail 。 Martin 假設了一個場景,一個 client 要修改一個檔案,它先申請得到鎖,然後修改檔案寫回,放鎖。另一個 client 再申請鎖 ... 程式碼流程如下:

// THIS CODE IS BROKEN
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(); } } 複製程式碼

可惜即使你的鎖服務非常完美,上述程式碼還是可能跪,下面的流程圖會告訴你為什麼:

上述圖中,得到鎖的 client1 在持有鎖的期間 pause 了一段時間,例如 GC 停頓。鎖有過期時間(一般叫租約,為了防止某個 client 崩潰之後一直佔有鎖),但是如果 GC 停頓太長超過了鎖租約時間,此時鎖已經被另一個 client2 所得到,原先的 client1 還沒有感知到鎖過期,那麼奇怪的結果就會發生,曾經 HBase 就發生過這種 Bug。即使你在 client1 寫回之前檢查一下鎖是否過期也無助於解決這個問題,因為 GC 可能在任何時候發生,即使是你非常不便的時候(在最後的檢查與寫操作期間)。 如果你認為自己的程式不會有長時間的 GC 停頓,還有其他原因會導致你的程式 pause。例如程式可能讀取尚未進入記憶體的資料,所以它得到一個 page fault 並且等待 page 被載入進快取;還有可能你依賴於網路服務;或者其他程式佔用 CPU;或者其他人意外發生 SIGSTOP 等。

... .... 這裡 Martin 又增加了一節列舉各種程式 pause 的例子,為了證明上面的程式碼是不安全的,無論你的鎖服務多完美。

使用 Fencing (柵欄)使得鎖變安全

修復問題的方法也很簡單:你需要在每次寫操作時加入一個 fencing token。這個場景下,fencing token 可以是一個遞增的數字(lock service 可以做到),每次有 client 申請鎖就遞增一次:

client1 申請鎖同時拿到 token33,然後它進入長時間的停頓鎖也過期了。client2 得到鎖和 token34 寫入資料,緊接著 client1 活過來之後嘗試寫入資料,自身 token33 比 34 小因此寫入操作被拒絕。注意這需要儲存層來檢查 token,但這並不難實現。如果你使用 Zookeeper 作為 lock service 的話那麼你可以使用 zxid 作為遞增數字。 但是對於 Redlock 你要知道,沒什麼生成 fencing token 的方式,並且怎麼修改 Redlock 演演算法使其能產生 fencing token 呢?好像並不那麼顯而易見。因為產生 token 需要單調遞增,除非在單節點 Redis 上完成但是這又沒有高可靠性,你好像需要引進一致性協議來讓 Redlock 產生可靠的 fencing token。

使用時間來解決一致性

Redlock 無法產生 fencing token 早該成為在需求正確性的場景下棄用它的理由,但還有一些值得討論的地方。

學術界有個說法,演演算法對時間不做假設:因為程式可能pause一段時間、資料包可能因為網路延遲延後到達、時鐘可能根本就是錯的。而可靠的演演算法依舊要在上述假設下做正確的事情。

對於 failure detector 來說,timeout 只能作為猜測某個節點 fail 的依據,因為網路延遲、本地時鐘不正確等其他原因的限制。考慮到 Redis 使用 gettimeofday,而不是單調的時鐘,會受到系統時間的影響,可能會突然前進或者後退一段時間,這會導致一個 key 更快或更慢地過期。

可見,Redlock 依賴於許多時間假設,它假設所有 Redis 節點都能對同一個 Key 在其過期前持有差不多的時間、跟過期時間相比網路延遲很小、跟過期時間相比程式 pause 很短。

用不可靠的時間打破 Redlock

這節 Martin 舉了個因為時間問題,Redlock 不可靠的例子。

  1. client1 從 ABC 三個節點處申請到鎖,DE由於網路原因請求沒有到達
  2. C節點的時鐘往前推了,導致 lock 過期
  3. client2 在CDE處獲得了鎖,AB由於網路原因請求未到達
  4. 此時 client1 和 client2 都獲得了鎖

在 Redlock 官方檔案中也提到了這個情況,不過是C崩潰的時候,Redlock 官方本身也是知道 Redlock 演演算法不是完全可靠的,官方為瞭解決這種問題建議使用延時啟動,相關內容可以看之前的這篇文章。但是 Martin 這裡分析得更加全面,指出延時啟動不也是依賴於時鐘的正確性的麼?

接下來 Martin 又列舉了程式 Pause 時而不是時鐘不可靠時會發生的問題:

  1. client1 從 ABCDE 處獲得了鎖
  2. 當獲得鎖的 response 還沒到達 client1 時 client1 進入 GC 停頓
  3. 停頓期間鎖已經過期了
  4. client2 在 ABCDE 處獲得了鎖
  5. client1 GC 完成收到了獲得鎖的 response,此時兩個 client 又拿到了同一把鎖

同時長時間的網路延遲也有可能導致同樣的問題。

Redlock 的同步性假設

這些例子說明瞭,僅有在你假設了一個同步性系統模型的基礎上,Redlock 才能正常工作,也就是系統能滿足以下屬性:

  1. 網路延時邊界,即假設資料包一定能在某個最大延時之內到達
  2. 程式停頓邊界,即程式停頓一定在某個最大時間之內
  3. 時鐘錯誤邊界,即不會從一個壞的 NTP 伺服器處取得時間

結論

Martin 認為 Redlock 實在不是一個好的選擇,對於需求效能的分散式鎖應用它太重了且成本高;對於需求正確性的應用來說它不夠安全。因為它對高危的時鐘或者說其他上述列舉的情況進行了不可靠的假設,如果你的應用只需要高效能的分散式鎖不要求多高的正確性,那麼單節點 Redis 夠了;如果你的應用想要保住正確性,那麼不建議 Redlock,建議使用一個合適的一致性協調系統,例如 Zookeeper,且保證存在 fencing token。