分布式鎖是一個在很多環境中非常有用的原語,它是不同進程互斥操作共用資源的唯一方法。有很多的開發庫和部落格描述如何使用Redis實現DLM(Distributed Lock Manager),但是每個開發庫使用不同的方式,而且相比更複雜的設計與實現,很多庫使用一些簡單低可靠的方式來實現。
這篇文章嘗試提供更標準的演算法來使用Redis實現分布式鎖。我們提出一種演算法,叫做Relock,它實現了我們認為比vanilla單一執行個體方式更安全的DLM(分布式鎖管理)。我們希望社區分析它並提供反饋,以做為更加複雜或替代設計的一個實現。
實現
在說具體演算法之前,下面有一些具體的實現可供參考.
- Redlock-rb (Ruby實現).
- Redlock-php (PHP 實現).
- Redsync.go (Go 實現).
- Redisson (Java 實現).
安全和活躍性保證
從有效分布式鎖的最小保證粒度來說,我們的模型裡面只用了3個屬性,具體如下:
1. 屬性安全: 互斥行.在任何時候,只有一個用戶端可以獲得鎖.
2. 活躍屬性A: 死結自由. 即使一個用戶端已經擁用了已損壞或已被分割資源的鎖,但它也有可能請求其他的鎖.
3. 活躍屬性B:容錯. 只要大部分Redis節點可用, 用戶端就可以獲得和釋放鎖.
為何基於容錯的實現還不夠
要理解我們所做的改進,就要先分析下當前基於Redis的分布式鎖的做法。
使用Redis鎖住資源的最簡單的方法是建立一對key-value值。利用Redis的逾時機制,key被建立為有一定的生存期,因此它最終會被釋放。而當用戶端想要釋放時,直接刪除key就行了。
一般來說這工作得很好,但有個問題: 這是系統的一個單點。如果Redis主節點掛了呢?當然,我們可以加個子節點,主節點出問題時可以切換過來。不過很可惜,這種方案不可行,因為Redis的主-從複製是非同步,我們無法用其實現互斥的安全特性。
這明顯是該模型的一種競態條件:
- 用戶端A在主節點獲得了一個鎖。
- 主節點掛了,而到從節點的寫同步還沒完成。
- 從節點被提升為主節點。
- 用戶端B獲得和A相同的鎖。注意,鎖安全性被破壞了!
有時候,在某些情況下這反而工作得很好,例如在出錯時,多個用戶端可以獲得同一個鎖。如果這正好是你想要的,那就可以使用主-從複製的方案。否則,我們建議使用這篇文章中描述的方法。
單一實例的正確實現方案
在嘗試解決上文描述的單一實例方案的缺陷之前,先讓我們確保針對這種簡單的情況,怎麼做才是無誤的,因為這種方案對某些程式而言也是可以接受的,而且這也是我們即將描述的分布式方案的基礎。
為了擷取鎖,方法是這樣的:
複製代碼 代碼如下:
SET resource_name my_random_value NX PX 30000
這條指令將設定key的值,僅當其不存在時生效(NX選項), 且設定其生存期為30000毫秒(PX選項)。和key關聯的value值是"my_random_value"。這個值在所有用戶端和所有加鎖請求中是必須是唯一的。
使用隨機值主要是為了能夠安全地釋放鎖,這要同時結合這麼個處理邏輯:刪除key值若且唯若其已存在並且其value值是我們所期待的。看看以下lua代碼:
複製代碼 代碼如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
這麼做很重要,可以避免誤刪其他用戶端建立的鎖。例如某個用戶端獲得了一個鎖,但它的處理時間長度超過了鎖的有效時間長度,之後它刪除了這個鎖,而此時這個鎖可能又被其他用戶端給獲得了。僅僅做刪除是不夠安全的,很可能會把其他用戶端的鎖給刪了。結合上面的代碼,每個鎖都有個唯一的隨機值,因此僅當這個值依舊是用戶端所設定的值時,才會去刪除它。
那麼應該怎樣產生這個隨機值呢?我們使用的是從/dev/urandom讀取的20個位元組,但你也可以找個更簡單的方法,只要能滿足任務就行。例如,可以使用/dev/urandom初始化RC4演算法,然後用其產生隨機數流。更簡單的方法是組合unix時間戳記和用戶端ID, 這並不安全,但對很多環境而言也夠用了。
我們所說的key的時間,是指”鎖的有效時間長度“. 它代表兩種情況,一種是指鎖的自動釋放時間長度,另一種是指在另一個用戶端擷取鎖之前某個用戶端佔用這個鎖的時間長度,這被限制在從鎖擷取後開始的一段時間視窗內。
現在我們已經有好的辦法擷取和釋放鎖了。在單一實例非分布式系統中,只要保證節點沒掛掉,這個方法就是安全的。那麼讓我們把這個概念擴充到分布式的系統中吧,那裡可沒有這種保證。
Redlock 演算法
在此演算法的分布式版本中,我們假設有N個Redis主節點。這些節點是相互獨立的,因此我們不使用複製或其他隱式同步機制。我們已經描述過在單一實例情況下如何安全地擷取鎖。我們也指出此演算法將使用這種方法從單一實例擷取和釋放鎖。在以下樣本中,我們設定N=5(這是個比較適中的值),這樣我們需要在不同物理機或虛擬機器上運行5個Redis主節點,以確保它們的出錯是儘可能獨立的。
為了擷取鎖,用戶端執行以下操作:
- 擷取目前時間,以毫秒為單位。
- 以串列的方式嘗試從所有的N個執行個體中擷取鎖,使用的是相同的key值和相同的隨機value值。在從每個執行個體擷取鎖時,用戶端會設定一個連線逾時,其時間長度相比鎖的自動釋放時間要短得多。例如,若鎖的自動釋放時間是10秒,那麼連線逾時大概設在5到50毫秒之間。這可以避免當Redis節點掛掉時,會長時間堵住用戶端:如果某個節點沒及時響應,就應該儘快轉到下個節點。
- 用戶端計算擷取所有鎖耗費的時間長度,方法是使用目前時間減去步驟1中的時間戳記。若且唯若用戶端能從多數節點(至少3個)中獲得鎖,並且耗費的時間長度小於鎖的有效期間時,可認為鎖已經獲得了。
- 如果鎖獲得了,它的最終有效時間長度將重新計算為其原時間長度減去步驟3中擷取鎖耗費的時間長度。
- 如果鎖擷取失敗了(要麼是沒有鎖住N/2+1個節點,要麼是鎖的最終有效時間長度為負數),用戶端會對所有執行個體進行解鎖操作(即使對那些沒有加鎖成功的執行個體也一樣)。
演算法是非同步?
演算法依賴於這樣一個假定,它在處理的時候不是(基於)同步時鐘的,每個處理中仍然使用的是本地的時間,它只是大致地以同樣地速率運行,這樣它就會有一個小的錯誤,與之相比會有一個小的自動開合的時鐘時間。這個假設很像真正世界的電腦:每一台電腦有一個本地時鐘,通常我們使用不同的電腦會有一個很小的時鐘差。
基於這個觀點,我們需要更好地指明我們共同的互斥法則:這是保證用戶端能長時間保持狀態鎖定,其將會終止它們在有效時間內的工作(在步驟3中獲得),減去一些時間(在處理時時間差時減去了一些毫秒用來補償)。
想要瞭解關於系統需要一個範圍的時間差的內容可以擷取更多的資訊,這篇論文是很好的參考: Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
失敗時重試
當用戶端無法擷取鎖時,它應該在一個隨機延遲後重試,從而避免多個用戶端同時試圖擷取鎖,相對應同一的同時請求(這可能會導致崩潰,沒人會勝出)。同樣的,用戶端在大多數場合下嘗試擷取鎖的速度越快,崩潰的視窗就越少(重試的需要也越少),所以實際情況下用戶端應嘗試採用複用方式發送SET命令到多個執行個體。
強調客戶在擷取主鎖失敗是值得的,釋放(或部分)以儘快獲得鎖,這樣沒有必要為擷取鎖鎖而去等待鍵到期(但是如果網路磁碟分割發生變化時用戶端不能與Redis通訊的情況下,需要顯性提示和等待逾時)。
釋放鎖
釋放鎖是簡單的,只需要釋放所有執行個體的鎖即可,儘管用戶端認為有能力成功鎖住一個給出的執行個體。
安全參數
要問一個演算法是安全的嗎?那麼可以嘗試著去理解在不同的情景下發生了什麼。我們以假設用戶端在大多數情況下都能獲得鎖來開始,所有的執行個體都包含相同生存周期的鍵。由於鍵是在不同的時間設定的,所以鍵也將在不同的時間逾時。然而,如果第一個節點最遲在t1時刻建立(即樣品接觸的第一伺服器之前),上一個鍵最遲在T2時刻建立(從上一個伺服器獲得回複的時間)。可以確定的是第一個鍵在逾時之前將生存至少MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。所有其他的鑰匙將到期後,鑰匙將至少在這一次同時設定。
在過半的鍵被設定這段時間裡,另一個用戶端無法獲得鎖,如果N/2+1個鍵已經存在,N/2+1 SET NX操作將不能成功。所以一個鎖被擷取,同一時刻被重複擷取是不可能的(違反互斥性)。
然而我們還想讓多個用戶端在擷取鎖的時候不能同時成功。
如果一個用戶端鎖定大部分執行個體的時間超過了鎖的最大有效時間(TTL基本設定) ,它將考慮鎖無效了,並解鎖。所以我們僅考慮在有效時間內大部分執行個體獲得鎖的情況。這種情況已經在上文中討論過, 對於MIN_VALIDITY沒有用戶端會重新擷取鎖。所以只有當鎖大多數執行個體的時間超過TTL時間時,多用戶端才能同時鎖住N/2+1個執行個體(在步驟2的“時間”即將結束時),讓鎖失效。
你是否能提供一個形式化的證明,指出現存的足夠相似的演算法,或找出些bug? 那我們將感激不盡。
存活性證明
系統的存活性基於以下三個主要特性:
- 鎖的自動釋放(key會到期): 最終所有的key將可以被重新鎖住;
- 一般來說,用戶端如果沒有成功獲得鎖,或者獲得了鎖並且完成了工作,都會及時釋放鎖,使得我們無需等待key自動釋放以重新獲得。
- 當用戶端重新擷取鎖之前,它會等待一段時間,這段時間比擷取鎖本身要長得多,這是為了盡量降低資源競爭引起的腦裂條件的機率。
然而,在網路割裂的情況下,我們得付出等同於"TTL"時間的可用性代價,如果網路持續割裂,我們就得無限的付出這個代價。這發生於當用戶端擷取了一個鎖,而在刪除鎖之前網路斷開了。
基本上,如果網路無限期地持續割裂,那系統將無限期地不可用。
效能、故障恢複和檔案同步
許多使用者使用Redis作為一個需要高效能的加鎖伺服器,可以根據延遲動態擷取和釋放鎖,每秒可以成功執行大量的擷取/釋放鎖操作。為了滿足這些需求,一種多工策略是協同N台 Redis伺服器減少延遲(或者叫做窮人的互助,也就是說,將連接埠置為non-blocking模式,發送所有的命令,延遲讀出所有的命令,假定用戶端和每個Redis執行個體的往返時間是相似的)。
然而,如果我們旨在實現一種故障系統的復原模式,這裡有另一種與持久性相關的思路。
考慮這個基本問題,假定我們完全沒有配置Redis的持久性。一個用戶端需要鎖定5個執行個體中的3個。其中一個允許用戶端擷取的鎖重新啟動,雖然我們可以再次為一些資源鎖定3個執行個體,但其它的用戶端同樣可以鎖定它,違反了獨佔鎖定安全性。
如果我們啟用AOF持久性,情況就會得到相當的改善。例如我們可以通過發送 SHUTDOWN升級一個伺服器並且重啟它。因為Redis的期限是通過語義設定的,所以伺服器關閉的情況下虛擬時間仍然會流逝,我們所有的需求都得到了滿足。不管怎樣所有事務都會正常運轉只要伺服器完全關閉。如果電源中斷會怎樣?如果Redis進行了相關配置,預設情況下每秒檔案都會同步寫入磁碟,很有可能在重啟後我們的資料會丟失。理論上,如果我們想在任何一種執行個體重啟後保證鎖的安全性,我們需要確保在持久性配置中設定fsync=always。這將會在同等層級的CP系統上損失效能,傳統上這種方式用來更安全的分配鎖。
不管怎樣事情比我們初次瞥見他們看起來好些。基本上演算法的安全性得到保留,就算是當一個執行個體在故障後重啟,它也將不再參與任何當前活躍的鎖的分配。因此當執行個體重啟時,當前所有活動鎖的設定將從鎖定的執行個體中擷取除它重新加入系統。
為了保證這一點,我們只需要做一個執行個體,在超過最大TTL後,崩潰,不可用,那麼就需要時間去擷取所有存在著的鎖的鑰匙,當執行個體崩潰時,其就會變得無效,會被自動釋放。
使用延時重啟可以基本上實現安全,甚至不需要利用任何Redis的持久化特性,但是這存在著另外的副作用。舉例來說,如果大量的執行個體崩潰,系統變得全域不可用,那麼TTL(這裡的全域意味著根本就沒有資源可用,在這個時間內所有的資源都會被鎖定)。
讓演算法更可靠: 擴充鎖
如果客戶工作的執行是由小步驟組成,那麼它就可以在預設時間裡預設使用更小的鎖,並擴充了演算法去實現的一個鎖的擴充機制。當鎖的有效性接近於一個低值,那麼通常是用戶端在運算中處於置中位置。當鎖被取得時,可能擴充的鎖通過發送一個Lua指令碼到所有的執行個體,這個執行個體是擴充TTL的鑰匙,如果鑰匙存在,那麼它的值就是用戶端複製的隨機值。
用戶端應該僅考慮鎖的重新取得,如果它可以被擴充,鎖就會在有效時間內進入大量執行個體(基本的演算法使用非常類似於擷取鎖的使用)。
雖然這不是從技術上去改變演算法,但是無論如何嘗試擷取鎖的最大次數是需要限制的,否則的話會違反活躍性中的一個屬性。