小編認為現在 redis 逐漸被使用到數據管理領域,這個領域需要更強的數據一致性和耐久性,這使得他感到擔心,因為這不是 Redis 最初設計的初衷(事實上這也是很多業界程序員的誤區,越來越把 Redis 當成數據庫在使用),其中基于 Redis 的分布式鎖就是令人擔心的其一。
Martin 指出首先你要明確你為什么使用分布式鎖,為了性能還是正確性?為了幫你區分這二者,在這把鎖 fail 了的時候你可以詢問自己以下問題:
- 要性能的: 擁有這把鎖使得你不會重復勞動(例如一個 job 做了兩次),如果這把鎖 fail 了,兩個節點同時做了這個 Job,那么這個 Job 增加了你的成本。
- 要正確性的: 擁有鎖可以防止并發操作污染你的系統或者數據,如果這把鎖 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 不可靠的例子。
- client1 從 ABC 三個節點處申請到鎖,DE由于網絡原因請求沒有到達
- C節點的時鐘往前推了,導致 lock 過期
- client2 在CDE處獲得了鎖,AB由于網絡原因請求未到達
- 此時 client1 和 client2 都獲得了鎖
在 Redlock 官方文檔中也提到了這個情況,不過是C崩潰的時候,Redlock 官方本身也是知道 Redlock 算法不是完全可靠的,官方為了解決這種問題建議使用延時啟動,相關內容可以看之前的這篇文章。但是 Martin 這里分析得更加全面,指出延時啟動不也是依賴于時鐘的正確性的么?
接下來 Martin 又列舉了進程 Pause 時而不是時鐘不可靠時會發生的問題:
- client1 從 ABCDE 處獲得了鎖
- 當獲得鎖的 response 還沒到達 client1 時 client1 進入 GC 停頓
- 停頓期間鎖已經過期了
- client2 在 ABCDE 處獲得了鎖
- client1 GC 完成收到了獲得鎖的 response,此時兩個 client 又拿到了同一把鎖
同時長時間的網絡延遲也有可能導致同樣的問題。
Redlock 的同步性假設
這些例子說明了,僅有在你假設了一個同步性系統模型的基礎上,Redlock 才能正常工作,也就是系統能滿足以下屬性:
- 網絡延時邊界,即假設數據包一定能在某個最大延時之內到達
- 進程停頓邊界,即進程停頓一定在某個最大時間之內
- 時鐘錯誤邊界,即不會從一個壞的 NTP 服務器處取得時間
結論
Martin 認為 Redlock 實在不是一個好的選擇,對于需求性能的分布式鎖應用它太重了且成本高;對于需求正確性的應用來說它不夠安全。因為它對高危的時鐘或者說其他上述列舉的情況進行了不可靠的假設,如果你的應用只需要高性能的分布式鎖不要求多高的正確性,那么單節點 Redis 夠了;如果你的應用想要保住正確性,那么不建議 Redlock,建議使用一個合適的一致性協調系統,例如 Zookeeper,且保證存在 fencing token。