redis分散式鎖安全性的探討
本文轉載、整理自上篇:http://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w
與下篇:http://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQ兩篇博文
其中省略了Martin提出的fencing token有關的討論(上篇)、網友與Redis作者的討論(下篇)以及Chubby的介紹(下篇)。
想詳細看這三方面的朋友可以移至原文閱讀。
最後引用一下Martin的話:Engineering discussions rarely have one right answer.
基於單Redis節點的分散式鎖
首先,Redis客戶端為了獲取鎖
SET resource_name my_random_value NX PX 30000
命令執行成功:成功獲取了鎖,可以訪問共享資源了;
命令執行失敗:獲取鎖失敗。
注意,在上面的SET命令中:
my_random_value是由客戶端生成的一個隨機字串,它要保證在足夠長的一段時間內在所有客戶端的所有獲取鎖的請求中都是唯一的。
NX(Not if Exist)表示只有當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]的值傳進去。
SET resource_name my_random_value NX PX 30000
命令中容易被忽略的4個細節:
- 這個鎖必須要設定一個有效時間
time)。否則的話,當一個客戶端獲取該鎖成功之後,假如它崩潰了(單機存在的問題),或者由於發生了網路分割(network
partition)導致它再也無法和Redis節點通訊了,那麼它就會一直持有這個鎖,而其它客戶端永遠無法獲得該鎖了。(同時,有效時間設定也是一個兩難的問題,如果設定太短的話,鎖就有可能在客戶端完成對於共享資源的訪問之前過期,從而失去保護;如果設定太長的話,一旦某個持有鎖的客戶端釋放鎖失敗,那麼就會導致所有其它客戶端都無法獲取鎖,從而長時間內無法正常工作。) - 在獲取鎖的操作時,有人把它實現成了兩個Redis命令: SETNX resource_name my_random_value
EXPIRE resource_name 30 雖然這兩個命令和前面演算法描述中的一個SET命令執行效果相同,但卻不是原子的。
如果客戶端在執行完SETNX後崩潰了,那麼就沒有機會執行EXPIRE了,導致它一直持有這個鎖。 - 設定一個隨機字串my_random_value是很有必要的,它保證了客戶端釋放的鎖必須是自己持有的鎖。假如獲取鎖時SET的不是一個隨機字串,而是一個固定值,那麼可能會發生下面的執行序列:
- 客戶端1獲取鎖成功。
- 客戶端1在某個操作上阻塞了很長時間。
- 過期時間到了,鎖自動釋放了。
- 客戶端2獲取到了對應同一個資源的鎖。
- 客戶端1從阻塞中恢復過來,釋放掉了客戶端2持有的鎖。
- 釋放鎖的操作必須使用Lua指令碼來實現。釋放鎖其實包含三步操作:’GET’、判斷和’DEL’,用Lua實現能保證這三步的原子性。否則,如果把這三步操作放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題類似的執行序列:
- 客戶端1獲取鎖成功。
- 客戶端1操作共享資源後進入判斷要釋放鎖
- 客戶端1由於某個原因阻塞住了很長時間。
- 過期時間到了,鎖自動釋放了。
- 客戶端2獲取到了對應同一個資源的鎖。(要麼實現隨機字串的唯一性,要麼實現釋放鎖的原子性操作)
- 客戶端1從阻塞中恢復過來,執行DEL操縱,釋放掉了客戶端2持有的鎖。 實際上,在上述第三個問題和第四個問題的分析中,如果不是客戶端阻塞住了,而是出現了大的網路延遲,也有可能導致類似的執行序列發生。
基於Redis叢集的分散式鎖
前面的四個細節,只要實現的時候加以注意,就都能夠被正確處理。但除此之外,由failover(失敗切換重試)引起的問題,卻是基於單Redis節點的分散式鎖無法解決的。正是這個問題催生了Redlock的出現。
Redlock是為了規範各家對基於Redis的分散式鎖的實現,而由Redis的作者提出的一個更安全的實現。
當單Redis節點宕機了,那麼所有客戶端就都無法獲得鎖了,服務變得不可用。為了提高可用性,我們可以給這個Redis節點掛一個Slave,當Master節點不可用的時候,系統自動切到Slave上(failover)。但由於Redis的主從複製(replication)是非同步的,這可能導致在failover過程中喪失鎖的安全性。考慮下面的執行序列:
- 客戶端1從Master獲取了鎖。
- Master宕機了,儲存鎖的key還沒有來得及同步到Slave上。
- Slave升級為Master。
- 客戶端2從新的Master獲取到了對應同一個資源的鎖。
於是,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破。針對Redis主從複製過程中可能存在喪失鎖的安全性問題,Redis作者設計了Redlock演算法。
執行Redlock演算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操作:
- 獲取當前時間(毫秒數)。
- 按順序依次向N個Redis節點執行獲取鎖的操作。這個獲取操作跟前面基於單Redis節點的獲取鎖的過程相同,包含隨機字串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間)。為了保證在某個Redis節點不可用的時候演算法能夠繼續執行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗以後,應該立即嘗試下一個Redis節點。這裡的失敗,應該包含任何型別的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這裡只提到了Redis節點不可用的情況,但也應該包含其它的失敗情況)。
- 計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間。如果客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認為最終獲取鎖成功;否則,認為最終獲取鎖失敗。
- 如果最終獲取鎖成功了,那麼這個鎖的有效時間等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。
- 如果最終獲取鎖失敗了(可能由於獲取到鎖的Redis節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有Redis節點發起釋放鎖的操作(即前面介紹的Redis Lua指令碼)。
當然,上面描述的只是獲取鎖的過程,而釋放鎖的過程比較簡單:客戶端向所有Redis節點發起釋放鎖的操作,不管這些節點當時在獲取鎖的時候成功與否。也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。這是因為存在這樣一種情況,客戶端發給某個Redis節點的獲取鎖的請求成功到達了該Redis節點,這個節點也成功執行了SET操作,但是它返回給客戶端的響應包卻丟失了。這在客戶端看來,獲取鎖的請求由於超時而失敗了,但在Redis這邊看來,加鎖已經成功了。因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些Redis節點同樣發起請求。實際上,這種情況在非同步通訊模型中是有可能發生的:客戶端向伺服器通訊是正常的,但反方向卻是有問題的。
由於N個Redis節點中的大多數能正常工作就能保證Redlock正常工作,因此理論上它的可用性更高。我們前面討論的單Redis節點的分散式鎖在failover的時候鎖失效的問題,在Redlock中不存在了,但如果有節點發生崩潰重啟,還是會對鎖的安全性有影響的。具體的影響程度跟Redis對資料的持久化程度有關。
假設一共有5個Redis節點:A, B, C, D, E。設想發生瞭如下的事件序列:
- 客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住)。
- 節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了。
- 節點C重啟後,客戶端2鎖住了C, D, E,獲取鎖成功。
這樣,客戶端1和客戶端2同時獲得了鎖(針對同一資源)。
在預設情況下,Redis的AOF持久化方式是每秒寫一次磁碟(即執行fsync),因此最壞情況下可能丟失1秒的資料。為了儘可能不丟資料,Redis允許設定成每次修改資料都進行fsync,但這會降低效能。當然,即使執行了fsync也仍然有可能丟失資料(這取決於系統而不是Redis的實現)。所以,由於節點崩潰重啟引發的鎖失效問題,總是有可能出現的。為了應對這一問題,作者又提出了延遲重啟(delayed restarts)的概念。也就是說,一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,這段時間應該大於鎖的有效時間(lock validity time)。這樣的話,這個節點在重啟前所參與的鎖都會過期,它在重啟後就不會對現有的鎖造成影響。
Martin(一個分散式專家)認為Redlock對系統記時(timing)的過分依賴(Redis中EX或PX時間都依靠伺服器時間,如果手動調一下伺服器時間使其大於有效時間,那麼這個鍵值對將立刻過期),他首先給出了下面的一個例子(還是假設有5個Redis節點A, B, C, D, E):
- 客戶端1從Redis節點A, B, C成功獲取了鎖(多數節點)。由於網路問題,與D和E通訊失敗。
- 節點C上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。
- 客戶端2從Redis節點C, D, E成功獲取了同一個資源的鎖(多數節點)。
- 客戶端1和客戶端2現在都認為自己持有了鎖。
Redlock的安全性(safety property)對系統的時鐘有比較強的依賴,一旦系統的時鐘變得不準確,演算法的安全性也就保證不了了。Martin在這裡其實是要指出分散式演算法研究中的一些常識問題,即好的分散式演算法應該基於非同步模型(asynchronous model),演算法的安全性不應該依賴於任何記時假設(timing assumption)。在非同步模型中:程序可能pause任意長的時間,訊息可能在網路中延遲任意長的時間,甚至丟失,系統時鐘也可能以任意方式出錯。一個好的分散式演算法,這些因素不應該影響它的安全性(safety property),只可能影響到它的活性(liveness property),也就是說,即使在非常極端的情況下(比如系統時鐘嚴重錯誤),演算法頂多是不能在有限的時間內給出結果而已,而不應該給出錯誤的結果。這樣的演算法在現實中是存在的,像比較著名的Paxos,或Raft。但顯然按這個標準的話,Redlock的安全性級別是達不到的。
Martin還提出一個很有見地的觀點,就是對鎖的用途的區分。他把鎖的用途分為兩種:
為了效率(efficiency),協調各個客戶端避免做重複的工作。即使鎖偶爾失效了,只是可能把某些操作多做一遍而已,不會產生其它的不良後果。比如重複傳送了一封同樣的email。
為了正確性(correctness)。在任何情況下都不允許鎖失效的情況發生,因為一旦發生,就可能意味著資料不一致(inconsistency),資料丟失,檔案損壞,或者其它嚴重的問題。
最後,Martin得出瞭如下的結論:
如果是為了效率(efficiency)而使用分散式鎖,允許鎖的偶爾失效,那麼使用單Redis節點的鎖方案就足夠了,簡單而且效率高。Redlock則是個過重的實現(heavyweight)。
如果是為了正確性(correctness)在很嚴肅的場合使用分散式鎖,那麼不要使用Redlock。它不是建立在非同步模型上的一個足夠強的演算法,它對於系統模型的假設中包含很多危險的成分(對於timing)。應該考慮類似Zookeeper的分散式鎖方案(這也是目前企業流行方案),或者支援事務的資料庫。
到此,Martin認為Redlock會失效的情況主要有三種:
- 時鐘發生跳躍。
- 長時間的GC pause。
- 長時間的網路延遲。
對於後兩種情況來說,Redlock在當初設計的時候已經考慮到了,對它們引起的後果有一定的免疫力。並且對於大延遲給Redlock帶來的影響與所有的分散式鎖是一致的,而這種影響不單單針對Redlock。Redlock的實現已經保證了它是和其它任何分散式鎖的安全性是一樣的。
關鍵在於時鐘跳躍,Redis作者認為通過恰當的運維,完全可以避免時鐘發生大的跳動,而Redlock對於時鐘的要求在現實系統中是完全可以滿足的。(實際中:時鐘偏移在現實中是存在的)
Martin在提到時鐘跳躍的時候,舉了兩個可能造成時鐘跳躍的具體例子:
系統管理員手動修改了時鐘。
從NTP服務收到了一個大的時鐘更新事件。
Redis作者反駁說:
手動修改時鐘這種人為原因,不要那麼做就是了。否則的話,如果有人手動修改Raft協議的持久化日誌,那麼就算是Raft協議它也沒法正常工作了。
使用一個不會進行“跳躍”式調整系統時鐘的ntpd程式(可能是通過恰當的配置),對於時鐘的修改通過多次微小的調整來完成。
而Redlock對時鐘的要求,並不需要完全精確,它只需要時鐘差不多精確就可以了。比如,要記時5秒,但可能實際記了4.5秒,然後又記了5.5秒,有一定的誤差。不過只要誤差不超過一定範圍,這對Redlock不會產生影響。antirez認為呢,像這樣對時鐘精度並不是很高的要求,在實際環境中是完全合理的。
基於ZK的分散式鎖
Flavio Junqueira是ZooKeeper的作者之一。他給出了一個基於ZooKeeper構建分散式鎖的描述(當然這不是唯一的方式):
客戶端嘗試建立一個znode節點,比如/lock。那麼第一個客戶端就建立成功了,相當於拿到了鎖;而其它的客戶端會建立失敗(znode已存在),獲取鎖失敗。
持有鎖的客戶端訪問共享資源完成後,將znode刪掉,這樣其它客戶端接下來就能來獲取鎖了。
znode應該被建立成ephemeral的。這是znode的一個特性,它保證如果建立znode的那個客戶端崩潰了,那麼相應的znode會被自動刪除。這保證了鎖一定會被釋放。
看起來這個鎖相當完美,沒有Redlock過期時間的問題,而且能在需要的時候讓鎖自動釋放。但仔細考察的話,並不盡然。
ZooKeeper是怎麼檢測出某個客戶端已經崩潰了呢?實際上,每個客戶端都與ZooKeeper的某臺伺服器維護著一個Session,這個Session依賴定期的心跳(heartbeat)來維持。如果ZooKeeper長時間收不到客戶端的心跳(這個時間稱為Sesion的過期時間),那麼它就認為Session過期了,通過這個Session所建立的所有的ephemeral型別的znode節點都會被自動刪除。
設想如下的執行序列:
- 客戶端1建立了znode節點/lock,獲得了鎖。
- 客戶端1進入了長時間的GC pause。
- 客戶端1連線到ZooKeeper的Session過期了。znode節點/lock被自動刪除。
- 客戶端2建立了znode節點/lock,從而獲得了鎖。
- 客戶端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的分散式鎖支援在獲取鎖失敗之後等待該鎖重新釋放後再獲取。這讓客戶端對鎖的使用更加靈活。