基於 Redis 的分散式鎖到底安全嗎?

張鐵蕾發表於2017-02-27

【完整版】

網上有關Redis分散式鎖的文章可謂多如牛毛了,不信的話你可以拿關鍵詞“Redis 分散式鎖”隨便到哪個搜尋引擎上去搜尋一下就知道了。這些文章的思路大體相近,給出的實現演算法也看似合乎邏輯,但當我們著手去實現它們的時候,卻發現如果你越是仔細推敲,疑慮也就越來越多。

實際上,大概在一年以前,關於Redis分散式鎖的安全性問題,在分散式系統專家Martin Kleppmann和Redis的作者antirez之間就發生過一場爭論。由於對這個問題一直以來比較關注,所以我前些日子仔細閱讀了與這場爭論相關的資料。這場爭論的大概過程是這樣的:為了規範各家對基於Redis的分散式鎖的實現,Redis的作者提出了一個更安全的實現,叫做Redlock。有一天,Martin Kleppmann寫了一篇blog,分析了Redlock在安全性上存在的一些問題。然後Redis的作者立即寫了一篇blog來反駁Martin的分析。但Martin表示仍然堅持原來的觀點。隨後,這個問題在Twitter和Hacker News上引發了激烈的討論,很多分散式系統的專家都參與其中。

對於那些對分散式系統感興趣的人來說,這個事件非常值得關注。不管你是剛接觸分散式系統的新手,還是有著多年分散式開發經驗的老手,讀完這些分析和評論之後,大概都會有所收穫。要知道,親手實現過Redis Cluster這樣一個複雜系統的antirez,足以算得上分散式領域的一名專家了。但對於由分散式鎖引發的一系列問題的分析中,不同的專家卻能得出迥異的結論,從中我們可以窺見分散式系統相關的問題具有何等的複雜性。實際上,在分散式系統的設計中經常發生的事情是:許多想法初看起來毫無破綻,而一旦詳加考量,卻發現不是那麼天衣無縫。

下面,我們就從頭至尾把這場爭論過程中各方的觀點進行一下回顧和分析。在這個過程中,我們把影響分散式鎖的安全性的那些技術細節展開進行討論,這將是一件很有意思的事情。這也是一個比較長的故事。當然,其中也免不了包含一些小“八卦”。

Redlock演算法

就像本文開頭所講的,藉助Redis來實現一個分散式鎖(Distributed Lock)的做法,已經有很多人嘗試過。人們構建這樣的分散式鎖的目的,是為了對一些共享資源進行互斥訪問。

但是,這些實現雖然思路大體相近,但實現細節上各不相同,它們能提供的安全性和可用性也不盡相同。所以,Redis的作者antirez給出了一個更好的實現,稱為Redlock,算是Redis官方對於實現分散式鎖的指導規範。Redlock的演算法描述就放在Redis的官網上:

在Redlock之前,很多人對於分散式鎖的實現都是基於單個Redis節點的。而Redlock是基於多個Redis節點(都是Master)的一種實現。為了能理解Redlock,我們首先需要把簡單的基於單Redis節點的演算法描述清楚,因為它是Redlock的基礎。

基於單Redis節點的分散式鎖

首先,Redis客戶端為了獲取鎖,向Redis節點傳送如下命令:

SET resource_name my_random_value NX PX 30000複製程式碼

上面的命令如果執行成功,則客戶端成功獲取到了鎖,接下來就可以訪問共享資源了;而如果上面的命令執行失敗,則說明獲取鎖失敗。

注意,在上面的SET命令中:

  • my_random_value是由客戶端生成的一個隨機字串,它要保證在足夠長的一段時間內在所有客戶端的所有獲取鎖的請求中都是唯一的。
  • NX表示只有當resource_name對應的key值不存在的時候才能SET成功。這保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  • PX 30000表示這個鎖有一個30秒的自動過期時間。當然,這裡30秒只是一個例子,客戶端可以選擇合適的過期時間。

最後,當客戶端完成了對共享資源的操作之後,執行下面的Redis Lua指令碼來釋放鎖

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

這段Lua指令碼在執行的時候要把前面的my_random_value作為ARGV[1]的值傳進去,把resource_name作為KEYS[1]的值傳進去。

至此,基於單Redis節點的分散式鎖的演算法就描述完了。這裡面有好幾個問題需要重點分析一下。

首先第一個問題,這個鎖必須要設定一個過期時間。否則的話,當一個客戶端獲取鎖成功之後,假如它崩潰了,或者由於發生了網路分割(network partition)導致它再也無法和Redis節點通訊了,那麼它就會一直持有這個鎖,而其它客戶端永遠無法獲得鎖了。antirez在後面的分析中也特別強調了這一點,而且把這個過期時間稱為鎖的有效時間(lock validity time)。獲得鎖的客戶端必須在這個時間之內完成對共享資源的訪問。

第二個問題,第一步獲取鎖的操作,網上不少文章把它實現成了兩個Redis命令:

SETNX resource_name my_random_value
EXPIRE resource_name 30複製程式碼

雖然這兩個命令和前面演算法描述中的一個SET命令執行效果相同,但卻不是原子的。如果客戶端在執行完SETNX後崩潰了,那麼就沒有機會執行EXPIRE了,導致它一直持有這個鎖。

第三個問題,也是antirez指出的,設定一個隨機字串my_random_value是很有必要的,它保證了一個客戶端釋放的鎖必須是自己持有的那個鎖。假如獲取鎖時SET的不是一個隨機字串,而是一個固定值,那麼可能會發生下面的執行序列:

  1. 客戶端1獲取鎖成功。
  2. 客戶端1在某個操作上阻塞了很長時間。
  3. 過期時間到了,鎖自動釋放了。
  4. 客戶端2獲取到了對應同一個資源的鎖。
  5. 客戶端1從阻塞中恢復過來,釋放掉了客戶端2持有的鎖。

之後,客戶端2在訪問共享資源的時候,就沒有鎖為它提供保護了。

第四個問題,釋放鎖的操作必須使用Lua指令碼來實現。釋放鎖其實包含三步操作:'GET'、判斷和'DEL',用Lua指令碼來實現能保證這三步的原子性。否則,如果把這三步操作放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題類似的執行序列:

  1. 客戶端1獲取鎖成功。
  2. 客戶端1訪問共享資源。
  3. 客戶端1為了釋放鎖,先執行'GET'操作獲取隨機字串的值。
  4. 客戶端1判斷隨機字串的值,與預期的值相等。
  5. 客戶端1由於某個原因阻塞住了很長時間。
  6. 過期時間到了,鎖自動釋放了。
  7. 客戶端2獲取到了對應同一個資源的鎖。
  8. 客戶端1從阻塞中恢復過來,執行DEL操縱,釋放掉了客戶端2持有的鎖。

實際上,在上述第三個問題和第四個問題的分析中,如果不是客戶端阻塞住了,而是出現了大的網路延遲,也有可能導致類似的執行序列發生。

前面的四個問題,只要實現分散式鎖的時候加以注意,就都能夠被正確處理。但除此之外,antirez還指出了一個問題,是由failover引起的,卻是基於單Redis節點的分散式鎖無法解決的。正是這個問題催生了Redlock的出現。

這個問題是這樣的。假如Redis節點當機了,那麼所有客戶端就都無法獲得鎖了,服務變得不可用。為了提高可用性,我們可以給這個Redis節點掛一個Slave,當Master節點不可用的時候,系統自動切到Slave上(failover)。但由於Redis的主從複製(replication)是非同步的,這可能導致在failover過程中喪失鎖的安全性。考慮下面的執行序列:

  1. 客戶端1從Master獲取了鎖。
  2. Master當機了,儲存鎖的key還沒有來得及同步到Slave上。
  3. Slave升級為Master。
  4. 客戶端2從新的Master獲取到了對應同一個資源的鎖。

於是,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破。針對這個問題,antirez設計了Redlock演算法,我們接下來會討論。

其它疑問

前面這個演算法中出現的鎖的有效時間(lock validity time),設定成多少合適呢?如果設定太短的話,鎖就有可能在客戶端完成對於共享資源的訪問之前過期,從而失去保護;如果設定太長的話,一旦某個持有鎖的客戶端釋放鎖失敗,那麼就會導致所有其它客戶端都無法獲取鎖,從而長時間內無法正常工作。看來真是個兩難的問題。

而且,在前面對於隨機字串my_random_value的分析中,antirez也在文章中承認的確應該考慮客戶端長期阻塞導致鎖過期的情況。如果真的發生了這種情況,那麼共享資源是不是已經失去了保護呢?antirez重新設計的Redlock是否能解決這些問題呢?

分散式鎖Redlock

由於前面介紹的基於單Redis節點的分散式鎖在failover的時候會產生解決不了的安全性問題,因此antirez提出了新的分散式鎖的演算法Redlock,它基於N個完全獨立的Redis節點(通常情況下N可以設定成5)。

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

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

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

由於N個Redis節點中的大多數能正常工作就能保證Redlock正常工作,因此理論上它的可用性更高。我們前面討論的單Redis節點的分散式鎖在failover的時候鎖失效的問題,在Redlock中不存在了,但如果有節點發生崩潰重啟,還是會對鎖的安全性有影響的。具體的影響程度跟Redis對資料的持久化程度有關。

假設一共有5個Redis節點:A, B, C, D, E。設想發生瞭如下的事件序列:

  1. 客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住)。
  2. 節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了。
  3. 節點C重啟後,客戶端2鎖住了C, D, E,獲取鎖成功。

這樣,客戶端1和客戶端2同時獲得了鎖(針對同一資源)。

在預設情況下,Redis的AOF持久化方式是每秒寫一次磁碟(即執行fsync),因此最壞情況下可能丟失1秒的資料。為了儘可能不丟資料,Redis允許設定成每次修改資料都進行fsync,但這會降低效能。當然,即使執行了fsync也仍然有可能丟失資料(這取決於系統而不是Redis的實現)。所以,上面分析的由於節點重啟引發的鎖失效問題,總是有可能出現的。為了應對這一問題,antirez又提出了延遲重啟(delayed restarts)的概念。也就是說,一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,這段時間應該大於鎖的有效時間(lock validity time)。這樣的話,這個節點在重啟前所參與的鎖都會過期,它在重啟後就不會對現有的鎖造成影響。

關於Redlock還有一點細節值得拿出來分析一下:在最後釋放鎖的時候,antirez在演算法描述中特別強調,客戶端應該向所有Redis節點發起釋放鎖的操作。也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。這是為什麼呢?設想這樣一種情況,客戶端發給某個Redis節點的獲取鎖的請求成功到達了該Redis節點,這個節點也成功執行了SET操作,但是它返回給客戶端的響應包卻丟失了。這在客戶端看來,獲取鎖的請求由於超時而失敗了,但在Redis這邊看來,加鎖已經成功了。因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些Redis節點同樣發起請求。實際上,這種情況在非同步通訊模型中是有可能發生的:客戶端向伺服器通訊是正常的,但反方向卻是有問題的。

其它疑問

前面在討論單Redis節點的分散式鎖的時候,最後我們提出了一個疑問,如果客戶端長期阻塞導致鎖過期,那麼它接下來訪問共享資源就不安全了(沒有了鎖的保護)。這個問題在Redlock中是否有所改善呢?顯然,這樣的問題在Redlock中是依然存在的。

另外,在演算法第4步成功獲取了鎖之後,如果由於獲取鎖的過程消耗了較長時間,重新計算出來的剩餘的鎖有效時間很短了,那麼我們還來得及去完成共享資源訪問嗎?如果我們認為太短,是不是應該立即進行鎖的釋放操作?那到底多短才算呢?又是一個選擇難題。

Martin的分析

Martin Kleppmann在2016-02-08這一天發表了一篇blog,名字叫"How to do distributed locking
",地址如下:

Martin在這篇文章中談及了分散式系統的很多基礎性的問題(特別是分散式計算的非同步模型),對分散式系統的從業者來說非常值得一讀。這篇文章大體可以分為兩大部分:

  • 前半部分,與Redlock無關。Martin指出,即使我們擁有一個完美實現的分散式鎖(帶自動過期功能),在沒有共享資源參與進來提供某種fencing機制的前提下,我們仍然不可能獲得足夠的安全性。
  • 後半部分,是對Redlock本身的批評。Martin指出,由於Redlock本質上是建立在一個同步模型之上,對系統的記時假設(timing assumption)有很強的要求,因此本身的安全性是不夠的。

首先我們討論一下前半部分的關鍵點。Martin給出了下面這樣一份時序圖:

基於 Redis 的分散式鎖到底安全嗎?

在上面的時序圖中,假設鎖服務本身是沒有問題的,它總是能保證任一時刻最多隻有一個客戶端獲得鎖。上圖中出現的lease這個詞可以暫且認為就等同於一個帶有自動過期功能的鎖。客戶端1在獲得鎖之後發生了很長時間的GC pause,在此期間,它獲得的鎖過期了,而客戶端2獲得了鎖。當客戶端1從GC pause中恢復過來的時候,它不知道自己持有的鎖已經過期了,它依然向共享資源(上圖中是一個儲存服務)發起了寫資料請求,而這時鎖實際上被客戶端2持有,因此兩個客戶端的寫請求就有可能衝突(鎖的互斥作用失效了)。

初看上去,有人可能會說,既然客戶端1從GC pause中恢復過來以後不知道自己持有的鎖已經過期了,那麼它可以在訪問共享資源之前先判斷一下鎖是否過期。但仔細想想,這絲毫也沒有幫助。因為GC pause可能發生在任意時刻,也許恰好在判斷完之後。

也有人會說,如果客戶端使用沒有GC的語言來實現,是不是就沒有這個問題呢?Martin指出,系統環境太複雜,仍然有很多原因導致程式的pause,比如虛存造成的缺頁故障(page fault),再比如CPU資源的競爭。即使不考慮程式pause的情況,網路延遲也仍然會造成類似的結果。

總結起來就是說,即使鎖服務本身是沒有問題的,而僅僅是客戶端有長時間的pause或網路延遲,仍然會造成兩個客戶端同時訪問共享資源的衝突情況發生。而這種情況其實就是我們在前面已經提出來的“客戶端長期阻塞導致鎖過期”的那個疑問。

那怎麼解決這個問題呢?Martin給出了一種方法,稱為fencing token。fencing token是一個單調遞增的數字,當客戶端成功獲取鎖的時候它隨同鎖一起返回給客戶端。而客戶端訪問共享資源的時候帶著這個fencing token,這樣提供共享資源的服務就能根據它進行檢查,拒絕掉延遲到來的訪問請求(避免了衝突)。如下圖:

基於 Redis 的分散式鎖到底安全嗎?

在上圖中,客戶端1先獲取到的鎖,因此有一個較小的fencing token,等於33,而客戶端2後獲取到的鎖,有一個較大的fencing token,等於34。客戶端1從GC pause中恢復過來之後,依然是向儲存服務傳送訪問請求,但是帶了fencing token = 33。儲存服務發現它之前已經處理過34的請求,所以會拒絕掉這次33的請求。這樣就避免了衝突。

現在我們再討論一下Martin的文章的後半部分。

Martin在文中構造了一些事件序列,能夠讓Redlock失效(兩個客戶端同時持有鎖)。為了說明Redlock對系統記時(timing)的過分依賴,他首先給出了下面的一個例子(還是假設有5個Redis節點A, B, C, D, E):

  1. 客戶端1從Redis節點A, B, C成功獲取了鎖(多數節點)。由於網路問題,與D和E通訊失敗。
  2. 節點C上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。
  3. 客戶端2從Redis節點C, D, E成功獲取了同一個資源的鎖(多數節點)。
  4. 客戶端1和客戶端2現在都認為自己持有了鎖。

上面這種情況之所以有可能發生,本質上是因為Redlock的安全性(safety property)對系統的時鐘有比較強的依賴,一旦系統的時鐘變得不準確,演算法的安全性也就保證不了了。Martin在這裡其實是要指出分散式演算法研究中的一些基礎性問題,或者說一些常識問題,即好的分散式演算法應該基於非同步模型(asynchronous model),演算法的安全性不應該依賴於任何記時假設(timing assumption)。在非同步模型中:程式可能pause任意長的時間,訊息可能在網路中延遲任意長的時間,甚至丟失,系統時鐘也可能以任意方式出錯。一個好的分散式演算法,這些因素不應該影響它的安全性(safety property),只可能影響到它的活性(liveness property),也就是說,即使在非常極端的情況下(比如系統時鐘嚴重錯誤),演算法頂多是不能在有限的時間內給出結果而已,而不應該給出錯誤的結果。這樣的演算法在現實中是存在的,像比較著名的Paxos,或Raft。但顯然按這個標準的話,Redlock的安全性級別是達不到的。

隨後,Martin覺得前面這個時鐘跳躍的例子還不夠,又給出了一個由客戶端GC pause引發Redlock失效的例子。如下:

  1. 客戶端1向Redis節點A, B, C, D, E發起鎖請求。
  2. 各個Redis節點已經把請求結果返回給了客戶端1,但客戶端1在收到請求結果之前進入了長時間的GC pause。
  3. 在所有的Redis節點上,鎖過期了。
  4. 客戶端2在A, B, C, D, E上獲取到了鎖。
  5. 客戶端1從GC pause從恢復,收到了前面第2步來自各個Redis節點的請求結果。客戶端1認為自己成功獲取到了鎖。
  6. 客戶端1和客戶端2現在都認為自己持有了鎖。

Martin給出的這個例子其實有點小問題。在Redlock演算法中,客戶端在完成向各個Redis節點的獲取鎖的請求之後,會計算這個過程消耗的時間,然後檢查是不是超過了鎖的有效時間(lock validity time)。也就是上面的例子中第5步,客戶端1從GC pause中恢復過來以後,它會通過這個檢查發現鎖已經過期了,不會再認為自己成功獲取到鎖了。隨後antirez在他的反駁文章中就指出來了這個問題,但Martin認為這個細節對Redlock整體的安全性沒有本質的影響。

拋開這個細節,我們可以分析一下Martin舉這個例子的意圖在哪。初看起來,這個例子跟文章前半部分分析通用的分散式鎖時給出的GC pause的時序圖是基本一樣的,只不過那裡的GC pause發生在客戶端1獲得了鎖之後,而這裡的GC pause發生在客戶端1獲得鎖之前。但兩個例子的側重點不太一樣。Martin構造這裡的這個例子,是為了強調在一個分散式的非同步環境下,長時間的GC pause或訊息延遲(上面這個例子中,把GC pause換成Redis節點和客戶端1之間的訊息延遲,邏輯不變),會讓客戶端獲得一個已經過期的鎖。從客戶端1的角度看,Redlock的安全性被打破了,因為客戶端1收到鎖的時候,這個鎖已經失效了,而Redlock同時還把這個鎖分配給了客戶端2。換句話說,Redis伺服器在把鎖分發給客戶端的途中,鎖就過期了,但又沒有有效的機制讓客戶端明確知道這個問題。而在之前的那個例子中,客戶端1收到鎖的時候鎖還是有效的,鎖服務本身的安全性可以認為沒有被打破,後面雖然也出了問題,但問題是出在客戶端1和共享資源伺服器之間的互動上。

在Martin的這篇文章中,還有一個很有見地的觀點,就是對鎖的用途的區分。他把鎖的用途分為兩種:

  • 為了效率(efficiency),協調各個客戶端避免做重複的工作。即使鎖偶爾失效了,只是可能把某些操作多做一遍而已,不會產生其它的不良後果。比如重複傳送了一封同樣的email。
  • 為了正確性(correctness)。在任何情況下都不允許鎖失效的情況發生,因為一旦發生,就可能意味著資料不一致(inconsistency),資料丟失,檔案損壞,或者其它嚴重的問題。

最後,Martin得出瞭如下的結論:

  • 如果是為了效率(efficiency)而使用分散式鎖,允許鎖的偶爾失效,那麼使用單Redis節點的鎖方案就足夠了,簡單而且效率高。Redlock則是個過重的實現(heavyweight)。
  • 如果是為了正確性(correctness)在很嚴肅的場合使用分散式鎖,那麼不要使用Redlock。它不是建立在非同步模型上的一個足夠強的演算法,它對於系統模型的假設中包含很多危險的成分(對於timing)。而且,它沒有一個機制能夠提供fencing token。那應該使用什麼技術呢?Martin認為,應該考慮類似Zookeeper的方案,或者支援事務的資料庫。

Martin對Redlock演算法的形容是:

neither fish nor fowl (非驢非馬)

其它疑問

  • Martin提出的fencing token的方案,需要對提供共享資源的服務進行修改,這在現實中可行嗎?
  • 根據Martin的說法,看起來,如果資源伺服器實現了fencing token,它在分散式鎖失效的情況下也仍然能保持資源的互斥訪問。這是不是意味著分散式鎖根本沒有存在的意義了?
  • 資源伺服器需要檢查fencing token的大小,如果提供資源訪問的服務也是包含多個節點的(分散式的),那麼這裡怎麼檢查才能保證fencing token在多個節點上是遞增的呢?
  • Martin對於fencing token的舉例中,兩個fencing token到達資源伺服器的順序顛倒了(小的fencing token後到了),這時資源伺服器檢查出了這一問題。如果客戶端1和客戶端2都發生了GC pause,兩個fencing token都延遲了,它們幾乎同時到達了資源伺服器,但保持了順序,那麼資源伺服器是不是就檢查不出問題了?這時對於資源的訪問是不是就發生衝突了?
  • 分散式鎖+fencing的方案是絕對正確的嗎?能證明嗎?

(以上是上部)


自從我寫完這個話題的上半部分之後,就感覺頭腦中出現了許多細小的聲音,久久揮之不去。它們就像是在為了一些雞毛蒜皮的小事而相互爭吵個不停。的確,有關分散式的話題就是這樣,瑣碎異常,而且每個人說的話聽起來似乎都有道理。

今天,我們就繼續探討這個話題的後半部分。本文中,我們將從antirez反駁Martin Kleppmann的觀點開始講起,然後會涉及到Hacker News上出現的一些討論內容,接下來我們還會討論到基於Zookeeper和Chubby的分散式鎖是怎樣的,並和Redlock進行一些對比。最後,我們會提到Martin對於這一事件的總結。

antirez的反駁

Martin在發表了那篇分析分散式鎖的blog (How to do distributed locking)之後,該文章在Twitter和Hacker News上引發了廣泛的討論。但人們更想聽到的是Redlock的作者antirez對此會發表什麼樣的看法。

Martin的那篇文章是在2016-02-08這一天發表的,但據Martin說,他在公開發表文章的一星期之前就把草稿發給了antirez進行review,而且他們之間通過email進行了討論。不知道Martin有沒有意料到,antirez對於此事的反應很快,就在Martin的文章發表出來的第二天,antirez就在他的部落格上貼出了他對於此事的反駁文章,名字叫"Is Redlock safe?",地址如下:

這是高手之間的過招。antirez這篇文章也條例非常清晰,並且中間涉及到大量的細節。antirez認為,Martin的文章對於Redlock的批評可以概括為兩個方面(與Martin文章的前後兩部分對應):

  • 帶有自動過期功能的分散式鎖,必須提供某種fencing機制來保證對共享資源的真正的互斥保護。Redlock提供不了這樣一種機制。
  • Redlock構建在一個不夠安全的系統模型之上。它對於系統的記時假設(timing assumption)有比較強的要求,而這些要求在現實的系統中是無法保證的。

antirez對這兩方面分別進行了反駁。

首先,關於fencing機制。antirez對於Martin的這種論證方式提出了質疑:既然在鎖失效的情況下已經存在一種fencing機制能繼續保持資源的互斥訪問了,那為什麼還要使用一個分散式鎖並且還要求它提供那麼強的安全性保證呢?即使退一步講,Redlock雖然提供不了Martin所講的遞增的fencing token,但利用Redlock產生的隨機字串(my_random_value)可以達到同樣的效果。這個隨機字串雖然不是遞增的,但卻是唯一的,可以稱之為unique token。antirez舉了個例子,比如,你可以用它來實現“Check and Set”操作,原話是:

When starting to work with a shared resource, we set its state to “<token>”, then we operate the read-modify-write only if the token is still the same when we write.
(譯文:當開始和共享資源互動的時候,我們將它的狀態設定成“<token>”,然後僅在token沒改變的情況下我們才執行“讀取-修改-寫回”操作。)

第一遍看到這個描述的時候,我個人是感覺沒太看懂的。“Check and Set”應該就是我們平常聽到過的CAS操作了,但它如何在這個場景下工作,antirez並沒有展開說(在後面講到Hacker News上的討論的時候,我們還會提到)。

然後,antirez的反駁就集中在第二個方面上:關於演算法在記時(timing)方面的模型假設。在我們前面分析Martin的文章時也提到過,Martin認為Redlock會失效的情況主要有三種:

  • 時鐘發生跳躍。
  • 長時間的GC pause。
  • 長時間的網路延遲。

antirez肯定意識到了這三種情況對Redlock最致命的其實是第一點:時鐘發生跳躍。這種情況一旦發生,Redlock是沒法正常工作的。而對於後兩種情況來說,Redlock在當初設計的時候已經考慮到了,對它們引起的後果有一定的免疫力。所以,antirez接下來集中精力來說明通過恰當的運維,完全可以避免時鐘發生大的跳動,而Redlock對於時鐘的要求在現實系統中是完全可以滿足的。

Martin在提到時鐘跳躍的時候,舉了兩個可能造成時鐘跳躍的具體例子:

  • 系統管理員手動修改了時鐘。
  • 從NTP服務收到了一個大的時鐘更新事件。

antirez反駁說:

  • 手動修改時鐘這種人為原因,不要那麼做就是了。否則的話,如果有人手動修改Raft協議的持久化日誌,那麼就算是Raft協議它也沒法正常工作了。
  • 使用一個不會進行“跳躍”式調整系統時鐘的ntpd程式(可能是通過恰當的配置),對於時鐘的修改通過多次微小的調整來完成。

而Redlock對時鐘的要求,並不需要完全精確,它只需要時鐘差不多精確就可以了。比如,要記時5秒,但可能實際記了4.5秒,然後又記了5.5秒,有一定的誤差。不過只要誤差不超過一定範圍,這對Redlock不會產生影響。antirez認為呢,像這樣對時鐘精度並不是很高的要求,在實際環境中是完全合理的。

好了,到此為止,如果你相信antirez這裡關於時鐘的論斷,那麼接下來antirez的分析就基本上順理成章了。

關於Martin提到的能使Redlock失效的後兩種情況,Martin在分析的時候恰好犯了一個錯誤(在本文上半部分已經提到過)。在Martin給出的那個由客戶端GC pause引發Redlock失效的例子中,這個GC pause引發的後果相當於在鎖伺服器和客戶端之間發生了長時間的訊息延遲。Redlock對於這個情況是能處理的。回想一下Redlock演算法的具體過程,它使用起來的過程大體可以分成5步:

  1. 獲取當前時間。
  2. 完成獲取鎖的整個過程(與N個Redis節點互動)。
  3. 再次獲取當前時間。
  4. 把兩個時間相減,計算獲取鎖的過程是否消耗了太長時間,導致鎖已經過期了。如果沒過期,
  5. 客戶端持有鎖去訪問共享資源。

在Martin舉的例子中,GC pause或網路延遲,實際發生在上述第1步和第3步之間。而不管在第1步和第3步之間由於什麼原因(程式停頓或網路延遲等)導致了大的延遲出現,在第4步都能被檢查出來,不會讓客戶端拿到一個它認為有效而實際卻已經過期的鎖。當然,這個檢查依賴系統時鐘沒有大的跳躍。這也就是為什麼antirez在前面要對時鐘條件進行辯護的原因。

有人會說,在第3步之後,仍然可能會發生延遲啊。沒錯,antirez承認這一點,他對此有一段很有意思的論證,原話如下:

The delay can only happen after steps 3, resulting into the lock to be considered ok while actually expired, that is, we are back at the first problem Martin identified of distributed locks where the client fails to stop working to the shared resource before the lock validity expires. Let me tell again how this problem is common with all the distributed locks implementations, and how the token as a solution is both unrealistic and can be used with Redlock as well.
(譯文:延遲只能發生在第3步之後,這導致鎖被認為是有效的而實際上已經過期了,也就是說,我們回到了Martin指出的第一個問題上,客戶端沒能夠在鎖的有效性過期之前完成與共享資源的互動。讓我再次申明一下,這個問題對於所有的分散式鎖的實現是普遍存在的,而且基於token的這種解決方案是不切實際的,但也能和Redlock一起用。)

這裡antirez所說的“Martin指出的第一個問題”具體是什麼呢?在本文上半部分我們提到過,Martin的文章分為兩大部分,其中前半部分與Redlock沒有直接關係,而是指出了任何一種帶自動過期功能的分散式鎖在沒有提供fencing機制的前提下都有可能失效。這裡antirez所說的就是指的Martin的文章的前半部分。換句話說,對於大延遲給Redlock帶來的影響,恰好與Martin在文章的前半部分針對所有的分散式鎖所做的分析是一致的,而這種影響不單單針對Redlock。Redlock的實現已經保證了它是和其它任何分散式鎖的安全性是一樣的。當然,與其它“更完美”的分散式鎖相比,Redlock似乎提供不了Martin提出的那種遞增的token,但antirez在前面已經分析過了,關於token的這種論證方式本身就是“不切實際”的,或者退一步講,Redlock能提供的unique token也能夠提供完全一樣的效果。

另外,關於大延遲對Redlock的影響,antirez和Martin在Twitter上有下面的對話:

antirez:
@martinkl so I wonder if after my reply, we can at least agree about unbound messages delay to don’t cause any harm.

Martin:
@antirez Agree about message delay between app and lock server. Delay between app and resource being accessed is still problematic.

(譯文:
antirez問:我想知道,在我發文回覆之後,我們能否在一點上達成一致,就是大的訊息延遲不會給Redlock的執行造成損害。
Martin答:對於客戶端和鎖伺服器之間的訊息延遲,我同意你的觀點。但客戶端和被訪問資源之間的延遲還是有問題的。)

通過這段對話可以看出,對於Redlock在第4步所做的鎖有效性的檢查,Martin是予以肯定的。但他認為客戶端和資源伺服器之間的延遲還是會帶來問題的。Martin在這裡說的有點模糊。就像antirez前面分析的,客戶端和資源伺服器之間的延遲,對所有的分散式鎖的實現都會帶來影響,這不單單是Redlock的問題了。

以上就是antirez在blog中所說的主要內容。有一些點值得我們注意一下:

  • antirez是同意大的系統時鐘跳躍會造成Redlock失效的。在這一點上,他與Martin的觀點的不同在於,他認為在實際系統中是可以避免大的時鐘跳躍的。當然,這取決於基礎設施和運維方式。
  • antirez在設計Redlock的時候,是充分考慮了網路延遲和程式停頓所帶來的影響的。但是,對於客戶端和資源伺服器之間的延遲(即發生在演算法第3步之後的延遲),antirez是承認所有的分散式鎖的實現,包括Redlock,是沒有什麼好辦法來應對的。

討論進行到這,Martin和antirez之間誰對誰錯其實並不是那麼重要了。只要我們能夠對Redlock(或者其它分散式鎖)所能提供的安全性的程度有充分的瞭解,那麼我們就能做出自己的選擇了。

Hacker News上的一些討論

針對Martin和antirez的兩篇blog,很多技術人員在Hacker News上展開了激烈的討論。這些討論所在地址如下:

在Hacker News上,antirez積極參與了討論,而Martin則始終置身事外。

下面我把這些討論中一些有意思的點拿出來與大家一起分享一下(集中在對於fencing token機制的討論上)。

關於antirez提出的“Check and Set”操作,他在blog裡並沒有詳加說明。果然,在Hacker News上就有人出來問了。antirez給出的答覆如下:

You want to modify locked resource X. You set X.currlock = token. Then you read, do whatever you want, and when you write, you "write-if-currlock == token". If another client did X.currlock = somethingelse, the transaction fails.

翻譯一下可以這樣理解:假設你要修改資源X,那麼遵循下面的偽碼所定義的步驟。

  1. 先設定X.currlock = token。
  2. 讀出資源X(包括它的值和附帶的X.currlock)。
  3. 按照"write-if-currlock == token"的邏輯,修改資源X的值。意思是說,如果對X進行修改的時候,X.currlock仍然和當初設定進去的token相等,那麼才進行修改;如果這時X.currlock已經是其它值了,那麼說明有另外一方也在試圖進行修改操作,那麼放棄當前的修改,從而避免衝突。

隨後Hacker News上一位叫viraptor的使用者提出了異議,它給出了這樣一個執行序列:

  • A: X.currlock = Token_ID_A
  • A: resource read
  • A: is X.currlock still Token_ID_A? yes
  • B: X.currlock = Token_ID_B
  • B: resource read
  • B: is X.currlock still Token_ID_B? yes
  • B: resource write
  • A: resource write

到了最後兩步,兩個客戶端A和B同時進行寫操作,衝突了。不過,這位使用者應該是理解錯了antirez給出的修改過程了。按照antirez的意思,判斷X.currlock是否修改過和對資源的寫操作,應該是一個原子操作。只有這樣理解才能合乎邏輯,否則的話,這個過程就有嚴重的破綻。這也是為什麼antirez之前會對fencing機制產生質疑:既然資源伺服器本身都能提供互斥的原子操作了,為什麼還需要一個分散式鎖呢?因此,antirez認為這種fencing機制是很累贅的,他之所以還是提出了這種“Check and Set”操作,只是為了證明在提供fencing token這一點上,Redlock也能做到。但是,這裡仍然有一些不明確的地方,如果將"write-if-currlock == token"看做是原子操作的話,這個邏輯勢必要在資源伺服器上執行,那麼第二步為什麼還要“讀出資源X”呢?除非這個“讀出資源X”的操作也是在資源伺服器上執行,它包含在“判斷-寫回”這個原子操作裡面。而假如不這樣理解的話,“讀取-判斷-寫回”這三個操作都放在客戶端執行,那麼看不出它們如何才能實現原子性操作。在下面的討論中,我們暫時忽略“讀出資源X”這一步。

這個基於random token的“Check and Set”操作,如果與Martin提出的遞增的fencing token對比一下的話,至少有兩點不同:

  • “Check and Set”對於寫操作要分成兩步來完成(設定token、判斷-寫回),而遞增的fencing token機制只需要一步(帶著token向資源伺服器發起寫請求)。
  • 遞增的fencing token機制能保證最終操作共享資源的順序,那些延遲時間太長的操作就無法操作共享資源了。但是基於random token的“Check and Set”操作不會保證這個順序,那些延遲時間太長的操作如果後到達了,它仍然有可能操作共享資源(當然是以互斥的方式)。

對於前一點不同,我們在後面的分析中會看到,如果資源伺服器也是分散式的,那麼使用遞增的fencing token也要變成兩步。

而對於後一點操作順序上的不同,antirez認為這個順序沒有意義,關鍵是能互斥訪問就行了。他寫下了下面的話:

So the goal is, when race conditions happen, to avoid them in some way.
......
Note also that when it happens that, because of delays, the clients are accessing concurrently, the lock ID has little to do with the order in which the operations were indented to happen.
(譯文: 我們的目標是,當競爭條件出現的時候,能夠以某種方式避免。
......
還需要注意的是,當那種競爭條件出現的時候,比如由於延遲,客戶端是同時來訪問的,鎖的ID的大小順序跟那些操作真正想執行的順序,是沒有什麼關係的。)

這裡的lock ID,跟Martin說的遞增的token是一回事。

隨後,antirez舉了一個“將名字加入列表”的操作的例子:

  • T0: Client A receives new name to add from web.
  • T0: Client B is idle
  • T1: Client A is experiencing pauses.
  • T1: Client B receives new name to add from web.
  • T2: Client A is experiencing pauses.
  • T2: Client B receives a lock with ID 1
  • T3: Client A receives a lock with ID 2

你看,兩個客戶端(其實是Web伺服器)執行“新增名字”的操作,A本來是排在B前面的,但獲得鎖的順序卻是B排在A前面。因此,antirez說,鎖的ID的大小順序跟那些操作真正想執行的順序,是沒有什麼關係的。關鍵是能排出一個順序來,能互斥訪問就行了。那麼,至於鎖的ID是遞增的,還是一個random token,自然就不那麼重要了。

Martin提出的fencing token機制,給人留下了無盡的疑惑。這主要是因為他對於這一機制的描述缺少太多的技術細節。從上面的討論可以看出,antirez對於這一機制的看法是,它跟一個random token沒有什麼區別,而且,它需要資源伺服器本身提供某種互斥機制,這幾乎讓分散式鎖本身的存在失去了意義。圍繞fencing token的問題,還有兩點是比較引人注目的,Hacker News上也有人提出了相關的疑問:

  • (1)關於資源伺服器本身的架構細節。
  • (2)資源伺服器對於fencing token進行檢查的實現細節,比如是否需要提供一種原子操作。

關於上述問題(1),Hacker News上有一位叫dwenzek的使用者發表了下面的評論:

...... the issue around the usage of fencing tokens to reject any late usage of a lock is unclear just because the protected resource and its access are themselves unspecified. Is the resource distributed or not? If distributed, does the resource has a mean to ensure that tokens are increasing over all the nodes? Does the resource have a mean to rollback any effects done by a client which session is interrupted by a timeout?

(譯文:...... 關於使用fencing token拒絕掉延遲請求的相關議題,是不夠清晰的,因為受保護的資源以及對它的訪問方式本身是沒有被明確定義過的。資源服務是不是分散式的呢?如果是,資源服務有沒有一種方式能確保token在所有節點上遞增呢?對於客戶端的Session由於過期而被中斷的情況,資源服務有辦法將它的影響回滾嗎?)

這些疑問在Hacker News上並沒有人給出解答。而關於分散式的資源伺服器架構如何處理fencing token,另外一名分散式系統的專家Flavio Junqueira在他的一篇blog中有所提及(我們後面會再提到)。

關於上述問題(2),Hacker News上有一位叫reza_n的使用者發表了下面的疑問:

I understand how a fencing token can prevent out of order writes when 2 clients get the same lock. But what happens when those writes happen to arrive in order and you are doing a value modification? Don't you still need to rely on some kind of value versioning or optimistic locking? Wouldn't this make the use of a distributed lock unnecessary?

(譯文: 我理解當兩個客戶端同時獲得鎖的時候fencing token是如何防止亂序的。但是如果兩個寫操作恰好按序到達了,而且它們在對同一個值進行修改,那會發生什麼呢?難道不會仍然是依賴某種資料版本號或者樂觀鎖的機制?這不會讓分散式鎖變得沒有必要了嗎?)

一位叫Terr_的Hacker News使用者答:

I believe the "first" write fails, because the token being passed in is no longer "the lastest", which indicates their lock was already released or expired.

(譯文: 我認為“第一個”寫請求會失敗,因為它傳入的token不再是“最新的”了,這意味著鎖已經釋放或者過期了。)

Terr_的回答到底對不對呢?這不好說,取決於資源伺服器對於fencing token進行檢查的實現細節。讓我們來簡單分析一下。

為了簡單起見,我們假設有一臺(先不考慮分散式的情況)通過RPC進行遠端訪問檔案伺服器,它無法提供對於檔案的互斥訪問(否則我們就不需要分散式鎖了)。現在我們按照Martin給出的說法,加入fencing token的檢查邏輯。由於Martin沒有描述具體細節,我們猜測至少有兩種可能。

第一種可能,我們修改了檔案伺服器的程式碼,讓它能多接受一個fencing token的引數,並在進行所有處理之前加入了一個簡單的判斷邏輯,保證只有當前接收到的fencing token大於之前的值才允許進行後邊的訪問。而一旦通過了這個判斷,後面的處理不變。

現在想象reza_n描述的場景,客戶端1和客戶端2都發生了GC pause,兩個fencing token都延遲了,它們幾乎同時到達了檔案伺服器,而且保持了順序。那麼,我們新加入的判斷邏輯,應該對兩個請求都會放過,而放過之後它們幾乎同時在操作檔案,還是衝突了。既然Martin宣稱fencing token能保證分散式鎖的正確性,那麼上面這種可能的猜測也許是我們理解錯了。

當然,還有第二種可能,就是我們對檔案伺服器確實做了比較大的改動,讓這裡判斷token的邏輯和隨後對檔案的處理放在一個原子操作裡了。這可能更接近antirez的理解。這樣的話,前面reza_n描述的場景中,兩個寫操作都應該成功。

基於ZooKeeper的分散式鎖更安全嗎?

很多人(也包括Martin在內)都認為,如果你想構建一個更安全的分散式鎖,那麼應該使用ZooKeeper,而不是Redis。那麼,為了對比的目的,讓我們先暫時脫離開本文的題目,討論一下基於ZooKeeper的分散式鎖能提供絕對的安全嗎?它需要fencing token機制的保護嗎?

我們不得不提一下分散式專家Flavio Junqueira所寫的一篇blog,題目叫“Note on fencing and distributed locks”,地址如下:

Flavio Junqueira是ZooKeeper的作者之一,他的這篇blog就寫在Martin和antirez發生爭論的那幾天。他在文中給出了一個基於ZooKeeper構建分散式鎖的描述(當然這不是唯一的方式):

  • 客戶端嘗試建立一個znode節點,比如/lock。那麼第一個客戶端就建立成功了,相當於拿到了鎖;而其它的客戶端會建立失敗(znode已存在),獲取鎖失敗。
  • 持有鎖的客戶端訪問共享資源完成後,將znode刪掉,這樣其它客戶端接下來就能來獲取鎖了。
  • znode應該被建立成ephemeral的。這是znode的一個特性,它保證如果建立znode的那個客戶端崩潰了,那麼相應的znode會被自動刪除。這保證了鎖一定會被釋放。

看起來這個鎖相當完美,沒有Redlock過期時間的問題,而且能在需要的時候讓鎖自動釋放。但仔細考察的話,並不盡然。

ZooKeeper是怎麼檢測出某個客戶端已經崩潰了呢?實際上,每個客戶端都與ZooKeeper的某臺伺服器維護著一個Session,這個Session依賴定期的心跳(heartbeat)來維持。如果ZooKeeper長時間收不到客戶端的心跳(這個時間稱為Sesion的過期時間),那麼它就認為Session過期了,通過這個Session所建立的所有的ephemeral型別的znode節點都會被自動刪除。

設想如下的執行序列:

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

最後,客戶端1和客戶端2都認為自己持有了鎖,衝突了。這與之前Martin在文章中描述的由於GC pause導致的分散式鎖失效的情況類似。

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

還有一個很有用的特性是ZooKeeper的watch機制。這個機制可以這樣來使用,比如當客戶端試圖建立/lock的時候,發現它已經存在了,這時候建立失敗,但客戶端不一定就此對外宣告獲取鎖失敗。客戶端可以進入一種等待狀態,等待當/lock節點被刪除的時候,ZooKeeper通過watch機制通知它,這樣它就可以繼續完成建立操作(獲取鎖)。這可以讓分散式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖為止。這樣的特性Redlock就無法實現。

小結一下,基於ZooKeeper的鎖和基於Redis的鎖相比在實現特性上有兩個不同:

  • 在正常情況下,客戶端可以持有鎖任意長的時間,這可以確保它做完所有需要的資源訪問操作之後再釋放鎖。這避免了基於Redis的鎖對於有效時間(lock validity time)到底設定多長的兩難問題。實際上,基於ZooKeeper的鎖是依靠Session(心跳)來維持鎖的持有狀態的,而Redis不支援Sesion。
  • 基於ZooKeeper的鎖支援在獲取鎖失敗之後等待鎖重新釋放的事件。這讓客戶端對鎖的使用更加靈活。

順便提一下,如上所述的基於ZooKeeper的分散式鎖的實現,並不是最優的。它會引發“herd effect”(羊群效應),降低獲取鎖的效能。一個更好的實現參見下面連結:

我們重新回到Flavio Junqueira對於fencing token的分析。Flavio Junqueira指出,fencing token機制本質上是要求客戶端在每次訪問一個共享資源的時候,在執行任何操作之前,先對資源進行某種形式的“標記”(mark)操作,這個“標記”能保證持有舊的鎖的客戶端請求(如果延遲到達了)無法操作資源。這種標記操作可以是很多形式,fencing token是其中比較典型的一個。

隨後Flavio Junqueira提到用遞增的epoch number(相當於Martin的fencing token)來保護共享資源。而對於分散式的資源,為了方便討論,假設分散式資源是一個小型的多備份的資料儲存(a small replicated data store),執行寫操作的時候需要向所有節點上寫資料。最簡單的做標記的方式,就是在對資源進行任何操作之前,先把epoch number標記到各個資源節點上去。這樣,各個節點就保證了舊的(也就是小的)epoch number無法運算元據。

當然,這裡再展開討論下去可能就涉及到了這個資料儲存服務的實現細節了。比如在實際系統中,可能為了容錯,只要上面講的標記和寫入操作在多數節點上完成就算成功完成了(Flavio Junqueira並沒有展開去講)。在這裡我們能看到的,最重要的,是這種標記操作如何起作用的方式。這有點類似於Paxos協議(Paxos協議要求每個proposal對應一個遞增的數字,執行accept請求之前先執行prepare請求)。antirez提出的random token的方式顯然不符合Flavio Junqueira對於“標記”操作的定義,因為它無法區分新的token和舊的token。只有遞增的數字才能確保最終收斂到最新的操作結果上。

在這個分散式資料儲存服務(共享資源)的例子中,客戶端在標記完成之後執行寫入操作的時候,儲存服務的節點需要判斷epoch number是不是最新,然後確定能不能執行寫入操作。如果按照上一節我們的分析思路,這裡的epoch判斷和接下來的寫入操作,是不是在一個原子操作裡呢?根據Flavio Junqueira的相關描述,我們相信,應該是原子的。那麼既然資源本身可以提供原子互斥操作了,那麼分散式鎖還有存在的意義嗎?應該說有。客戶端可以利用分散式鎖有效地避免衝突,等待寫入機會,這對於包含多個節點的分散式資源尤其有用(當然,是出於效率的原因)。

Chubby的分散式鎖是怎樣做fencing的?

提到分散式鎖,就不能不提Google的Chubby。

Chubby是Google內部使用的分散式鎖服務,有點類似於ZooKeeper,但也存在很多差異。Chubby對外公開的資料,主要是一篇論文,叫做“The Chubby lock service for loosely-coupled distributed systems”,下載地址如下:

另外,YouTube上有一個的講Chubby的talk,也很不錯,播放地址:

Chubby自然也考慮到了延遲造成的鎖失效的問題。論文裡有一段描述如下:

a process holding a lock L may issue a request R, but then fail. Another process may ac- quire L and perform some action before R arrives at its destination. If R later arrives, it may be acted on without the protection of L, and potentially on inconsistent data.

(譯文: 一個程式持有鎖L,發起了請求R,但是請求失敗了。另一個程式獲得了鎖L並在請求R到達目的方之前執行了一些動作。如果後來請求R到達了,它就有可能在沒有鎖L保護的情況下進行操作,帶來資料不一致的潛在風險。)

這跟Martin的分析大同小異。

Chubby給出的用於解決(緩解)這一問題的機制稱為sequencer,類似於fencing token機制。鎖的持有者可以隨時請求一個sequencer,這是一個位元組串,它由三部分組成:

  • 鎖的名字。
  • 鎖的獲取模式(排他鎖還是共享鎖)。
  • lock generation number(一個64bit的單調遞增數字)。作用相當於fencing token或epoch number。

客戶端拿到sequencer之後,在操作資源的時候把它傳給資源伺服器。然後,資源伺服器負責對sequencer的有效性進行檢查。檢查可以有兩種方式:

  • 呼叫Chubby提供的API,CheckSequencer(),將整個sequencer傳進去進行檢查。這個檢查是為了保證客戶端持有的鎖在進行資源訪問的時候仍然有效。
  • 將客戶端傳來的sequencer與資源伺服器當前觀察到的最新的sequencer進行對比檢查。可以理解為與Martin描述的對於fencing token的檢查類似。

當然,如果由於相容的原因,資源服務本身不容易修改,那麼Chubby還提供了一種機制:

  • lock-delay。Chubby允許客戶端為持有的鎖指定一個lock-delay的時間值(預設是1分鐘)。當Chubby發現客戶端被動失去聯絡的時候,並不會立即釋放鎖,而是會在lock-delay指定的時間內阻止其它客戶端獲得這個鎖。這是為了在把鎖分配給新的客戶端之前,讓之前持有鎖的客戶端有充分的時間把請求佇列排空(draining the queue),儘量防止出現延遲到達的未處理請求。

可見,為了應對鎖失效問題,Chubby提供的三種處理方式:CheckSequencer()檢查、與上次最新的sequencer對比、lock-delay,它們對於安全性的保證是從強到弱的。而且,這些處理方式本身都沒有保證提供絕對的正確性(correctness)。但是,Chubby確實提供了單調遞增的lock generation number,這就允許資源伺服器在需要的時候,利用它提供更強的安全性保障。

關於時鐘

在Martin與antirez的這場爭論中,衝突最為嚴重的就是對於系統時鐘的假設是不是合理的問題。Martin認為系統時鐘難免會發生跳躍(這與分散式演算法的非同步模型相符),而antirez認為在實際中系統時鐘可以保證不發生大的跳躍。

Martin對於這一分歧發表瞭如下看法(原話):

So, fundamentally, this discussion boils down to whether it is reasonable to make timing assumptions for ensuring safety properties. I say no, Salvatore says yes — but that's ok. Engineering discussions rarely have one right answer.

(譯文:
從根本上來說,這場討論最後歸結到了一個問題上:為了確保安全性而做出的記時假設到底是否合理。我認為不合理,而antirez認為合理 —— 但是這也沒關係。工程問題的討論很少只有一個正確答案。)

那麼,在實際系統中,時鐘到底是否可信呢?對此,Julia Evans專門寫了一篇文章,“TIL: clock skew exists”,總結了很多跟時鐘偏移有關的實際資料,並進行了分析。這篇文章地址:

Julia Evans在文章最後得出的結論是:

clock skew is real
(時鐘偏移在現實中是存在的)

Martin的事後總結

我們前面提到過,當各方的爭論在激烈進行的時候,Martin幾乎始終置身事外。但是Martin在這件事過去之後,把這個事件的前後經過總結成了一個很長的故事線。如果你想最全面地瞭解這個事件發生的前後經過,那麼建議去讀讀Martin的這個總結:

在這個故事總結的最後,Martin寫下了很多感性的評論:

For me, this is the most important point: I don't care who is right or wrong in this debate — I care about learning from others' work, so that we can avoid repeating old mistakes, and make things better in future. So much great work has already been done for us: by standing on the shoulders of giants, we can build better software.
......
By all means, test ideas by arguing them and checking whether they stand up to scrutiny by others. That's part of the learning process. But the goal should be to learn, not to convince others that you are right. Sometimes that just means to stop and think for a while.

(譯文:
對我來說最重要的一點在於:我並不在乎在這場辯論中誰對誰錯 —— 我只關心從其他人的工作中學到的東西,以便我們能夠避免重蹈覆轍,並讓未來更加美好。前人已經為我們創造出了許多偉大的成果:站在巨人的肩膀上,我們得以構建更棒的軟體。
......
對於任何想法,務必要詳加檢驗,通過論證以及檢查它們是否經得住別人的詳細審查。那是學習過程的一部分。但目標應該是為了獲得知識,而不應該是為了說服別人相信你自己是對的。有時候,那隻不過意味著停下來,好好地想一想。)


關於分散式鎖的這場爭論,我們已經完整地做了回顧和分析。

按照鎖的兩種用途,如果僅是為了效率(efficiency),那麼你可以自己選擇你喜歡的一種分散式鎖的實現。當然,你需要清楚地知道它在安全性上有哪些不足,以及它會帶來什麼後果。而如果你是為了正確性(correctness),那麼請慎之又慎。在本文的討論中,我們在分散式鎖的正確性上走得最遠的地方,要數對於ZooKeeper分散式鎖、單調遞增的epoch number以及對分散式資源進行標記的分析了。請仔細審查相關的論證。

Martin為我們留下了不少疑問,尤其是他提出的fencing token機制。他在blog中提到,會在他的新書《Designing Data-Intensive Applications》的第8章和第9章再詳加論述。目前,這本書尚在預售當中。我感覺,這會是一本值得一讀的書,它不同於為了出名或賺錢而出版的那種短平快的書籍。可以看出作者在這本書上投入了巨大的精力。

最後,我相信,這個討論還遠沒有結束。分散式鎖(Distributed Locks)和相應的fencing方案,可以作為一個長期的課題,隨著我們對分散式系統的認識逐漸增加,可以再來慢慢地思考它。思考它更深層的本質,以及它在理論上的證明。

(完)

感謝

由衷地感謝幾位朋友花了寶貴的時間對本文草稿所做的review:CacheCloud的作者付磊,快手的李偉博,阿里的李波。當然,文中如果還有錯漏,由我本人負責^-^。

其它精選文章

基於 Redis 的分散式鎖到底安全嗎?

相關文章