Redis 實現分佈式鎖之Redlock 算法淺析

保證分佈式鎖有效的三個屬性

  1. Safety Properties:安全性,此處也就是互斥性,任意時刻只能有一個客戶端可以持有鎖
  2. Liveness Property A:無死鎖,即使持有鎖的客戶端崩潰或被分區,也可以獲得鎖
  3. Liveness Property B:容錯性,只要大多數 Redis 節點正常,客戶端就能獲取和釋放鎖

爲什麼基於故障轉移(failover-based)的實現還不夠

我們先來看看現有大多數 Redis 分佈式鎖的實現。

最簡單的方案是在一個實例中創建一個 key,並給這個 key 設置過期時間,保證這個鎖最終一定能夠釋放,當客戶端釋放鎖的時候,刪除這個 key

看上去可能不錯,但是有個問題:當我們的 Redis 主節點掛掉時會發生什麼?好,那我們增加一個從節點,當主節點不可用時自動切換到從節點。但不幸地是這不行,因爲 Redis 複製是異步的,所以不能保證互斥性。

在這個方案下有一個明顯的競態條件:

  1. 客戶端 A 在 master 節點獲取鎖;
  2. 在寫 key 操作被傳輸到 slave 節點前 master 節點掛了;
  3. slave 晉升爲 master;
  4. 客戶端 B 獲取 A 其實剛剛已經獲取到的鎖;

SAFETY VIOLATION 違反了文章開頭提到的安全屬性

雖然有上述缺陷,但在一些特殊場景下,這種方案還是可以使用的。比如故障發生的時候,多個客戶端同時持有鎖對於系統運行或者業務邏輯沒有太大影響,那麼就可以使用這種基於複製的解決方案。否則最好還是使用本文後續將會提到的 Redlock 算法。

單實例情況下正確的實現

在解決單實例單點故障的限制前,我們先來看看如何正確地執行它

獲取一個鎖的方式:

set resource_name my_random_value NX PX 30000

解釋:

在 resource_name 不存在(NX 選項)的時候創建它,並設置過期時間爲 30000 毫秒。
值是一個隨機值,而且這個值在每個客戶端和每個鎖請求中都是唯一的,這樣做的目的是爲了能夠安全地釋放鎖,不會出現 A 客戶端獲取的鎖被 B 客戶端刪除的情況。使用一段簡單的 lua 代碼告訴 Redis,只有 key 存在而且值與當前客戶端持有的值相等時才刪除這個 key。

if redis.call("get", KEYS[1]) == ARGV[1] then
	return redis.call("del", KEYS[1])
else
	return 0
end

防止由其他客戶端創建的鎖被錯誤刪除非常重要。

舉個例子,當一個客戶端獲取了鎖,並因一些長時間的操作,阻塞時間超過了鎖的可用時間(key 過期時間)導致 key 被刪除,然後該 key 又被其它客戶端創建(也就是其它客戶端獲得鎖)。
如果前一個客戶端在後一個客戶端用完鎖前進行了釋放鎖的操作,就導致了實際上現在屬於後一個客戶端的鎖被刪除。
所以必須使用上面的腳本保證客戶端釋放的一定是自己持有的鎖,而且隨機值的生成很重要,必須是全局唯一。

接下來我們把上述的算法擴展到分佈式的情況。

Redlock 算法

在算法的分佈式版本中,假設有 N 個 Redis 節點。而且這些節點全都是相互獨立的,都是 master 節點,且不使用分佈式協調方案。假設 N=5 ,即部署 5 個 Redis master 節點在不同的機器(或虛擬機)上。

客戶端需要進行如下的操作來獲取鎖:

  1. 獲取當前時間(毫秒);
  2. 嘗試按順序在 N 個節點獲取鎖(set 相同的 key value)。客戶端在每個節點請求鎖時,使用一個相對總的鎖過期時間而言非常小的請求超時時間。例如鎖過期時間爲 10s,那麼請求超時時間應該設置在大約 5~50ms 之間。這可以防止客戶端在一個掛掉的節點上長時間阻塞:如果實例不可用,我們應該儘快嘗試下一個實例;
  3. 客戶端計算獲取鎖所花的時間(當前時間減去第一步中的時間)。當且僅當客戶端在大多數節點上(至少三個)都成功獲得了鎖,而且總時間消耗小於鎖有效時間,鎖被認爲獲取成功;
  4. 如果鎖獲取成功了,那麼它的有效時間就是最初的鎖有效時間減去之前獲取鎖所消耗的時間;
  5. 如果因爲某些原因,鎖獲取失敗了(無論是否能在大部分節點成功獲取鎖,還是鎖有效時間小於 0),將會嘗試釋放所有節點的鎖(即使是那些沒有獲取成功的節點);

這個算法是異步的嗎

這個算法依賴於一個假設:即使在沒有同步時鐘機制的兩個進程中,每個進程的本地時間仍然以相同的速率前進,即使有誤差,這個誤差時間相對於鎖自動釋放時間也是極小到可以忽略的。這個假設非常像現實世界中的計算機:每臺計算機都有一個本地時鐘,我們經常相信不同的計算機的時間差是很小的。

在這一點上需要再細化一下互斥鎖的規則:必須確保客戶端在 鎖過期時間-跨進程的時間差(clock drift) 時間內做完自己所有的工作
更多相關信息可以閱讀這篇有趣的文章:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency

錯誤重試

當一個客戶端不能獲得鎖時,它應該在隨機延遲後再次嘗試,避免大量客戶端同時獲取鎖的情況出現,這種情況下可能發生腦裂(split brain condition),導致大家都獲取不了鎖。另外,客戶端越快嘗試在大多數節點中獲取鎖,出現腦裂情況的時間窗口就越小。所以理想的情況下,客戶端應該並行同時向全部節點發起獲取鎖請求

這裏有必要強調一下,客戶端在沒有成功獲取鎖時,一定要儘快並行在全部節點上釋放鎖,這樣就沒有必要等到 key 超時後才能重新獲取這個鎖(但是如果網絡分區的情況發生,客戶端無法連接到 Redis 節點時,會損失鎖自動過期釋放這段時間內的系統可用性)。

釋放鎖

釋放鎖比較簡單,因爲只需要所有節點都釋放鎖都行,不管之前有沒有在該節點獲取成功鎖。(譯者注:爲什麼要對所有節點操作?因爲分佈式場景下從一個節點獲取鎖失敗不代表在那個節點上加鎖失敗,可能實際上加鎖已經成功了,但是返回時因爲網絡抖動超時了。)

安全性論證

這個算法到底是不是安全的,我們可以觀察一些不同情況下的表現。

我們假設客戶端可以在全部節點上獲取成功鎖,所有的節點將會有一個相同存活時間的 key。但要注意,這個 key 是在不同時間設置的,所以 key 也會在不同時間超時。如果在最壞情況下,第一個 key 在 T1 時間設置(在發起請求前採樣),最後一個 key 在 T2 時間設置(在服務器響應後採樣),我們可以確認最早超時的 key 至少也會存在 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT 時間。所有其他的 key 超時都會大於這個時間,所以我們可以確定至少在這個時間點前這些 key 都是同時存在的。

在大部分節點都設置了 key 的時候,其他客戶端無法搶佔這個鎖,因爲 N/2+1 SET NX 操作在 N/2+1 個 key 存在的情況下無法成功。所以如果一個鎖被獲取成功了,就不可能重新在同一時間獲取它(違反了安全屬性)。

此外我們還需要確保多個客戶端同時獲取鎖時不會同時成功。
如果一個客戶端獲取大多數節點的鎖的耗時接近甚至超過鎖的最大有效時間,那麼系統就會認爲這個鎖是無效的,並全部解鎖。所以我們只需要考慮在大多數節點獲取鎖的耗時小於鎖有效時間的情況。在前面討論的案例中可知,在 MIN_VALIDITY 時間內,沒有客戶端能成功重新獲取鎖。所以多個客戶端只可能在大多數節點上獲取鎖的時間大於 TTL 時纔可以,這會導致鎖失效。

可用性(liveness)論證

系統可用性基於三個特性:

  1. 自動釋放鎖(基於 key 過期):最終鎖一定能夠再次被獲取;
  2. 現實情況下客戶端一般都會主動釋放鎖,所以我們不需要等到 key 過期才能再去獲取鎖;
  3. 當客戶端發起重試獲取鎖的請求時,它會等待一段比去大多數節點獲取鎖的時間更長的時間,這會降低多個客戶端同時請求鎖而發生腦裂狀態的概率;

然而,我們在網絡分區發生的時候會損失 TTL 時間的系統可用性,所以如果分區連續發生,不可用也會持續。這種情況在每次客戶端獲得鎖並在釋放鎖前遇到了網絡分區的情況時都會發生。
基本上,如果持續的網絡分區的話,系統也會持續不可用。

性能、故障後恢復和 fsync

很多用戶使用 Redis 做分佈式鎖服務時,不但要求加解鎖要低延遲,還要求高吞吐量(每秒能夠執行加/解鎖操作的次數)。爲了達到這個需求,可以通過多路複用並行和 N 個服務器通信,或者也可以將 socket 設置爲非阻塞模式,一次性發送全部的命令,之後再一次性處理全部返回的命令,假設客戶端和不同 Redis 服務節點的網絡延遲不大的話,

爲了能夠實現故障恢復,我們需要考慮關於持久化的問題。
假設有一個客戶端成功得獲取了鎖(至少 3/5 個節點成功),而已經成功獲得鎖的其中一個節點重啓了,那麼我們就又有了 3 個可以分配鎖的節點,這樣其它客戶端就又可以成功獲得鎖了,違反了互斥鎖的安全性原則。

如果啓用了 AOF 持久化,情況會好很多。例如我們可以發起 SHUTDOWN 請求並重啓服務器,因爲 Redis 超時時間是語義層面的,所以在服務器關掉期間超時還是存在的,所以過期策略仍然存在。

但如果是意外停機呢?如果 Redis 被配置爲每秒同步數據到磁盤一次(默認),可能在重啓的時候丟失一些 key。理論上,如果我們要確保鎖在任何重啓的情況下都安全,就必須設置 fsync=always。但這樣會完全犧牲性能,使其和傳統的 CP 系統的分佈式鎖方案沒有區別。

但事情往往不像第一眼看上去這麼糟糕,基本上,只要一個服務節點掛了重啓後不去管系統中現有活躍的鎖,這樣當節點重啓時,整個系統中活躍的鎖必然是由正在已獲得鎖的客戶端使用的,而不是新加入系統的。

爲了確保這一點,只需讓崩潰重啓的實例,在最大鎖有效時間內不可用,令該節點的舊鎖信息全部過期釋放。

使用延遲重啓基本上可以解決安全性問題,但要注意,這可能會造成可用性的下降:當系統內的大多數節點都掛了,那麼在 TTL 時間內整個系統都處於不可用狀態(無法獲得鎖)。

使算法更可靠:擴展鎖

如果客戶端執行的工作由小的步驟組成,那麼可以使用比較小的 TTL 時間來設置鎖,並在鎖快過期時刷新鎖有效時間(續約)。但在技術上不會改變算法本質,因此應該限制重新獲取鎖嘗試的最大次數,不然會違反可用性。

系統時鐘漂移

redis的過期時間是依賴系統時鐘的,如果時鐘漂移過大時會影響到過期時間的計算。

爲什麼系統時鐘會存在漂移呢?先簡單說下系統時間,linux提供了兩個系統時間:clock realtime和clock monotonic。

  1. clock realtime也就是xtime/wall time,這個時間是可以被用戶改變的,被NTP改變,gettimeofday取的就是這個時間,redis的過期計算用的也是這個時間。
  2. clock monotonic,直譯過來是單調時間,不會被用戶改變,但是會被NTP改變。

最理想的情況時,所有系統的時鐘都時時刻刻和NTP服務器保持同步,但這顯然是不可能的。導致系統時鐘漂移的原因有兩個:

  1. 系統的時鐘和NTP服務器不同步。這個目前沒有特別好的解決方案,只能相信運維同學了。
  2. clock realtime被人爲修改。在實現分佈式鎖時,不要使用clock realtime。不過很可惜,redis使用的就是這個時間,我看了下Redis 5.0源碼,使用的還是clock realtime。Antirez說過改成clock monotonic的,不過大佬還沒有改。也就是說,人爲修改redis服務器的時間,就能讓redis出問題了。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章