網上有關Redis分佈式鎖的文章可謂多如牛毛了,不信的話你能夠拿關鍵詞「Redis 分佈式鎖」隨便到哪一個搜索引擎上去搜索一下就知道了。這些文章的思路大致相近,給出的實現算法也看似合乎邏輯,但當咱們着手去實現它們的時候,卻發現若是你越是仔細推敲,疑慮也就愈來愈多。html
實際上,大概在一年之前,關於Redis分佈式鎖的安全性問題,在分佈式系統專家Martin Kleppmann和Redis的做者antirez之間就發生過一場爭論。因爲對這個問題一直以來比較關注,因此我前些日子仔細閱讀了與這場爭論相關的資料。這場爭論的大概過程是這樣的:爲了規範各家對基於Redis的分佈式鎖的實現,Redis的做者提出了一個更安全的實現,叫作Redlock。有一天,Martin Kleppmann寫了一篇blog,分析了Redlock在安全性上存在的一些問題。而後Redis的做者當即寫了一篇blog來反駁Martin的分析。但Martin表示仍然堅持原來的觀點。隨後,這個問題在Twitter和Hacker News上引起了激烈的討論,不少分佈式系統的專家都參與其中。redis
對於那些對分佈式系統感興趣的人來講,這個事件很是值得關注。無論你是剛接觸分佈式系統的新手,仍是有着多年分佈式開發經驗的老手,讀完這些分析和評論以後,大概都會有所收穫。要知道,親手實現過Redis Cluster這樣一個複雜系統的antirez,足以算得上分佈式領域的一名專家了。但對於由分佈式鎖引起的一系列問題的分析中,不一樣的專家卻能得出迥異的結論,從中咱們能夠窺見分佈式系統相關的問題具備何等的複雜性。實際上,在分佈式系統的設計中常常發生的事情是:許多想法初看起來毫無破綻,而一旦詳加考量,卻發現不是那麼完美無缺。算法
下面,咱們就從頭到尾把這場爭論過程當中各方的觀點進行一下回顧和分析。在這個過程當中,咱們把影響分佈式鎖的安全性的那些技術細節展開進行討論,這將是一件頗有意思的事情。這也是一個比較長的故事。固然,其中也免不了包含一些小「八卦」。數據庫
就像本文開頭所講的,藉助Redis來實現一個分佈式鎖(Distributed Lock)的作法,已經有不少人嘗試過。人們構建這樣的分佈式鎖的目的,是爲了對一些共享資源進行互斥訪問。安全
可是,這些實現雖然思路大致相近,但實現細節上各不相同,它們能提供的安全性和可用性也不盡相同。因此,Redis的做者antirez給出了一個更好的實現,稱爲Redlock,算是Redis官方對於實現分佈式鎖的指導規範。Redlock的算法描述就放在Redis的官網上:服務器
在Redlock以前,不少人對於分佈式鎖的實現都是基於單個Redis節點的。而Redlock是基於多個Redis節點(都是Master)的一種實現。爲了能理解Redlock,咱們首先須要把簡單的基於單Redis節點的算法描述清楚,由於它是Redlock的基礎。網絡
首先,Redis客戶端爲了獲取鎖,向Redis節點發送以下命令:dom
SET resource_name my_random_value NX PX 30000
上面的命令若是執行成功,則客戶端成功獲取到了鎖,接下來就能夠訪問共享資源了;而若是上面的命令執行失敗,則說明獲取鎖失敗。異步
注意,在上面的SET
命令中:async
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
的不是一個隨機字符串,而是一個固定值,那麼可能會發生下面的執行序列:
以後,客戶端2在訪問共享資源的時候,就沒有鎖爲它提供保護了。
第四個問題,釋放鎖的操做必須使用Lua腳原本實現。釋放鎖其實包含三步操做:’GET’、判斷和’DEL’,用Lua腳原本實現能保證這三步的原子性。不然,若是把這三步操做放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題相似的執行序列:
DEL
操縱,釋放掉了客戶端2持有的鎖。實際上,在上述第三個問題和第四個問題的分析中,若是不是客戶端阻塞住了,而是出現了大的網絡延遲,也有可能致使相似的執行序列發生。
前面的四個問題,只要實現分佈式鎖的時候加以注意,就都可以被正確處理。但除此以外,antirez還指出了一個問題,是由failover引發的,倒是基於單Redis節點的分佈式鎖沒法解決的。正是這個問題催生了Redlock的出現。
這個問題是這樣的。假如Redis節點宕機了,那麼全部客戶端就都沒法得到鎖了,服務變得不可用。爲了提升可用性,咱們能夠給這個Redis節點掛一個Slave,當Master節點不可用的時候,系統自動切到Slave上(failover)。但因爲Redis的主從複製(replication)是異步的,這可能致使在failover過程當中喪失鎖的安全性。考慮下面的執行序列:
因而,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破。針對這個問題,antirez設計了Redlock算法,咱們接下來會討論。
【其它疑問】
前面這個算法中出現的鎖的有效時間(lock validity time),設置成多少合適呢?若是設置過短的話,鎖就有可能在客戶端完成對於共享資源的訪問以前過時,從而失去保護;若是設置太長的話,一旦某個持有鎖的客戶端釋放鎖失敗,那麼就會致使全部其它客戶端都沒法獲取鎖,從而長時間內沒法正常工做。看來真是個兩難的問題。
並且,在前面對於隨機字符串my_random_value
的分析中,antirez也在文章中認可的確應該考慮客戶端長期阻塞致使鎖過時的狀況。若是真的發生了這種狀況,那麼共享資源是否是已經失去了保護呢?antirez從新設計的Redlock是否能解決這些問題呢?
因爲前面介紹的基於單Redis節點的分佈式鎖在failover的時候會產生解決不了的安全性問題,所以antirez提出了新的分佈式鎖的算法Redlock,它基於N個徹底獨立的Redis節點(一般狀況下N能夠設置成5)。
運行Redlock算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操做:
my_random_value
,也包含過時時間(好比PX 30000
,即鎖的有效時間)。爲了保證在某個Redis節點不可用的時候算法可以繼續運行,這個獲取鎖的操做還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗之後,應該當即嘗試下一個Redis節點。這裏的失敗,應該包含任何類型的失敗,好比該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這裏只提到了Redis節點不可用的狀況,但也應該包含其它的失敗狀況)。固然,上面描述的只是獲取鎖的過程,而釋放鎖的過程比較簡單:客戶端向全部Redis節點發起釋放鎖的操做,無論這些節點當時在獲取鎖的時候成功與否。
因爲N個Redis節點中的大多數能正常工做就能保證Redlock正常工做,所以理論上它的可用性更高。咱們前面討論的單Redis節點的分佈式鎖在failover的時候鎖失效的問題,在Redlock中不存在了,但若是有節點發生崩潰重啓,仍是會對鎖的安全性有影響的。具體的影響程度跟Redis對數據的持久化程度有關。
假設一共有5個Redis節點:A, B, 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 Kleppmann在2016-02-08這一天發表了一篇blog,名字叫」How to do distributed locking 「,地址以下:
Martin在這篇文章中談及了分佈式系統的不少基礎性的問題(特別是分佈式計算的異步模型),對分佈式系統的從業者來講很是值得一讀。這篇文章大致能夠分爲兩大部分:
首先咱們討論一下前半部分的關鍵點。Martin給出了下面這樣一份時序圖:
在上面的時序圖中,假設鎖服務自己是沒有問題的,它老是能保證任一時刻最多隻有一個客戶端得到鎖。上圖中出現的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,這樣提供共享資源的服務就能根據它進行檢查,拒絕掉延遲到來的訪問請求(避免了衝突)。以下圖:
在上圖中,客戶端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):
上面這種狀況之因此有可能發生,本質上是由於Redlock的安全性(safety property)對系統的時鐘有比較強的依賴,一旦系統的時鐘變得不許確,算法的安全性也就保證不了了。Martin在這裏實際上是要指出分佈式算法研究中的一些基礎性問題,或者說一些常識問題,即好的分佈式算法應該基於異步模型(asynchronous model),算法的安全性不該該依賴於任何記時假設(timing assumption)。在異步模型中:進程可能pause任意長的時間,消息可能在網絡中延遲任意長的時間,甚至丟失,系統時鐘也可能以任意方式出錯。一個好的分佈式算法,這些因素不該該影響它的安全性(safety property),只可能影響到它的活性(liveness property),也就是說,即便在很是極端的狀況下(好比系統時鐘嚴重錯誤),算法頂可能是不能在有限的時間內給出結果而已,而不該該給出錯誤的結果。這樣的算法在現實中是存在的,像比較著名的Paxos,或Raft。但顯然按這個標準的話,Redlock的安全性級別是達不到的。
隨後,Martin以爲前面這個時鐘跳躍的例子還不夠,又給出了一個由客戶端GC pause引起Redlock失效的例子。以下:
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的這篇文章中,還有一個頗有見地的觀點,就是對鎖的用途的區分。他把鎖的用途分爲兩種:
最後,Martin得出了以下的結論:
Martin對Redlock算法的形容是:
neither fish nor fowl (非驢非馬)
【其它疑問】
(未完,故事太長,下半部待續)
原文出自:
http://zhangtielei.com/posts/blog-redlock-reasoning.html