前置知識
最低保證的分布式鎖特性
- 互斥安全:能夠保護臨界代碼區(qū)被互斥的訪問借笙。(這是鎖的要求)
- 不會死鎖:當持有鎖的客戶端崩潰或者無法訪問到redis锋玲,該鎖能夠被釋放,使得其它客戶端仍然可以獲取這個鎖沿腰。
- 可用:只要大部分redis節(jié)點還活著鲜锚,客戶端就可以獲取和釋放這個鎖(單實例的redis不需要考慮這個特性,在下面我們暫時不考慮這個特性扯键,我們暫時只考慮多客戶端訪問單實例redis)睦袖。
事實上,要想同時滿足所有這三個特性是非常難的荣刑。
使用到的命令
命令 | 含義 | 返回值 |
---|---|---|
SETNX key value | 當 key 不存在馅笙,將 key 的值設(shè)為 value | 設(shè)置成功返回1伦乔,否則返回0 |
PEXPIRE/EXPIRE key milliseconds | 為給定 key 設(shè)置生存時間 | 設(shè)置成功返回 1,否則返回0 |
SET key value [EX seconds] [PX milliseconds] [NX|XX] | 將字符串值 value 關(guān)聯(lián)到 key | 設(shè)置完成返回OK董习,如果設(shè)置了 NX 或者 XX 烈和,但因為條件沒達到而造成設(shè)置操作未執(zhí)行,那么命令返回空批量回復(NULL Bulk Reply) |
DEL key [key ...] | 刪除給定的一個或多個 key | 被刪除 key 的數(shù)量 |
EVAL script numkeys key [key ...] arg [arg ...] | 通過內(nèi)置的 Lua 解釋器皿淋,可以使用 EVAL 命令對 Lua 腳本進行求值 | Lua 腳本的返回值也會被轉(zhuǎn)換成 Redis 協(xié)議(protocol)招刹,然后由 EVAL 將值返回給客戶端 |
分布式系統(tǒng)中可能遇到的情況
- 進程崩潰:分布式系統(tǒng)中某一個進程的崩潰,可能會使得其持有的資源無法釋放或者其它需要依賴于該進程的進程們無法正常進程窝趣,等等疯暑。
- 網(wǎng)絡(luò)分區(qū):網(wǎng)絡(luò)分區(qū)可能是的一部分進程無法訪問另外一部分進程。
- 網(wǎng)絡(luò)延時:網(wǎng)絡(luò)延時可能會使得一個數(shù)據(jù)包在經(jīng)歷了一段比較長的時間之后才到的對端哑舒,而這時可能對端的狀態(tài)已經(jīng)出現(xiàn)了比較大的改變妇拯。
- 網(wǎng)絡(luò)丟包:某些數(shù)據(jù)包可能在傳輸?shù)倪^程中丟失。(如果是在一條tcp連接上洗鸵,tcp本身會保證不會丟包越锈,udp則不會保證)
- 網(wǎng)絡(luò)包亂序:后發(fā)送的數(shù)據(jù)包可能比其它數(shù)據(jù)包提前到達。(如果是在一條tcp連接上傳輸數(shù)據(jù)预麸,tcp本身會保證不會產(chǎn)生數(shù)據(jù)包亂序瞪浸,udp則不會保證)
分布式系統(tǒng)中錯誤的假設(shè)
- The network is reliable.
網(wǎng)絡(luò)總是可靠的。 - Latency is zero.
網(wǎng)羅延遲總是零吏祸。 - Bandwidth is infinite.
帶寬是無限的对蒲。 - The network is secure.
網(wǎng)絡(luò)是安全的。 - Topology doesn't change.
網(wǎng)絡(luò)拓撲關(guān)系從不改變贡翘。 - There is one administrator.
有一個管理員能夠處理出現(xiàn)的問題蹈矮。 - Transport cost is zero.
傳輸成本為零。 - The network is homogeneous.
網(wǎng)絡(luò)是同構(gòu)的鸣驱。
實現(xiàn)
分布式系統(tǒng)程序設(shè)計目標
分布式設(shè)計要為重啟而設(shè)計
- 我們開發(fā)的程序的可靠性只需要略高于硬件和操作系統(tǒng)即可:連續(xù)運行數(shù)個星期泛鸟。
- 某些資源不足的錯誤,直接重啟進程就好
- 通過軟件手段來提高系統(tǒng)的整體可用性踊东。
下面我們來看看設(shè)計一個分布式鎖來保護臨界區(qū)方法doSomething的過程北滥。
先來看一個最簡單的鎖
t, err = SETNX lock "locked" // p1
if err == nil && t == 1 : // p2
doSomething() // p3
del lock // p4
對這個算法各個階段失敗的探索,有助于我們理解分布式程序和單機程序的不同之處:
- 如果p1調(diào)用失敗了(即err!=nil)闸翅,失敗的原因可能有哪些呢再芋?有可能是請求打到redis服務(wù)器前,主機崩潰了或者網(wǎng)絡(luò)永久性不可達了坚冀,在這種情況下济赎,這次請求沒有被redis服務(wù)端接受的,返回了錯誤,客戶端不會獲得鎖司训,算法沒有毛病构捡。如果請求打到了redis服務(wù)器之后,在redis執(zhí)行了相關(guān)操作但是響應(yīng)打到客戶端之前壳猜,主機崩潰了或者網(wǎng)絡(luò)永久性不可達了勾徽,將會導致這樣的一種狀況:客戶端得到了err!=nil,認為自己沒有拿到鎖蓖谢,自然之后也就不會有釋放鎖的操作捂蕴;但是在redis那邊,鍵lock已經(jīng)被創(chuàng)建了闪幽,鎖已經(jīng)分配出去了啥辨,其它的客戶端將再也無法獲取到這個鎖。如此就造成了一種死鎖現(xiàn)象盯腌。這是算法就無法正常工作了溉知。請注意:一次網(wǎng)絡(luò)調(diào)用實際上起碼是分三個操作來完成的:客戶端發(fā)送請求,服務(wù)端執(zhí)行操作腕够,服務(wù)端返回響應(yīng)级乍,這三個不是原子操作,它們可能因為進程崩潰或者網(wǎng)絡(luò)斷開而中斷帚湘,在分布式環(huán)境下玫荣,我們必須要對這種情形進行容錯處理。
- 和上面的分析一樣大诸,我們還可以看到捅厂,在客戶端獲得鎖(redis設(shè)置了lock同時客戶端獲取到了err!=nil&&t==1的響應(yīng))之后,在接下來執(zhí)行p2和p3和p4的時候资柔,仍然有可能出錯焙贷,考慮一下,在客戶端持有鎖直到del lock請求發(fā)送到服務(wù)端之前贿堰,一旦客戶端崩潰或者和redis服務(wù)器之間的網(wǎng)絡(luò)永久性斷開辙芍,客戶端將再也無法通知redis服務(wù)器釋放鎖。而這個鎖也就再也不能被其他客戶端獲取羹与。注意:客戶端的崩潰有可能使得某些資源再也無法被釋放故硅。
- 上面的2種情形的都會出現(xiàn)redis再也無法釋放鎖從而造成死鎖的現(xiàn)象,只不過在情形1中客戶端并不知道自己持有鎖纵搁,而在情形2中吃衅,客戶端則是知道自己持有鎖的。向上翻一下诡渴,這肯定是違背了分布式鎖最低要求的特性2的。接下來我們需要想出一種辦法來避免這種情形下的死鎖的發(fā)生。
這個實現(xiàn)可以滿足特性1:互斥安全的要求妄辩,但是無法滿足特性2:互斥安全
帶有過期時間的鎖
我們必須提供一個機制惑灵,使得當持有鎖的客戶端(不管該客戶端知道不知道它持有這個鎖)崩潰或者和redis服務(wù)器之間的網(wǎng)絡(luò)不可達的時候,仍然能夠釋放該客戶端持有的鎖眼耀。
我們可以使用redis的超時機制通過redis服務(wù)器自身來釋放這個鎖英支。
t, err = SET lock "locked" NX EX 10 // p1
if err == nil && t == 1: // p2
doSomething() // p3
del lock // p4
- 我們?yōu)殒ilock設(shè)置了一個超時時間,這樣當持有鎖的客戶端失效(崩潰或者和redis服務(wù)器網(wǎng)絡(luò)永久性不可達)后哮伟,redis在鍵lock過期之后干花,會主動的把該鍵刪除從而釋放這個鎖,從而使得其它的客戶端可以獲取到這個鎖楞黄。
- 但是池凄,這個算法仍然是有問題的。為什么呢鬼廓?因為加入了過期時間的問題肿仑,這意味著客戶端持有的鎖的時間是有限的,它必須在鎖過期時間之內(nèi)完成臨界區(qū)代碼碎税,否則尤慰,一旦鎖過期了,該鎖就可能被其它的客戶端獲取到雷蹂,從而使得兩個客戶端代碼同時進入了臨界區(qū)伟端,而這違背了分布式鎖的特性1的要求。
- 我們繼續(xù)對上述的情形進行分析匪煌,看看兩個客戶端同時進入了臨界區(qū)代碼之后會發(fā)生的情況责蝠,在首先獲取鎖的客戶端1的鎖過期后,客戶端2獲取了鎖但是還沒有退出臨界區(qū)代碼的時候虐杯,客戶端1率先完成了臨界區(qū)代碼玛歌,然后它執(zhí)行del語句,而這會將客戶端2持有的鎖刪除擎椰,這也是不合理的支子,客戶端2持有的鎖應(yīng)該只能由它自己釋放。
這個實現(xiàn)可以滿足特性2:不會死鎖达舒,但是削弱了特性1:互斥安全的特性
帶有識別客戶端和超時時間的鎖
對于上面的那個實現(xiàn)值朋,我們首先看怎么解決情形3中的問題,即一個客戶端可能會釋放另外一個客戶端的鎖巩搏,造成這種情況的原因在于昨登,該redis服務(wù)器并不能區(qū)分釋放鎖的客戶端是否就是獲取到鎖的客戶端。所以我們自己來實現(xiàn)這樣的需求贯底。
第一個問題是丰辣,我們該怎樣去區(qū)分不同的客戶端呢撒强?一種比較好的方式是使用客戶端的ip-port串去標示,你還可以在加上當前獲取鎖的時間戳笙什,就像這樣:ip-port-unixstamp飘哨。我們需要在獲取鎖的時候把這個標識保存到redis服務(wù)器上,我們可以把它作為鎖的value值琐凭,我們可以使用這樣的語句來獲取鎖:
SET lock "192.168.0.108-8888-1536512839" NX EX 10
第二個問題是芽隆,在我們的客戶端釋放鎖的時候,如何讓redis對比持有鎖的客戶端標示和當前發(fā)送釋放鎖指令的客戶端统屈,以決定是否刪除鎖呢胚吁?我們需要類似于這樣的原子指令:
if (get lock) == "192.168.0.108-8888-1536512839":
del lock
翻查一下redis命令手冊,沒有找到這樣的判斷-刪除的原子指令(也許有人會想到事務(wù)愁憔,遺憾的是腕扶,事務(wù)只能保證指令執(zhí)行間不被插入其它指令,但是redis沒有為我們提供判斷l(xiāng)ock的值是否為"192.168.0.108-8888-1536512839"的指令:如果我們使用redis事務(wù)惩淳,下面的命令序列應(yīng)該是大家能想到的:multi, exists, del, exec蕉毯,遺憾的是在執(zhí)行del前我們無法獲取到exists的值),不過我們不用絕望思犁,因為我們還有redis-lua來自己實現(xiàn)這樣的功能代虾,redis保證了一個redis—lua腳本會在redis服務(wù)器中被原子性的執(zhí)行,所以我們可以利用redis-lua腳本來實現(xiàn)這個功能:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
其中ARGV[1]的參數(shù)值為客戶端要釋放lock時發(fā)送的自身客戶端標識激蹲,KEYS[1]為鎖的key名lock棉磨,腳本的功能是判斷該標識是否和lock中保存的創(chuàng)建lock的客戶端標識一致,如果一致学辱,則刪除鎖lock乘瓤。
完整的代碼如下:
$do_del_lock$ = `if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end`
if t, err = SET lock $ip-port-unixstamp$ NX EX 10 // p1
if err == nil && t == 1: // p2
doSomething() // p3
eval $do_del_lock$ 1 lock $ip-port-unixstamp$ // p4
這樣我們就解決了上個實現(xiàn)中一個客戶端可能會釋放掉其它客戶端的鎖的問題。
現(xiàn)在我們還有一個問題策泣,即情形2中的問題衙傀,事實上,我們可以看到萨咕,如果情形2不會出問題的話统抬,即如果可以保證互斥的話,那么是不可能存在一個客戶端釋放掉另外一個客戶端持有的鎖的問題的危队,也就是情形3永遠也不會發(fā)生聪建。那么我們?yōu)槭裁床恢苯咏鉀Q情形2的問題呢?
我們來考慮一下茫陆,情形2的問題的關(guān)鍵在哪里金麸。原因在于,這個鎖會自動的過期簿盅,而它過期的時候挥下,可能持有鎖的客戶端正在執(zhí)行臨界區(qū)代碼揍魂,還沒有完成(標注1)。而為什么我們選擇了一個帶有過期時間的鎖呢棚瘟,因為我們持有鎖的客戶端可能會失效愉烙,使得該鎖永遠無法被釋放(標注2)。
如果我們決定使用帶有過期時間的鎖解取,可能會發(fā)生標注1中的情形,如果我們決定使用沒有過期時間的鎖返顺,那么可能會發(fā)生標注2中的情形禀苦。但是只要我們解決了其中任何一種情形,我們就可以實現(xiàn)一個同時滿足特性1:互斥安全和特性2:不會死鎖的redis鎖遂鹊。(標注1滿足2不滿足1振乏,而標注2滿足1不滿足2)
那么我們有沒有辦法解決這個問題呢?
對于標注1秉扑,很顯然的一種想法是在該鎖即將過期時我們進行續(xù)租慧邮,redis服務(wù)期并沒有提供一種某個鍵即將過期的機制,但是我們的客戶端請求鎖的時候是知道自己對該鎖的租約時間的舟陆,我們可以在即將到達的時候發(fā)送一個expire指令來延長我們的租約時間误澳。這個想法很好,那么客戶端在還剩下多久租約時間時發(fā)送這個續(xù)租命令秦躯,該續(xù)租命令能不能在剩余租約到達前被redis服務(wù)器執(zhí)行忆谓,執(zhí)行的結(jié)果能不能成功的返回(考慮我們之前說過的網(wǎng)絡(luò)延時和網(wǎng)絡(luò)故障的情形),如果續(xù)租失敗踱承,怎么辦倡缠。關(guān)于這個模型,我之后還會進行思考茎活,看看其可能性昙沦,我還會有更新的。
對于標注2载荔,問題的關(guān)鍵是redis服務(wù)器無法感知到客戶端是否是失效的盾饮,以及這個失效是永久性失效還是暫時性的失效。也就是說身辨,在客戶端和redis之間沒有一種心跳機制丐谋,redis服務(wù)器是個請求-響應(yīng)的服務(wù)器模型,暫時還沒有心跳機制煌珊,所以這條路走不通号俐。
可以看到,僅僅是實現(xiàn)redis分布式鎖的兩個特性就已經(jīng)非常困難了定庵,要實現(xiàn)全部三個特性那真是難如登天啊吏饿。
這個問題我還會關(guān)注的踪危,如果我有了什么新的想法我會繼續(xù)更新這篇文章的。