在算法的分布式版本中,我們假設(shè)我們有N個Redis主節(jié)點。這些節(jié)點是完全獨立的,因此我們不使用復(fù)制或任何其他隱式協(xié)調(diào)系統(tǒng)。我們已經(jīng)描述了如何在單個實例中安全地獲取和釋放鎖。我們理所當(dāng)然地認(rèn)為,算法將使用此方法在單個實例中獲取和釋放鎖。在我們的示例中,我們設(shè)置了 N=5,這是一個合理的值,因此我們需要在不同的計算機或虛擬機上運行 5 個 Redis 主節(jié)點,以確保它們以一種基本獨立的方式失敗。
為了獲取鎖,客戶端執(zhí)行以下操作:
- 它獲取當(dāng)前時間(以毫秒為單位)。
- 它嘗試按順序獲取所有 N 個實例中的鎖,在所有實例中使用相同的鍵名和隨機值。在步驟 2 中,在每個實例中設(shè)置鎖定時,客戶端使用與總鎖定自動釋放時間相比較小的超時來獲取它。例如,如果自動釋放時間為 10 秒,則超時可能在 ~ 5-50 毫秒范圍內(nèi)。這可以防止客戶端長時間試圖與已關(guān)閉的Redis節(jié)點通信:如果實例不可用,我們應(yīng)該盡快嘗試與下一個實例通信。
- 客戶端通過從當(dāng)前時間中減去步驟 1 中獲得的時間戳來計算獲取鎖所經(jīng)過的時間。當(dāng)且僅當(dāng)客戶端能夠在大多數(shù)實例(至少 3 個)中獲取鎖,并且獲取鎖所經(jīng)過的總時間小于鎖的有效時間,則該鎖被視為已獲取。
- 如果已獲取鎖,則其有效性時間被視為初始有效時間減去經(jīng)過的時間,如步驟 3 中計算的那樣。
- 如果客戶端由于某種原因未能獲取鎖定(它無法鎖定 N/2+1 個實例或有效期為負(fù)數(shù)),它將嘗試解鎖所有實例(甚至是它認(rèn)為無法鎖定的實例)。
算法是異步的嗎?
該算法依賴于以下假設(shè):雖然進(jìn)程之間沒有同步時鐘,但每個進(jìn)程中的本地時間以大致相同的速率更新,與鎖的自動釋放時間相比,誤差幅度很小。這個假設(shè)與現(xiàn)實世界的計算機非常相似:每臺計算機都有一個本地時鐘,我們通??梢砸揽坎煌挠嬎銠C來具有很小的時鐘漂移。
在這一點上,我們需要更好地指定我們的互斥規(guī)則:只要持有鎖的客戶端在鎖有效時間內(nèi)(如步驟3中獲得的那樣)終止其工作,減去一些時間(只需幾毫秒,以補償進(jìn)程之間的時鐘漂移),它才能得到保證。
本文包含有關(guān)需要綁定時鐘漂移的類似系統(tǒng)的更多信息:Leases:分布式文件緩存一致性的高效容錯機制。
失敗時重試
當(dāng)客戶端無法獲取鎖時,它應(yīng)該在隨機延遲后重試,以便嘗試取消同步多個客戶端,嘗試同時獲取同一資源的鎖(這可能會導(dǎo)致沒有人獲勝的裂腦情況)。此外,客戶端在大多數(shù) Redis 實例中嘗試獲取鎖的速度越快,裂腦情況的窗口就越?。ú⑶倚枰卦嚕?,因此理想情況下,客戶端應(yīng)嘗試使用多路復(fù)用同時將 SET 命令發(fā)送到 N 個實例。
值得強調(diào)的是,對于未能獲取大多數(shù)鎖的客戶端來說,盡快釋放(部分)獲取的鎖是多么重要,這樣就不需要等待密鑰到期才能再次獲取鎖(但是,如果發(fā)生網(wǎng)絡(luò)分區(qū)并且客戶端不再能夠與 Redis 實例通信, 在等待密鑰過期時需要支付可用性罰款)。
釋放鎖
釋放鎖很簡單,無論客戶端是否認(rèn)為它能夠成功鎖定給定實例,都可以執(zhí)行。
安全論據(jù)
算法安全嗎?讓我們來看看在不同場景中會發(fā)生什么。
首先,我們假設(shè)客戶端能夠在大多數(shù)實例中獲取鎖。所有實例都將包含一個具有相同生存時間的密鑰。但是,密鑰是在不同的時間設(shè)置的,因此密鑰也會在不同的時間過期。但是,如果在時間 T1(我們在聯(lián)系第一臺服務(wù)器之前采樣之前采樣的時間)將第一個鍵設(shè)置為最差,而在時間 T2(我們從最后一個服務(wù)器獲得回復(fù)的時間)將最后一個鍵設(shè)置為最差,則我們確信集中第一個過期的密鑰將至少存在 。所有其他密鑰稍后將過期,因此我們確信至少這次將同時設(shè)置這些密鑰。MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
在設(shè)置大多數(shù)密鑰期間,另一個客戶端將無法獲取鎖,因為如果 N/2+1 密鑰已存在,則 N/2+1 SET NX 操作無法成功。因此,如果獲得了鎖,則不可能同時重新獲得它(違反互斥屬性)。
但是,我們還希望確保嘗試同時獲取鎖的多個客戶端無法同時成功。
如果客戶端鎖定大多數(shù)實例的時間接近或大于鎖定最大有效時間(我們基本上用于 SET 的 TTL),它將認(rèn)為鎖定無效并將解鎖實例,因此我們只需要考慮客戶端能夠在小于有效時間的時間內(nèi)鎖定大多數(shù)實例的情況。在這種情況下,對于上面已經(jīng)表達(dá)的參數(shù),因為任何客戶端都不應(yīng)該能夠重新獲取鎖。因此,僅當(dāng)鎖定大多數(shù)實例的時間大于 TTL 時間時,多個客戶端才能同時鎖定 N/2+1 個實例(“時間”是步驟 2 的結(jié)束),從而使鎖定無效。MIN_VALIDITY
活潑性參數(shù)
系統(tǒng)活動性基于三個主要功能:
- 自動釋放鎖(因為鑰匙過期):最終鑰匙再次可供鎖定。
- 事實上,通常,當(dāng)未獲取鎖或獲取鎖并終止工作時,客戶端將合作移除鎖,因此我們可能不必等待密鑰過期即可重新獲取鎖。
- 事實上,當(dāng)客戶端需要重試鎖時,它等待的時間比獲取大多數(shù)鎖所需的時間要長得多,以便從概率上使資源爭用期間的裂腦條件變得不太可能。
但是,我們支付的可用性損失等于網(wǎng)絡(luò)分區(qū)上的TTL時間,因此,如果有連續(xù)分區(qū),我們可以無限期地支付此罰款。每次客戶端獲取鎖并在能夠刪除鎖之前進(jìn)行分區(qū)時,都會發(fā)生這種情況。
基本上,如果存在無限連續(xù)的網(wǎng)絡(luò)分區(qū),則系統(tǒng)可能會在無限長的時間內(nèi)變得不可用。
性能、崩潰恢復(fù)和同步
許多使用 Redis 作為鎖服務(wù)器的用戶在獲取和釋放鎖的延遲以及每秒可以執(zhí)行的獲取/釋放操作數(shù)方面都需要高性能。為了滿足這一要求,與N Redis服務(wù)器對話以減少延遲的策略肯定是多路復(fù)用(將套接字置于非阻塞模式,發(fā)送所有命令,稍后讀取所有命令,假設(shè)客戶端和每個實例之間的RTT相似)。
但是,如果我們想以崩潰恢復(fù)系統(tǒng)模型為目標(biāo),則關(guān)于持久性還有另一個考慮因素。
基本上,為了看到這里的問題,讓我們假設(shè)我們配置Redis時根本沒有持久性??蛻舳嗽?5 個實例中的 3 個實例中獲取鎖??蛻舳四軌颢@取鎖的其中一個實例重新啟動,此時我們可以為同一資源鎖定3個實例,而另一個客戶端可以再次鎖定它,這違反了鎖的獨占性的安全屬性。
如果我們啟用AOF持久性,事情將會有所改善。例如,我們可以通過向服務(wù)器發(fā)送 SHUTDOWN 命令并重新啟動它來升級服務(wù)器。由于 Redis 過期在語義上是實現(xiàn)的,因此當(dāng)服務(wù)器關(guān)閉時,時間仍然會過去,因此我們所有的要求都很好。但是,只要它是干凈關(guān)閉,一切都很好。停電怎么辦?如果 Redis 配置為(默認(rèn)情況下)每秒在磁盤上同步一次,則重新啟動后,我們的密鑰可能會丟失。從理論上講,如果我們想在面對任何類型的實例重啟時保證鎖定安全,我們需要在持久性設(shè)置中啟用。由于額外的同步開銷,這將影響性能。fsync=always
然而,事情比乍一看要好?;旧?,只要實例在崩潰后重新啟動,它就不再參與任何當(dāng)前活動的鎖定,算法安全性就會保留。這意味著實例重新啟動時的當(dāng)前活動鎖定集都是通過鎖定重新加入系統(tǒng)的實例以外的實例而獲得的。
為了保證這一點,我們只需要在崩潰后使一個實例不可用,至少比我們使用的最大TTL多一點。這是實例崩潰時存在的有關(guān)鎖的所有密鑰變得無效并自動釋放所需的時間。
使用延遲重啟,即使沒有任何可用的Redis持久性,基本上也可以實現(xiàn)安全,但請注意,這可能會轉(zhuǎn)化為可用性損失。例如,如果大多數(shù)實例崩潰,系統(tǒng)將全局不可用 TTL(此處全局意味著在此期間根本沒有資源可鎖定)。
使算法更可靠:擴(kuò)展鎖
如果客戶端執(zhí)行的工作由小步驟組成,則默認(rèn)情況下可以使用較小的鎖定有效期,并擴(kuò)展實現(xiàn)鎖定擴(kuò)展機制的算法?;旧?,如果客戶端在計算過程中鎖定有效性接近較低值,則可以通過將Lua腳本發(fā)送到所有實例來擴(kuò)展鎖定,該實例擴(kuò)展密鑰的TTL(如果密鑰存在并且其值仍然是客戶端在獲取鎖定時分配的隨機值)。
只有當(dāng)客戶端能夠?qū)㈡i擴(kuò)展到大多數(shù)實例中,并且在有效時間內(nèi)(基本上要使用的算法與獲取鎖時使用的算法非常相似),才應(yīng)考慮重新獲取鎖。
但是,這在技術(shù)上不會更改算法,因此應(yīng)限制鎖定重新獲取嘗試的最大次數(shù),否則會違反其中一個活動屬性。