以下內(nèi)容主要是從官方文檔翻譯過來,另外加了一些自己的理解褥芒。如果可以建議讀官方文檔的介紹袜蚕。在實際開發(fā)中之所以使用分布式鎖就是為了保證只有一個客戶端可以對共享資源進(jìn)行操作八毯,目前分布式鎖實現(xiàn)方式有多種,比如zookeeper焙蚓,而且據(jù)說zookeeper可靠性要比Redis強很多纹冤,只是效率偏低,這里也無意去爭論誰強誰弱购公,只是從純技術(shù)的角度來看看如何使用Redis實現(xiàn)分布式鎖萌京。
目前已有許多庫和博客文章描述了如何使用Redis實現(xiàn)分布式鎖管理器,但是每個庫都使用了不同的方法宏浩,并且許多庫使用簡單的方法與稍微復(fù)雜的方法相比其可靠性要低很多知残。
另外Java已經(jīng)有相關(guān)的實現(xiàn)方式——Redisson。
一比庄、安全性和活躍性保證
下面將僅使用三個屬性對我們的設(shè)計進(jìn)行建模求妹,從我們的角度來看,這些屬性是使用分布式鎖所需的最低保證印蔗。
1扒最、安全性:相互排斥。 在任意一個給定時刻华嘹,只有一個客戶端可以持有鎖吧趣。
2、活躍性A:無死鎖耙厚。 即使鎖定共享資源的客戶端崩潰或被分區(qū)强挫,客戶端最終也可以獲到鎖。
3薛躬、活躍性B:容錯俯渤。 只要大多數(shù)Redis節(jié)點處于運行狀態(tài),客戶端就能夠獲取和釋放鎖型宝。
為什么基于故障轉(zhuǎn)移的實現(xiàn)還不夠
為了理解RedLock算法改進(jìn)的內(nèi)容八匠,我們先了解一下目前一些基于Redis實現(xiàn)分布式鎖的狀態(tài)絮爷。
使用Redis鎖定資源最簡單的方式就是創(chuàng)建一個key,并且這個key通常會設(shè)置一個自動過期時間梨树,這樣就能夠保證這個鎖最終會被釋放掉(不會死鎖)坑夯。當(dāng)客戶端需要釋放資源的時候,刪除這個key就可以了抡四。表面上看這樣可行柜蜈,但是有一個問題:就是架構(gòu)中的單點故障,如果Redis掛掉了怎么辦指巡?大家肯定想給這個Redis添加一個從節(jié)點啊淑履,主節(jié)點掛掉就用從節(jié)點。但是這樣做是無法實現(xiàn)互斥安全性的藻雪,因為Redis的復(fù)制是異步的秘噪。
很明顯這種情況下就會出現(xiàn)競爭條件:
客戶端A在主節(jié)點獲取到了鎖
在主節(jié)點向從節(jié)點同步數(shù)據(jù)時主節(jié)點掛掉了
從節(jié)點被提升為主節(jié)點
客戶端B也獲取了此時客戶端A持有的鎖,這時候就出現(xiàn)了沖突阔涉。
在一些特殊情況下缆娃,例如在故障期間,多個客戶端同時持有鎖是完全正常的瑰排。 如果是這種情況贯要,可以使用基于復(fù)制的解決方案。 否則椭住,我們建議使用本文檔中描述的解決方案來實現(xiàn)崇渗。
使用單實例實現(xiàn)的正確方式
在嘗試克服上問所述單實例設(shè)置的限制之前,讓我們檢查如何在簡單的情況下正確地執(zhí)行它京郑,因為這在不時可以接受競爭條件的應(yīng)用中宅广,這實際上是一個可行的解決方案。并且因為鎖定到單個實例是實現(xiàn)本文實現(xiàn)分布式算法的基礎(chǔ)些举。
可以通過下面的命令來獲取一個鎖:
SET key_name random_value NX PX 30000
上面的命令如果key_name
不存在跟狱,則添加一個key (NX
選項,即if not exist)户魏,這個key的值為random_value
驶臊,設(shè)置的過期時間是30000毫秒(PX
選項,EX
選項單位則是秒)叼丑。這樣有個好處就是保證key最終會釋放关翎,即上面這步操作其實是一個原子操作。如果分步操作鸠信,比如先設(shè)置key和value纵寝,然后再設(shè)置其超時時間并不能保證key會被釋放掉。
上面命令中之所以使用隨機值也是為了以安全的方式釋放鎖星立,使用一個腳本告訴Redis:只有當(dāng)key存在且key存儲的value正是我期望的那個值時才刪除key爽茴。 下面是通過一個Lua腳本完成的:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
之前公司內(nèi)部技術(shù)分享的時候有提到過使用Lua腳本操作Redis葬凳,但是自己對這方面好像沒什么興趣,所以也就沒有去專門的研究過室奏。感興趣的話可以去專門的研究一下沮明。
為了避免刪除由其他客戶端創(chuàng)建的鎖是非常重要的。舉個簡單例子:客戶端A獲取到了一個鎖窍奋,但是其在某些操作過程中被阻塞的時間長于鎖定有效時間(key的失效時間),然后客戶端A刪除了已經(jīng)由另一個客戶端B獲取的鎖酱畅。也就是說客戶端A在獲取到其創(chuàng)建的鎖A之后持有時間過長琳袄,這時鎖A已經(jīng)超時(失效),而客戶端A這時去刪除鎖A(鎖A已經(jīng)不在了)纺酸,所以刪除的卻是客戶端B持有的鎖B.....這里客戶端B創(chuàng)建的鎖B是在鎖A超時之后窖逗,客戶端A刪除之前完成的。
因此僅使用DEL是不安全的餐蔬,因為客戶端可能會刪除另一個客戶端持有的鎖碎紊。使用上面的腳本而不是每個鎖都使用一個隨機字符串“簽名”,這樣只有在客戶端去嘗試刪除的鎖依然是它設(shè)置的鎖時樊诺,這個鎖才會被刪除仗考。這一點道理我是可以理解,但是如何判斷客戶端獲取的鎖就是它自身創(chuàng)建的鎖呢词爬?秃嗜?上面的Lua腳本里面具體的ARVG[1]
的意思我太明白。
這個隨機字符串應(yīng)該是什么顿膨?假設(shè)它是來自/ dev / urandom的20個字節(jié)锅锨,但是我們可以找到更簡單的方法來保證它在你的任務(wù)中是唯一的。比如恋沃,一個安全的方案是使用/dev/urandom對RC4進(jìn)行種子處理必搞,并從中生成偽隨機流。一個更簡單的解決方案是使用unix時間和微秒分辨率的組合囊咏,將其與客戶端的ID連接起來恕洲,這個方案不是那么的安全,但是在大多數(shù)環(huán)境中都應(yīng)該是可以完成任務(wù)的匆笤。
我們設(shè)置key的生存時間被稱為“鎖定有效時間”研侣。它既是鎖自動釋放時間,也是客戶端在不違背確迸谂酰互斥的前提下庶诡,在其他客戶端可能會再次獲取到這個鎖之前執(zhí)行相關(guān)操作所需的時間(應(yīng)該說是客戶端操作時間的上限,即不能超過鎖的有效期)咆课,這僅限于在給定的窗口期末誓,即從獲得鎖定的那一刻起的時間范圍內(nèi)扯俱。
所以現(xiàn)在我們有了獲得和釋放鎖的好方法。根據(jù)上文可見由單個喇澡、始終可用的Redis實例組成的非分布式系統(tǒng)是安全的迅栅。下面讓我們將概念擴展到?jīng)]有這種保證的分布式系統(tǒng)中。
二晴玖、Redlock算法
在算法的分布式版本中读存,我們假設(shè)有N個Redis主機。 這些節(jié)點完全獨立呕屎,并且我們不使用復(fù)制或任何其他隱式協(xié)調(diào)系統(tǒng)让簿。 在上面的內(nèi)容中我們已經(jīng)描述了如何在單個實例中安全地獲取和釋放鎖。我們理所當(dāng)然地認(rèn)為這種算法將會使用這種方法在單個實例中獲取和釋放鎖秀睛。在下面的示例中尔当,我們設(shè)置N = 5,這是一個合理的值蹂安,因此我們需要在不同的計算機或虛擬機上運行5個Redis主服務(wù)器椭迎,以確保它們以大多數(shù)獨立的方式失敗(這里我不是很理解田盈,意思是說當(dāng)大多數(shù)實例失敗的時候畜号,整個過程就算失敗了嗎?)允瞧。
為了獲取分布式鎖弄兜,客戶端需要執(zhí)行以下操作:
1、獲取當(dāng)前時間的毫秒值瓷式。
2替饿、嘗試按順序獲取所有N個實例中的鎖,在所有實例中使用相同的key名稱和隨機值贸典。在這個過程中视卢,當(dāng)在每個Redis實例中設(shè)置鎖時,客戶端使用一個與總的自動釋放時間相比較小的超時時間值來獲取它廊驼。比如据过,設(shè)置的總的自動釋放時間是10秒,那么超時可以設(shè)置在5~50毫秒范圍內(nèi)妒挎。這個超時時間是針對每一個實例的绳锅,主要為了防止客戶端長時間阻塞,即試圖與一個不可用的Redis節(jié)點進(jìn)行通信酝掩。這種情況下應(yīng)該及時停止鳞芙,并嘗試盡快與下一個實例進(jìn)行通信。其實這個還是很好理解的,就是為了及時止損原朝,因為總的超時時間是固定的驯嘱,如果某沒能從某節(jié)點獲取到對應(yīng)的鎖,應(yīng)該及時放棄喳坠,趕快獲取下個實例的鎖鞠评,以避免整個過程阻塞,其實只要保證能獲取到一半以上實例的鎖就當(dāng)這個鎖定成功了壕鹉。
3剃幌、客戶端通過從當(dāng)前時間中減去在步驟1中獲得的時間戳來計算獲取鎖所需總的時間。當(dāng)且僅當(dāng)客戶端能夠在大多數(shù)實例中獲取到鎖時(假設(shè)共5個實例晾浴,那么至少要獲取3個)并且獲取這些鎖所經(jīng)過的總時間小于鎖的有效時間锥忿,那么認(rèn)為鎖定被獲取。也就是說判斷獲取分布式鎖有沒有成功只需要有兩個保證怠肋,一、保證獲取到一半以上Redis實例中的鎖淹朋;二笙各、保證整個過程(獲取分布式鎖)的時間要小于其初始的有效時間。
4础芍、如果獲得了鎖杈抢,那么這個鎖實際的其有效時間是初始有效時間減去在每個Redis實例上花費的時間,如步驟3中計算的那樣仑性。
5惶楼、如果客戶端由于某種原因無法獲取到鎖定N / 2 + 1個實例的鎖或分布式鎖的實際有效時間為負(fù),那么則表明沒有獲取到分布式鎖诊杆,這時它將嘗試釋放所有的Redis實例(即使是它沒有獲取到鎖的實例)歼捐。
通過上面這些步驟我覺得應(yīng)該基本上能夠理解Redis實現(xiàn)分布式鎖的方法了,當(dāng)然嚴(yán)謹(jǐn)一點的話還應(yīng)該減去時鐘漂移晨汹。
算法是否異步豹储?
該算法依賴于以下前提條件:盡管跨進(jìn)程沒有同步時鐘,但每個進(jìn)程中的本地時間仍然大致以相同的速率流動淘这,其中錯誤與鎖的自動釋放時間相比較小剥扣。 這個假設(shè)與現(xiàn)實世界的計算機非常相似:每臺計算機都有一個本地時鐘,我們通陈燎睿可以依靠不同的計算機來獲得較小的時鐘漂移钠怯。
此時我們需要更好地指定我們的互斥規(guī)則:只要持有鎖的客戶端將在鎖定有效時間內(nèi)(上面步驟3中獲得)終止其工作,減去一些時間(僅幾毫秒曙聂,它就得到保證 為了補償進(jìn)程之間的時鐘漂移)晦炊。
一般來講可以忽略時鐘漂移,因為其相對鎖的超時時間很短,索引我們可以近似看作這個算法是同步的刽锤。但是從一個更嚴(yán)謹(jǐn)?shù)慕嵌瓤剂磕鞒撸瑫r鐘漂移不能忽略,尤其是服務(wù)器距離很遠(yuǎn)的情況下并思。
有關(guān)需要綁定時鐘漂移的類似系統(tǒng)的更多信息庐氮,本文是一個有趣的參考:
Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
失敗重試機制
當(dāng)客戶端無法獲取鎖時,它應(yīng)該在一個隨機延遲后再次嘗試宋彼,以避免多個客戶端嘗試在同一時間獲取同一資源的鎖情況(這可能會導(dǎo)致腦裂情況)弄砍。此外,一個客戶端嘗試在大多數(shù)Redis實例中獲取鎖的速度越快输涕,發(fā)生腦裂情況的可能性就越幸羯簟(并且需要重試),所以理想情況下客戶端應(yīng)嘗試使用多路復(fù)用的方式將SET命令同時發(fā)送到N個Redis實例 莱坎。
需要強調(diào)的是衣式,對于沒能獲得大多數(shù)鎖的客戶端來說,及時釋放(部分)獲取的鎖是非常非常重要的檐什,這樣就沒必要再等待key到期碴卧,然后再去獲取鎖(但是如果發(fā)生網(wǎng)絡(luò)分區(qū)且客戶端無法再與Redis實例通信,則在等待key到期時需要支付可用性懲罰)乃正,說白了住册,及時釋放鎖能更加節(jié)省時間。
釋放鎖
釋放鎖是很簡單的瓮具,只需在所有的實例中釋放鎖即可荧飞,無論客戶端是否獲取到該實例的鎖。
安全性討論
這個算法安全嗎名党?我們可以嘗試了解一下不同場景中會發(fā)生什么叹阔。
首先假設(shè)客戶端能夠在大多數(shù)情況下獲取鎖。所有Redis實例都將包含一個存活時間相同的key传睹。但是這個key在不同實例上設(shè)置的時間點并不相同条获,因此key也將在不同的時間點過期。但是如果第一個key在時間T1設(shè)置為最差(在與第一個服務(wù)器通訊之前采樣的時間)蒋歌,并且最后一個key在時間T2設(shè)置為最差(從最后一個服務(wù)器獲得回復(fù)的時間)帅掘,我們可以確定在第一個key的存活時間最小為MIN_VALIDITY = TTL-(T2-T1)-CLOCK_DRIFT
,即分布式鎖的過期時間 - 獲取鎖過程的總時間 - 時鐘漂移堂油。所有其他key都將在之后的時間到期修档,因此我們確信這些key將至少同時設(shè)置為這個時間。
在設(shè)置大多數(shù)key期間府框,另一個客戶端將無法獲取鎖到吱窝,因為如果它已經(jīng)獲取到N / 2 + 1個key,那么N / 2 + 1 個SET NX操作就不能成功。因此院峡,如果已經(jīng)獲得鎖兴使,那么它是無法再次被重新獲取的(違反互斥屬性)。
然而我們還希望確保同時嘗試獲取鎖的多個客戶端不能同時成功照激。
如果客戶端使用的時間接近或大于鎖的最大有效時間(SET的TTL
)來鎖定大多數(shù)Redis實例发魄,那么可以認(rèn)為鎖定無效并將所有實例釋放,因此我們只需要考慮客戶端能夠在小于有效時間的時間內(nèi)鎖定大多數(shù)實例的情況俩垃。在這種情況下励幼,對于上面已經(jīng)表達(dá)的參數(shù),對于MIN_VALIDITY
沒有客戶端能夠重新獲取鎖口柳。因此只有當(dāng)鎖定多數(shù)實例的時間大于TTL
時苹粟,多個客戶端才能同時鎖定N / 2 + 1實例(時間上面是步驟2的結(jié)束時間),這時后這個鎖是無效的跃闹。
活躍性討論
系統(tǒng)的活躍性主要基于三個主要特征:
1嵌削、鎖的自動釋放(因為key到期):最終可以再次鎖定key。
2望艺、通常情況下苛秕,當(dāng)沒有獲得鎖時或者獲取了鎖并且整個流程終止時,客戶端通常會移除鎖荣茫,這樣就能夠使得我們可能不用等到key到期就能重新獲取鎖。
3场靴、事實上當(dāng)客戶端需要重新嘗試獲取鎖時啡莉,它等待的時間比獲取大多數(shù)鎖所需的時間要大得多,這樣就可以避免在資源爭用期間出現(xiàn)腦裂的情況旨剥。
但是咧欣,我們在網(wǎng)絡(luò)分區(qū)上付出的代價等于TTL
時間,因此如果有連續(xù)分區(qū)轨帜,我們可以無限期地支付此代價魄咕。這種情況在每次客戶端獲取鎖并在能夠刪除鎖之前進(jìn)行分區(qū)時都會發(fā)生。
基本上蚌父,如果存在無限的連續(xù)網(wǎng)絡(luò)分區(qū)哮兰,那么系統(tǒng)可能在無限長的時間內(nèi)都不可用。
性能苟弛、故障恢復(fù)和fsync
使用Redis作為鎖服務(wù)器需要很高的性能要求喝滞,以滿足獲取和釋放鎖的低延遲以及每秒可執(zhí)行的獲取/釋放操作數(shù)量方面的要求。為了滿足這個要求膏秫,需要減少客戶端與N個Redis服務(wù)器通信延遲右遭,因此選擇多路復(fù)用(或者是簡單版的多路復(fù)用,即將Socket
置于非阻塞模式,發(fā)送所有命令窘哈,之后讀取所有命令吹榴,假設(shè)客戶端和每個實例之間的RTT
,即往返時延是相似的)滚婉。
但是如果我們想要定位故障恢復(fù)系統(tǒng)模型图筹,還有另一個關(guān)于持久化的因素需要考慮。
假設(shè)我們根本沒有配置Redis的持久化满哪⌒龀猓客戶端從5個實例中的3個都獲取到了鎖。而客戶端能夠獲取鎖的1個Redis實例重啟了哨鸭,這時候就又出現(xiàn)了3個實例可以鎖定同一資源的情況民宿,這樣另一個客戶端可以再次鎖定它,這樣就違反了鎖獨占的安全屬性像鸡。舉個例子活鹰,客戶端A獲取了3個實例的鎖,這時可以認(rèn)為客戶端拿到了分布式鎖只估,但這時候這個3個實例中的1個重啟了志群,但是客戶端A已經(jīng)被判斷為拿到分布式鎖了。而客戶端B這時也獲取了3個Redis實例的鎖(客戶端A沒有獲取到的2個加重啟的1個)蛔钙,也拿到了分布式的鎖锌云,這樣就違反了鎖的獨占性。
如果我們開啟AOF持久化策略吁脱,情況會有所改善桑涎。例如,我們可以通過發(fā)送SHUTDOWN
并重新啟動它來升級服務(wù)器兼贡。因為Redis過期是在語義上實現(xiàn)的攻冷,所以當(dāng)服務(wù)器關(guān)閉時,實際上服務(wù)器仍然在計時遍希,即unix時間等曼,這樣我們所有的要要求都沒有問題。只要它是一個純粹的關(guān)機操作那么所有都沒有問題凿蒜。如果是停電呢禁谦?如果默認(rèn)情況下將Redis設(shè)置為每秒在磁盤上進(jìn)行fsync,那么重新啟動后可能會丟失key废封。理論上如果我們想要在任何類型的實例重啟時都保證鎖的安全性枷畏,我們需要在Redis的持久化設(shè)置中始終啟用fsync=always
,這時候Redis的性能先對要低寫虱饿。反過來這將完全毀掉傳統(tǒng)上用于以安全方式實現(xiàn)分布式鎖的CP系統(tǒng)的性能拥诡。
然而事情比第一眼看起來要好一些触趴。基本上只要實例在崩潰后重新啟動時渴肉,它就會保留算法安全性冗懦。重啟后的實例將不再參與任何當(dāng)前活動的鎖,因此當(dāng)實例重新啟動時仇祭,當(dāng)前活動鎖的集合都是通過鎖定的那些實例而不是重新加入系統(tǒng)的實例獲得的披蕉。也就是說重啟之后的實例不影響之前獲取到的鎖,且也不會參與到當(dāng)前活動的鎖中乌奇。
為了保證這一點没讲,我們只需要在Redis崩潰后創(chuàng)建一個實例,且在一個至少比最大TTL
多一點的時間范圍內(nèi)都不可用礁苗。這樣實例崩潰時存在的鎖的所有key就會變?yōu)闊o效并最終自動釋放爬凑。也就是說在崩潰時存在的鎖的有效期內(nèi),重新啟動的Redis實例都不可用试伙,即不參與當(dāng)前鎖的計算嘁信。
使用延遲重啟基本上可以實現(xiàn)安全性,即使沒有開啟的Redis持久化疏叨,但請需要注意的是這可能轉(zhuǎn)化為可用性懲罰潘靖。例如,如果大多數(shù)實例崩潰蚤蔓,對TTL
而言系統(tǒng)將全局不可用(意味著在此期間不可鎖定任何資源)卦溢。大多數(shù)實例崩潰也就意味著整個算法是不可用的。
三秀又、總結(jié)
上文的RedLock算法是從實現(xiàn)上比較容易理解单寂,關(guān)鍵點就是兩個:
一、獲取到的所有Redis的實例數(shù)必須大于N/2涮坐,即至少有N/2 + 1個實例
二凄贩、獲取所有Redis實例的時間 + 時鐘漂移 + 業(yè)務(wù)執(zhí)行時間應(yīng)該小于TTL
誓军。
另外需要注意的是:
1袱讹、一定要為每個Redis實例設(shè)置超時時間,且這個時間要遠(yuǎn)小于TTL
昵时,以避免獲取某個Redis的鎖時長時間阻塞捷雕,而影響整個流程。
2壹甥、在釋放Redis的分布式鎖時要釋放所有實例救巷,即使沒有獲取到該實例。
3句柠、重試機制應(yīng)該設(shè)置一定的次數(shù)限制
這里的RedLock算法是官方文檔提供的浦译,實際應(yīng)用中可以使用相關(guān)語言的具體實現(xiàn)棒假,比如Java實現(xiàn)的Redisson。以上內(nèi)容基本上是從官方文檔翻譯過來的精盅,但是因為自己能力有限帽哑,所以可能會有一定的偏差,還請諒解叹俏,如果上面內(nèi)容中有什么不當(dāng)之處也請指正妻枕。