用Redlock構建Redis分佈式鎖

因爲不一樣的進程都必須在排他的方式操做共享資源,分佈式鎖在不少環境中是很是有用的基礎組件。node

有不少庫和博客都介紹了怎麼用Redis來實現分佈式鎖管理器,可是每一個庫都有不一樣的設計理念,不少只是用了很小的一個方向,相比於略微複雜一點的設計都不能保證。算法

如今我嘗試提供一個更權威的算法來實現Redis分佈式鎖。咱們提出一個算法,名叫Redlock,咱們相信它是比通常單例實現方式更加安全地實現了分佈式鎖管理器。咱們但願Redis社區可以分析它,提供反饋,而且把它做爲一個實現更加複雜和可供選擇的設計的一個出發點。api

儘管已經有了10個以上Redlock的獨立實現,咱們不知道哪一個依賴這個算法,我仍是認爲把個人筆記分享出來是頗有意義的。因爲Redis已經在不少別的地方被屢次提到了,因此我不許備在這裏討論他了。數組

在我準備詳細講述Redlock以前,我想說我十分喜歡Redis,並且以前也成功地在生產環境中應用了。我認爲若是你想在服務器之間共享一些生命期比較短,類似而且快速變化的數據,並且對於偶爾無論什麼緣由的丟失數據不太敏感的話,那麼我建議你使用他。好比,一個好的用法是保留每一個IP地址的請求數,和不一樣IP的用戶ID。安全

然而,Redis也開始向着須要保證強一致性和持久化需求的數據管理的領域進軍。這一點困惑住我了,由於Redis起初是否是爲了這個目的設計的。能夠論證,分佈式鎖就是這些領域中的一種。讓咱們詳細去檢驗。服務器

你用那個鎖來作什麼?網絡

鎖的目的是保證在不一樣的節點間執行相同的任務,只有一個成功。這個任務多是向一個共享的存儲系統中寫入數據,進行某些計算,調用一些外部的API接口,或者別的。在較高層次分析,在分佈式應用中有兩個緣由是你想讓鎖去完成的:效率或者正確性。爲了區別這些狀況,你能夠試着想象鎖失敗會致使什麼後果:多線程

效率:併發

加鎖能夠不用你去把一件事作兩次(好比一些複雜計算)。若是鎖失敗了,兩個節點最終作了同一件事,結果就會在花銷上有略微的增長或者一些不適當的。異步

正確性:

加鎖阻止了線程的不一樣步和系統狀態的混亂。若是鎖失敗而且兩個節點在同一份數據上同步工做,結果就將致使產生一個損壞的文件,數據丟失,永久不一致。一個病人服用了錯誤劑量的藥物,或者其餘一些比較嚴重的問題。

二者都是獲取鎖時會遇到的情形,可是你必須很是清楚地分辨出你正在處理的是哪一種。

我認爲若是你只是出於保證效率來使用鎖,那麼使用Redlock而帶來的花銷和複雜度會令你望而卻步。運行五個Redis服務而且花費大量時間去檢查是否獲取到你的鎖。你最好不要僅僅使用一個Redis實例,以確保在宕機狀況下能夠經過異步複製的方式將數據複製到從節點。

若是你使用了單個Redis實例,當這個節點忽然斷電或者其餘什麼故障發生的時候就會釋放鎖。可是若是你只是將鎖用於優化效率的話,宕機也不會常常發生,那就沒大毛病。這個「沒多大毛病」的情景是Redis的一個亮點。至少若是你依賴單個Redis節點,每一個進入系統的人都是看到相同的,這隻用於極少數的情形。

另一個方面,Redlock算法,重大選舉和5個節點的複製,看起來更加適合正確性的選擇。我認爲在下面這些情形中都不大適合這個目的。本文的餘下部分咱們主要討論你的鎖在分佈式事務中怎樣保證正確性的,若是兩個不一樣節點併發地持有同一個鎖,這將是一個嚴重的bug。

使用鎖保護資源

讓咱們先不討論Redlock的特別之處,讓咱們先看下一個分佈式鎖一般是怎麼用的吧。記住,分佈式系統中的鎖不一樣於多線程應用中的mutex(互斥鎖)。他比那個要來的複雜,歸因於不一樣的節點和網絡會以不一樣的方式失敗而產生的問題。

舉個例子,假如說你有一個系統,他的客戶端須要更新共享存儲(如HDFS或S3)中的文件,不一樣之處在於,回寫更改的文件,最終釋放鎖。鎖同時阻止了會致使丟失更新的兩個客戶端讀寫循環。代碼以下所示:

// THIS CODE IS BROKEN
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) {
        throw 'Failed to acquire lock';
    }

    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

不幸的是,儘管你有個表現不錯的鎖服務,這段代碼依舊是有問題的。下面的圖標展現了你是怎樣致使數據混亂的:

在這個例子中,得到鎖的客戶端當持有鎖的時候中止了一大段時間——可能因爲GC在進行。鎖有一個延遲,或許也老是一個不錯的主意。然而,若是GC持續的時間超過釋放的過時時間,客戶端不會認爲它過時了,它會繼續運行,併產生不安全的狀態更改。

這個bug不只僅停留在理論:HBase常常有這種問題。一般GC是很短的。可是「stop the world」GC有時候會持續幾分鐘——固然要比釋放過時長的多。甚至所謂的線程垃圾收集器,好比HotSpot JVM的CMS不能徹底運行在並行條件下。甚至她們須要反覆地stop the world。

你不可以在將數據寫回到存儲以前在鎖過時期間加入檢查來修復這個問題。記住GC能夠在任什麼時候候阻斷一個運行中的線程,包括回最大限度形成不便的點。

若是你會由於本身的程序在運行階段不會發生長時間的GC停留而沾沾自喜,別高興太早,由於仍然會有別的緣由致使中止。可能你的進程想要去讀取一個還沒有寫入內存的地址數據,它就會獲得一個錯誤的頁面,停下來直到頁面從磁盤中加載出來。若是你的磁盤確實是EBS(快存儲),讀取一個無心而可變的數據致使Amazon的同步網絡請求阻塞。可能有不少進程搶佔CPU,你命中了你的任務樹中的一個黑色節點(算法基於紅黑樹)。可能一些時間發送給進程SIGSTOP。這樣的話你的進程遊可能會掛掉。

假如你仍舊不相信進程會中止的事實,請換位思考下,文件寫請求在到達存儲服務以前會在網絡中會發生延遲。報文相似廣域網和IP都會果斷延遲發送包,在GitHub的設計中,網絡包延遲大約是90秒。這意味着一個應用進程會發生寫請求,它會在當釋放過時一分鐘以後到達存儲服務器。

甚至在一個管理良好的網絡中,這種事情也會發生。你無法對延遲作一些假設,也是爲何上面的代碼基本上是不安全的,無論你用了怎樣的鎖服務。

使用柵欄讓鎖變得安全

這個問題解決起來也比較簡單:你須要在每一個向存儲服務的寫請求中加入柵欄token。在這篇文章中,一個柵欄token既是當客戶端請求鎖時遞增的數字。下面這張圖說明了這點:

client1須要釋放和獲取編號爲33的token,可是以後它進入了一個長時間的停滯,而後釋放超期。Client2須要釋放鎖,獲取了編號爲34的token,而後發送它的寫請求道存儲服務商,包括34token。而後,client1恢復活躍而且發送它的寫請求到存儲服務商,包括33token。然而,存儲服務器記錄了34token,因此拒絕了33token。

注意到這樣須要存儲服務器作一個動態的檢查tokens的操做,阻止往回寫的token。可是當你知道了這個套路以後就以爲不是特別的難。提供的鎖服務產生了嚴格遞增的tokens,這樣使得鎖變的安全。好比說,若是你把ZooKeeper看成鎖服務來用,你可使用zxid或者znode版本號看成柵欄token,這樣你就作的很好了。

然而,這樣致使你用Redlock時遇到一個大問題:它沒有產生柵欄tokens的能力。這個算法不能產生確保每時每刻提供給客戶端鎖的數組。這意味着儘管這個算法時很是不錯的,可是使用它不是很安全,由於你不能阻止客戶端之間的運行條件。當一個客戶端停滯或者包延遲的情形。

若是某人修改Redlock算法來產生柵欄tokens我也不會感到很稀奇。這個惟一的隨機數值不能提供須要的單調性。僅僅保證在Redis節點上計數器是不充分的,由於這個節點有可能掛掉。保證在不一樣節點上的計數器都正常代表他們可能不是同步的。因此你可能須要一個一致性算法來產生柵欄tokens。

花時間去解決一致性

事實上Redlock在產生柵欄tokens的時候老是失敗也稱爲它不該該應用在對鎖的正確性要求很高的業務場景中。可是有更須要討論的問題存在着。

在學術文獻中,這種算法最具實踐系統模型時不可靠失敗檢測的異步模型。英文字面上講,就是這個算法對時間不敏感:進程可能會隨意中止一段時間,包也會隨機性地發生網絡延時,鎖會失敗——儘管如此這個算法仍是但願去作正確的事。基於咱們上面所述,仍是有很是有理由的證據的。

算法使用鎖的惟一願望事產生延遲,避免節點宕機致使的無限時的等待。可是延遲事不精確的:只是由於一個請求延遲,不意味着其餘節點會掛掉——在網絡中產生大的延遲倒還好,也許是本地時鐘出問題了。當用來作失敗檢測的時候,延遲是斷定出錯的。

記得Redis使用getTimeOfDay api,而不是monotonic clock,去檢查過時的keys。getTimeOfDay的幫助頁明確說明了它返回給在系統時間上是不連續跳躍的——意味着,他會時隔幾分鐘忽然跳躍變化,或者及時地跳回來。如此,若是系統時鐘在作奇怪的事情,他會比想象更快或者更慢更容易發生Redis的key的延遲。

異步模型中的算法不是一個大問題:這些算法通暢證實他們的安全屬性常常維持住,沒有產生時間假定。只有存活的屬性依賴延遲或者其餘失敗檢測。用英文直譯的話就是若是系統延遲隨時發生,算法的表現就會不好,可是算法永遠不會形成錯誤的論斷。

然而,Redlock不是這樣的。他依賴於大量的時間假定:他卻包全部Redis節點可以在過時前維持keys很長一段時間;在過時延遲面前網絡延遲是個小case;進程的中止時長也比過時持續的時間要短的多。

經過壞的延遲摧毀Redlock

讓咱們看一些代表了Redlock是依賴於時間假設的例子。假設系統有5個Redis節點和兩個客戶端。若是在一個Redis節點上的時鐘跳過了會發生什麼?

1  client 1在節點A、B、C上加了鎖。因爲網絡緣由,D和E沒有可以到達。

2  在C節點上的時鐘跳過了,形成鎖過時。

3  Client 2在節點C、D、E上加了鎖。因爲網絡緣由,A和B不能到達。

4  Client 1和2都認爲他們獲取了鎖。

一個相似的問題也會發生。當C在持久化鎖道磁盤以前發生宕機,而後馬上重啓。因爲這個緣由,Redlock官方文檔推薦崩潰節點至少在長期持有鎖的過程當中延遲啓動。可是當一個理所固然的計算時間時候發生了延遲重啓,若是時鐘跳太久失敗。

好的,你可能認爲時鐘抖動是難以想象的。由於你確定很是確信正確地配置了NTP去調節是種。這種情形下,讓咱們看下一個進程的中止是怎麼樣形成這個算法失敗的例子:

1  Client 1請求了節點A,B,C,D,E上的鎖

2  當Client 1上的響應在爭奪資源,client 1將進行stop-the-world GC。

3  鎖在全部的Redis節點上過時。

4  Client 2在A,B,C,D,E上獲取鎖。

5  Client 1完成GC,收到來自Redis節點上的響應代表他獲取鎖成功了。

6  Client 1和2如今都確信他們獲取了鎖。

注意儘管Redis是用C寫的,這樣就沒有GC,任何客戶端發生GC停留的系統都會有這個問題。你只有組織Client 1在Client 2獲取鎖以後去作任何事情才能保證線程安全。好比使用上述的柵欄方法。

一個很長的網絡延遲會產生和進程中止同樣的效果。他可能受限於你的TCP用戶延遲-若是你讓延遲比Redis的帶寬還算。可能延遲的網絡包能夠忽略,可是咱們不得不仔細研究和思考TCP是怎麼樣實現來確保正確發送包的。固然,由於有延遲咱們迴歸到時間精確性的問題上來。

Redlock同步假定

這些例子展現了Redlock只有在你肯定一個同步系統模型的時候才能正確地工做-就是有下面這些屬性的系統:

綁定的網路延遲

綁定的進程中止

綁定的時鐘錯誤

注意同步模型不意味着絕對同步的時鐘:他代表你嘉定一個周知的,已經解決了網絡延遲綁定,中止和時鐘抖動。Redlock嘉定延遲,中止和抖動都是小問題;若是時間問題和時間的持久性同樣規模的話,算法就失效了。

固然在數據中心環境下,時間嘉定將適合不少場合-這被叫作半同步系統。可是他有足夠的好嗎?時間嘉定一旦失敗,Redlock就會將他的安全的屬性透明化,好比,容許在另外一個以前發佈到一個客戶端的節點就過時了。若是你認爲你的時鐘是可靠的,「大多數時間」不充分-你須要讓她老是正確的。

爲大多數版系統環境制定一個同步系統模型是不安全的。保證提醒你本身關於Github的90秒包延遲時間。

另外一個方面,一個爲半同步系統設計的一致性算法有機會工做。Raft,Viewstamped複製,Zab和Paxos在策略中都落實了。這樣的算法是用來遠離全部的時間假定的。這是困難的:保證網絡是臨時的,進程和時鐘比他們本身還要確信。可是關於分佈式系統的凌亂的認知,你不得不特別關心你的假定。

相關文章
相關標籤/搜索