[翻譯]基於redis的分散式鎖

飛來來發表於2018-12-02

本篇翻譯自【redis.io/topics/dist…

在很多不同程式必須以相互排斥的方式競爭分片資源的情況下,分散式鎖是非常有用的原始功能。

有很多的實現和部落格都描述瞭如何基於Redis來實現分散式鎖管理器(DLM,Distributed Lock Manager)。有的使用了不同的途徑,但是大多都是使用相同的簡單方案,與複雜的設計相比,下面這種官方的方案用更低的保證度來實現分散式鎖。官方把它稱作是更加規範的分散式鎖的實現方案,也就是所謂的RedLock。

實現

現在已經有很多的基於Redis的鎖實現,比如:

  • Redlock-rb (Ruby).
  • Redlock-py (Python).
  • Aioredlock (Asyncio Python).
  • Redlock-php (PHP).
  • PHPRedisMutex (further PHP)
  • cheprasov/php-redis-lock (PHP)
  • Redsync.go (Go).
  • Redisson (Java).
  • Redis::DistLock (Perl).
  • Redlock-cpp (C++).
  • Redlock-cs (C#/.NET).
  • RedLock.net (C#/.NET).
  • ScarletLock (C# .NET使用可配置的資料庫儲存來實現的)
  • node-redlock (NodeJS).

保證更加安全和靈活

RedLock設計有三個原則,這三個原則在RedLock的設計者看來是有效的分散式鎖的最低要求。

  • 安全屬性:保證互斥,任何時候都只有一個客戶端持有鎖
  • 效率屬性1:不會死鎖。即使鎖定資源的客戶端崩潰或者被隔離分割槽,也要能夠獲得鎖。
  • 效率屬性2:容錯。只要大多數節點還在執行,那麼客戶端就能繼續獲得和釋放鎖

基於故障轉移(failover-based)的方案是不夠的

想理解官方的分散式鎖方案原理,就得了解現有的分散式鎖的實現方式。

最簡單的方式是在單例項中使用Redis鎖住一個建立的key,這個key通常是有存活時間限制的,使用的是redis的expires特性,所以這種鎖最終都會釋放(滿足效率屬性2)。當客戶端需要釋放資源,就刪除這個key。

很容易理解的方案,但是有個問題。這是單點架構,如果Redis的master節點掛了,會發生什麼呢?當然你可能會使用新增slave的方法來解決這個問題。但是這是無效的,因為這種情況下無法保證互斥。原因是Redis的副本複製是非同步的。

這個模型有明顯的競爭條件:

  • 1.客戶端A的鎖在master中
  • 2.在master向slave同步傳輸資料的時候master崩潰了
  • 3.slave升級成為master
  • 4.客戶端B在獲取相同資源的鎖時可以正常獲取(客戶端A和B同時獲取了鎖,違反了安全性)

當然如果你允許兩個客戶端同時持有鎖,那麼這種方案是可行的。否則的話建議使用官方推薦的分散式鎖方案。

單點的正確實現

在嘗試克服上述單例項的限制之前,這裡會用一個簡單的例子來檢查如何完成。 在頻繁的競爭條件的應用中,這也是被接收的方案。因為在單例項中的鎖也是我們使用分散式演算法的基礎。

為了獲得鎖,可以這麼做:

SET resource_name my_random_value NX PX 30000複製程式碼

這個命令會在一個值不存在的情況下設定一個key(NX),這個key會存在30000毫秒(PX)。這個key設定了一個值“myrandomvalue”。這個值針對所有客戶端和鎖必須是唯一的。隨機數是以安全方式分發鎖的的基礎,這通過指令碼傳達給redis:如果存在這個key,那麼就移除這個key,然後儲存這個value則是我期望的效果。如果我使用Lua指令碼實現,將會是:

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

為了避免移除其他客戶端建立的鎖,這是非常必要的。比如一個客戶端獲得了鎖,然後在一個類似操作中阻塞了很長時間,而這段時間超過了合法的時間(在這段時間中key已經過期了),隨後移除這個被其他的客戶獲得的鎖。對於一個客戶端來說只是用刪除操作也許是刪除了其他客戶端所持有的鎖。使用上面的指令碼,那麼每個鎖都是隨機字串簽名的。所以只有在這個這個客戶端嘗試刪除鎖時,這個鎖才會被刪除。

和這個隨機的字串是怎麼產生的呢?我假設這個隨機字串是從/dev/urandom中取得20個byte,但是你也能用簡易的方法生成唯一的字串。比如使用/dev/urandom作為RC4的隨機種子,然後生成一個偽隨機流。一個更簡單的方法是使用一個unix的毫秒數和客戶端id的組合,但這是不安全的,但是能適應大多數環境。

我們開始設定這個key的存活時間,叫做“鎖的生效時間”。這個時間既是鎖的自動釋放時間,也是其他客戶端能夠再次獲得鎖之前已經獲得鎖的客戶端能夠執行操作的時間。這種情況沒有在技術上違反互斥保證,只是限制了獲得鎖的時間視窗。

所以我們現在有個不錯的方法來獲得和釋放鎖。現在這個非分散式的單點系統,總是可用的,並且安全的。讓我們將這種概念擴充套件到無保護的分散式系統中。

Redlock演算法

在分散式演算法中,我們假設有N個redis的master。這些node相互獨立,所以我不用複製,也不用任何做任何隱式的協同操作。現在已經在單節點中定義瞭如何獲得和釋放鎖。我們使用演算法保證了我們在單節點中鎖的獲得與釋放不會衝突。在我們的例子中,我們假設N等於5,這是個合理的值,所以我們需要在不同的機器上執行5臺redis,這也是為了儘可能保證節點相互獨立。

為了獲得鎖,客戶端需要做如下操作:

  • 1.獲得當前時間的毫秒數
  • 2.使用相同的key和不同的隨機字串作為value,嘗試在N個節點中順序獲得鎖。在步驟2中,當在一個節點中設定了鎖,那麼客戶端為了能獲得他,將會使用一個總的鎖定時間相比較小的時間作為超時時間。比如自動釋放的時間是10秒,那麼超時時間為5-50毫秒。這主要是為了防止在節點down了後,長時間嘗試獲得鎖時的阻塞。如果節點不可用,我們應該嘗試儘快與下一個節點互動。
  • 3.客戶端通過從當前時間中減去在步驟1中獲得的時間戳來計算獲取鎖定所需的時間。當且僅當客戶端能夠在大多數例項中獲取鎖定時(至少3個)並且獲取鎖定所經過的總時間小於鎖定有效時間,認為鎖定被獲取。
  • 4.如果獲得了鎖,則其有效時間被認為是初始有效時間減去經過的時間,如步驟3中計算的。
  • 5.如果客戶端由於某種原因(無法鎖定N / 2 + 1例項或有效時間為負)無法獲取鎖定,它將嘗試解鎖所有例項(即使它認為不是能夠鎖定)。

演算法是非同步的嗎?

該演算法依賴於這樣的假設:雖然跨過程沒有同步時鐘,但每個程式中的本地時間仍然大致以相同的速率流動,其中錯誤與鎖的自動釋放時間相比較小。這個假設非常類似於真實世界的計算機:每臺計算機都有一個本地時鐘,我們通常可以依靠不同的計算機來獲得較小的時鐘漂移。

此時我們需要更好地指定我們的互斥規則:只要持有鎖的客戶端將在鎖定有效時間內(在步驟3中獲得)終止其工作,減去一些時間(僅幾毫秒),它就得到保證為了補償程式之間的時鐘漂移)。 有關需要繫結時鐘漂移的類似系統的更多資訊,

這裡有個有趣的參考:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency[dl.acm.org/citation.cf…]

重試失敗

當客戶端無法獲取鎖定時,它應該在隨機延遲之後再次嘗試,以嘗試同步多個客戶端,同時嘗試獲取同一資源的鎖定(這可能會導致大腦分裂情況,因為無人能夠選取成功)。此外,客戶端嘗試在大多數Redis例項中獲取鎖定的速度越快,分裂大腦條件的視窗越小(並且需要重試),因此理想情況下客戶端應嘗試將SET命令傳送到N個例項同時使用多路複用。

值得強調的是,對於未能獲得大多數鎖定的客戶來說,儘快釋放(部分)獲取的鎖定是多麼重要,因此無需等待金鑰到期以便再次獲取鎖定(但是,如果發生網路分割槽且客戶端無法再與Redis例項通訊,則在等待金鑰到期時需要支付可用性懲罰。

釋放鎖

釋放鎖是很簡單的,只需在所有例項中釋放鎖,無論客戶端是否認為它能夠成功鎖定給定例項。

安全論點

演算法安全嗎?我們可以嘗試瞭解不同場景中會發生什麼。 首先讓我們假設客戶端能夠在大多數情況下獲取鎖。所有例項都將包含一個生存時間相同的金鑰。但是,金鑰設定在不同的時間,因此金鑰也將在不同的時間到期。但是如果第一個金鑰在時間T1設定為最差(我們在聯絡第一個伺服器之前取樣的時間),並且最後一個金鑰在時間T2(我們從最後一個伺服器獲得回覆的時間)設定為最差,我們確定在集合中到期的第一個金鑰將至少存在MIN_VALIDITY = TTL-(T2-T1)-CLOCK_DRIFT。所有其他金鑰將在稍後過期,因此我們確信金鑰將至少同時設定為此時間。

在那段時間裡,如果設定了大多數金鑰,則另一個客戶端將無法獲取鎖定,因為如果已存在N / 2 + 1金鑰,則N / 2 + 1 SET NX操作將無法成功。 因此,如果獲得鎖定,則無法同時重新獲取鎖定(違反互斥屬性)。

但是,我們還希望確保同時嘗試獲取鎖的多個客戶端不能同時成功。

如果客戶端使用的時間接近或大於鎖定最大有效時間(我們基本上用於SET的TTL)鎖定大多數例項,則會認為鎖定無效並將解鎖例項,因此我們只需要考慮 客戶端能夠在小於有效時間的時間內鎖定大多數例項的情況。 在這種情況下,對於上面已經表達的引數,對於MIN_VALIDITY,沒有客戶端應該能夠重新獲取鎖。 因此,多個客戶端將能夠同時鎖定N / 2 + 1個例項(“時間”為結束 步驟2)只有當鎖定多數的時間大於TTL時間時,才使鎖定無效。

爭論點

系統活躍度基於三個主要特徵:

  • 1.鎖的自動釋放(因為金鑰到期):最終可以再次鎖定金鑰。
  • 2.通常情況下,客戶通常會在未獲取鎖定時或在獲取鎖定並且工作終止時合作移除鎖定,這使得我們可能不必等待金鑰到期以重新獲取鎖。
  • 3.事實上,當客戶端需要重試鎖定時,它等待的時間比獲取大多數鎖定所需的時間要大得多,以便在資源爭用期間概率地分裂大腦條件。

但是,我們在網路分割槽上付出的可用性懲罰等於TTL時間,因此如果有連續分割槽,我們可以無限期地付出此懲罰時間。每次客戶端獲取鎖定並在能夠刪除鎖定之前進行分割槽時,都會發生這種情況。 基本上如果有無限連續的網路分割槽,那麼 系統可能無法在無限的時間內使用。

效能,崩潰恢復和fsync

使用Redis作為鎖服務的許多使用者在獲取和釋放鎖的延遲以及每秒可執行的獲取/釋放操作的數量方面需要高效能。為了滿足這一要求,與N個Redis伺服器通訊以減少延遲的策略肯定是多路複用(或者是妥協的多路複用,即將套接字置於非阻塞模式,傳送所有命令,並讀取所有命令稍後,假設客戶端和每個例項之間的RTT相似)。

但是,如果我們想要定位崩潰恢復系統模型,還有另一個考慮永續性的問題。

基本上要看到這裡的問題,讓我們假設我們根本沒有永續性配置Redis。客戶端在5個例項中的3箇中獲取鎖定。重新啟動客戶端能夠獲取鎖的例項之一,此時還有3個例項可以鎖定同一資源, 並且另一個客戶可以再次鎖定它,違反了鎖定的安全性。

如果我們啟用AOF永續性,事情會有所改善。 例如,我們可以通過傳送SHUTDOWN並重新啟動它來升級伺服器。 因為Redis過期是在語義上實現的,所以當伺服器關閉時,實際上時間仍然流逝了,我們所有的需求都是實現的很好。 但是一切都很好,只要它是一個簡潔的關閉。 如果停電呢? 如果預設情況下將Redis配置為每秒在磁碟上進行fsync,則重新啟動後可能會丟失金鑰。 理論上,如果我們想要在任何型別的例項重啟時保證鎖定安全性,我們需要在永續性設定中啟用fsync = always。 這反過來將完全破壞效能,淪落到傳統上用於以安全方式實現分散式鎖的CP系統的相同級別。 然而,事情比第一眼看起來更好。 基本上演算法是安全的 只要在崩潰後例項重新啟動時,它就會保留,它不再參與任何當前活動的鎖,因此當例項重新啟動時,當前活動鎖的集合都是通過鎖定除重新加入例項之外的例項獲得的系統。

為了保證這一點,我們只需要在崩潰後建立一個例項,至少比我們使用的最大TTL多一點,也就是說,例項崩潰時如果有獲取所有鎖所需的時間,則鎖變為無效並自動釋放。

使用延遲重啟基本上可以實現安全性,即使沒有任何可用的Redis永續性,但請注意,這可能轉化為可用性懲罰。 例如,如果大多數例項崩潰,系統將變為全域性不可用於TTL(這裡全域性意味著在此期間根本沒有資源可鎖定)。

使演算法更可靠:擴充套件鎖定

如果客戶端執行的工作由小步驟組成,則預設情況下可以使用較小的鎖定有效時間,並擴充套件實現鎖定擴充套件機制的演算法。基本上,客戶端如果在鎖定有效性接近低值的情況下處於計算中間,則可以通過向所有擴充套件金鑰的TTL的例項傳送Lua指令碼來擴充套件鎖定,如果金鑰存在且其值仍然是獲取鎖定時客戶端分配的隨機值。 如果能夠將鎖擴充套件到大多數例項,並且在有效時間內,基本上使用的演算法與獲取鎖時使用的演算法非常相似。

客戶端應該只考慮重新獲取的鎖。 但是這在技術上不會更改演算法,因此應限制鎖定重新獲取嘗試的最大次數,否則會違反其中一個活動屬性。

來源:https://juejin.im/post/5c03f07c518825741e7be631

相關文章