淺析redis與zookeeper構(gòu)建分布式鎖的異同
進程請求分布式鎖時一般包含三個階段:1. 進程請求獲取鎖;2. 獲取到鎖的進程持有鎖并執(zhí)行業(yè)務(wù)邏輯;3. 獲取到鎖的進程釋放鎖;下文會按照這個三個階段進行分析。
單機Redis
獲取鎖
從一開始的請求進程通過SETNX命令獲取鎖;127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
-> 因為存在進程通過SETNX命令獲取到鎖后,執(zhí)行業(yè)務(wù)邏輯期間掛掉,未能釋放鎖,導(dǎo)致死鎖的場景,引入了超時機制用于打破死鎖形成的條件之一(獲取到鎖的進程一直持有鎖),使得鎖即使在獲取鎖的進程崩潰后仍可以通過超時機制得到釋放;
127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
127.0.0.1:6379> EXPIRE redis_locks 60
(integer) 1
-> 引入超時機制后,獲取鎖存在兩條命令,SETNX EXPIRE,前者用于加鎖,后者用于設(shè)置鎖的過期時間,即加鎖過程不再具有原子性;因此亦存在進程通過SETNX獲取到鎖后還未執(zhí)行EXPIRE便掛掉的場景,同樣會導(dǎo)致死鎖;因此Redis在2.6.12版本后擴展了SET命令的參數(shù),使得通過一條命令SET Key Value EX 10 NX即可實現(xiàn)SETNX EXPIRE的效果,保證了獲取鎖的原子性。
127.0.0.1:6379> SET redis_locks 1 EX 60 NX
OK
釋放鎖
從一開始的獲取到鎖的進程執(zhí)行完業(yè)務(wù)邏輯后調(diào)用DEL命令釋放鎖;-> 引入超時機制后使得鎖的釋放多了一個渠道;如果獲取到鎖的進程執(zhí)行業(yè)務(wù)邏輯的過程中因為GC等原因造成進程暫停,并且因為進程暫停導(dǎo)致鎖觸發(fā)超時機制使得鎖被釋放,另一個進程獲取鎖成功,而當(dāng)前進程重新運行時并不知道自身的鎖已經(jīng)被釋放,會繼續(xù)執(zhí)行業(yè)務(wù)邏輯并且釋放鎖,而這個鎖是被另一個進程持有的;即一個客戶端釋放了其他客戶端持有的鎖,而要解決這個問題顯然要給鎖加上一個持有者的唯一標(biāo)識,如UUID,當(dāng)進程準(zhǔn)備釋放鎖時,首先檢查鎖的標(biāo)識確認該鎖是否屬于自身,只有鎖屬于自身時才會進行釋放;
// uuid可以通過 UUID.randomUUID().toString()獲取
127.0.0.1:6379> SET redis_locks $uuid EX 60 NX
OK
-> 引入唯一標(biāo)識后,鎖的釋放需要檢查鎖標(biāo)識、釋放鎖兩個步驟,顯然兩個步驟并不是原子的;在極端情況下,仍然會存在檢查鎖標(biāo)識時該鎖尚且屬于自身,而檢查完后鎖就因為超時被釋放了,此時另一個進程獲取到了鎖,從而導(dǎo)致當(dāng)前進程仍然存在釋放其他進程鎖的可能性;因此也需要將這兩個步驟變?yōu)樵拥模话闶峭ㄟ^Lua腳本來實現(xiàn);
--- 原子腳本中包含兩個步驟:1)判斷當(dāng)前鎖是否是自己的 2)鎖是自己的進行釋放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
優(yōu)化:自動續(xù)期
上述流程雖然已經(jīng)解決了進程持有鎖并進行業(yè)務(wù)邏輯時,鎖已經(jīng)因為過期而自動釋放這個場景下當(dāng)前進程釋放其他進程鎖的問題,而且當(dāng)前進程也可以將業(yè)務(wù)邏輯繼續(xù)運行完成;但如果當(dāng)前業(yè)務(wù)邏輯存在先后因果關(guān)系時,如Read And Modify, Check Then Act等,可能會導(dǎo)致數(shù)據(jù)一致性的問題;舉個例子:
-
該業(yè)務(wù)邏輯用于對數(shù)據(jù)庫某值進行加一,則先獲取鎖的進程讀取數(shù)據(jù)庫當(dāng)前值為1,然后便因為GC陷入進程暫停導(dǎo)致鎖超時;
-
此時另一個進程獲取到了鎖,并從數(shù)據(jù)庫讀取當(dāng)前值為1,并進行 1后寫入,此時數(shù)據(jù)庫值為2;
-
接著第一個進程從GC中醒來,繼續(xù)執(zhí)行業(yè)務(wù)邏輯,對之前讀到的值1進行 1后寫入,此時數(shù)據(jù)庫值仍為2;而這造成了更新丟失的問題;
在Java生態(tài)環(huán)境中,Redisson通過看門狗機制實現(xiàn)了自動續(xù)期的功能,我們只需要進行引用即可;并且Redisson的SDK中實現(xiàn)了很多功能,如可重入鎖、樂觀鎖、公平鎖、讀寫鎖以及下面集群版會提到的RedLock。
集群redis
一般生產(chǎn)環(huán)境通過主從 哨兵機制構(gòu)建redis集群,并通過寫主讀從的機制對外提供服務(wù),對于寫服務(wù)只要主庫寫入成功便返回客戶端,并通過RDB AOF的機制進行異步的主從狀態(tài)同步;那么在寫主讀從策略下的redis集群中,一個進程通過SET x x EX x NX命令寫主redis并成功獲取到鎖,并且該命令尚未通過AOF進行網(wǎng)絡(luò)同步,如果此時主redis崩潰,哨兵會進行主備切換,而顯然從庫中一定是沒有這個鎖對應(yīng)的鍵-值對的,因此如果此時其他進程嘗試獲取鎖便可能會獲取成功,而這會造成該鎖機制不再滿足互斥性;
上述問題的關(guān)鍵在于因為主從消息同步存在一定的滯后性,因此redis的作者提出了 RedLock 的機制用于解決上述的問題,RedLock的使用存在一個前提:不部署從庫和哨兵機制,但主庫要部署多個,官方推薦為5個(奇數(shù)); 如下圖所示:
獲取鎖
如果一個進程想要在redis集群中獲取到鎖,那么必須使得該進程獲取到鎖這件事在redis集群實例間達成共識,而達成共識一般通過Quorum機制,即少數(shù)服從多數(shù);因此在redLock算法中,一個進程需要依次向5個實例發(fā)送SET lock uuid EX 60 NX請求,并且記錄響應(yīng)結(jié)果,如果有3(5/2 1,半數(shù) 1)以上實例返回加鎖成功,那么該進程則成功獲取到鎖;獲取鎖失敗則需要向集群中所有redis實例發(fā)起釋放鎖的請求(通過Lua腳本釋放鎖);
上述為不考慮網(wǎng)絡(luò)延遲、進程暫停、時鐘漂移這三個會導(dǎo)致數(shù)據(jù)一致性問題的方案,接著基于網(wǎng)絡(luò)的部分同步模型來對算法做進一步的安全性探討;
1. 考慮超出上界的網(wǎng)絡(luò)延遲
進程請求鎖后同步等待redis服務(wù)端的響應(yīng)結(jié)果,如果此時因為網(wǎng)絡(luò)延遲超出上界的緣故,導(dǎo)致請求進程收到redis實例返回的加鎖成功的響應(yīng)時,當(dāng)前鎖已經(jīng)超過EX規(guī)定的時間并自動過期;而該進程對此并不知情仍然進行下一步的業(yè)務(wù)邏輯并在之后釋放鎖,而這會造成與redis單機版類似的問題-當(dāng)前進程釋放了其他進程的鎖;因此redLock在當(dāng)前進程嘗試獲取鎖時會先獲取當(dāng)前時間戳T1,等客戶端收到來自redis服務(wù)端的響應(yīng)時,再次獲取當(dāng)前時間戳T2,并判斷T2-T1 > EX Time,不等式成立時當(dāng)前客戶端才會認為自己加鎖成功,否則加鎖失?。?br />2. 考慮超出上界的進程暫停
如果進程已經(jīng)獲取到鎖后發(fā)生較長時間的GC亦會如單機版redis一樣,導(dǎo)致當(dāng)前客戶端釋放其他客戶端的鎖,解決方案類似,通過使用Lua腳本進行鎖的釋放;3. 考慮超出上界的時鐘漂移
如果請求鎖的客戶端獲取到60s的鎖后進行業(yè)務(wù)邏輯的處理,而此時redis集群中一些實例在同步NTP時間時,發(fā)生了大的跳躍,造成一些實例上的鎖提前過期了,這可能會導(dǎo)致同時有兩個客戶端持有集群的redis鎖;舉個例子:-
客戶端A在第一次加鎖時獲取了redis集群實例[1,2,3]的成功響應(yīng),而[4,5]被其他客戶端加鎖,但按照半數(shù)以上的原則,只有客戶端A獲取到了鎖;
-
此時實例3發(fā)生了時鐘跳躍導(dǎo)致實例3上的鎖提前過期,而此時另一個客戶端B請求加鎖時獲取到了[3,4,5]三個實例的成功響應(yīng),導(dǎo)致客戶端B也獲取到了鎖;
4. RedLock獲取鎖的過程
綜上所述,RedLock 獲取鎖的過程如下:-
請求進程記錄下當(dāng)前時間戳T1;
-
請求進程依次請求redis實例獲取鎖,并且每個請求都會設(shè)置超時時間(該超時時間遠小于鎖的有效時間),如果請求進程收到響應(yīng)或超過超時時間則繼續(xù)向下一個redis實例申請加鎖;
-
如果請求進程獲得了半數(shù)以上的redis集群實例響應(yīng),則獲取當(dāng)前時間戳T2,判斷T2-T1 > EX Time,如果不成立則獲取鎖失??;
釋放鎖
釋放鎖不僅需要通過Lua腳本進行釋放,而且考慮到加鎖期間存在一些redis實例中已經(jīng)添加鎖成功,但是響應(yīng)超時了,而這對于當(dāng)前鎖的持有者是不知情的,因此持有鎖的進程需要向集群中所有的redis實例發(fā)送請求釋放鎖;zookeeper實現(xiàn)分布式鎖的優(yōu)勢
因為zk基于全序廣播算法ZAB的緣故,zk對于每個進程發(fā)起的獲取鎖的請求,都會分配一個全局唯一遞增的ZXID,即ZXID越小,請求越早到達zk;并且因為zk對于每個請求的處理都會通過執(zhí)行ZAB算法在集群各個節(jié)點間達成共識,所以ZXID最小的請求會獲取到鎖。因此zk不需要依賴額外的RedLock機制來實現(xiàn)分布式共識;這也是zk實現(xiàn)分布式鎖的一個優(yōu)勢。獲取鎖:獲取鎖即為在zk中創(chuàng)建一個臨時節(jié)點,例如/exclusive_lock/lock;創(chuàng)建臨時節(jié)點成功的進程則獲取到鎖,創(chuàng)建失敗的進程則加鎖失??;我們可以通過開源的zk客戶端,如ZkClient、Curator的create()方法進行節(jié)點的創(chuàng)建;
釋放鎖:因為臨時節(jié)點的特性,釋放鎖存在兩種情況:1. 獲取鎖的進程刪除臨時節(jié)點便釋放了所持有的鎖;2. 獲取鎖的進程掛了,與zk斷連后該臨時節(jié)點會自動刪除,即自動釋放鎖;而這是通過zk獲取鎖的第二個優(yōu)勢-沒有鎖過期帶來的煩惱;回憶一下:redis引入超時過期機制是為了解決獲取鎖節(jié)點宕機的問題,并且因為這個超時過期帶來了很多的問題場景。
并且可以直接通過zk的順序節(jié)點和Watcher機制實現(xiàn)讀寫鎖、樂觀鎖;
Watcher機制:通過Watcher機制,客戶端可以向如下的/read_write_lock目錄節(jié)點注冊子節(jié)點變更的Watcher監(jiān)聽,這樣當(dāng)該目錄下子節(jié)點發(fā)生增減時,zk會將該事件通知所有注冊的客戶端;
順序節(jié)點:在順序節(jié)點目錄下的子節(jié)點,zk會為節(jié)點維護創(chuàng)建的先后順序,并在節(jié)點名稱后綴中增加節(jié)點創(chuàng)建的次序值:
通過上述兩個機制,我們可以實現(xiàn)讀寫鎖:
-
首先需要定義一個機制用于區(qū)分讀寫請求,可以在寫入節(jié)點值時增加Read or Write進行區(qū)分;因為順序節(jié)點后綴大小標(biāo)識了請求的先后性,如上圖所示:表明zk先后收到了兩個獲取Read鎖、一個獲取Wrtie鎖的請求;
-
接著對于一個進程獲取讀鎖的請求,如果此時/read_write_lock目錄下沒有包含Write的節(jié)點,則直接創(chuàng)建節(jié)點并返回獲取成功;如果此時存在獲取讀鎖的節(jié)點,則獲取失敗,不過仍然會在目錄下創(chuàng)建節(jié)點,但需要在該寫鎖節(jié)點上注冊Watcher監(jiān)聽,當(dāng)該寫鎖節(jié)點刪除后,原請求進程可以嘗試重新獲取讀鎖;
-
對于一個獲取寫鎖的請求,如果此時/read_write_lock目錄下Write節(jié)點已經(jīng)是存活的后綴最小的節(jié)點,則獲取寫鎖成功;如果在該請求前仍然存在其他節(jié)點,則獲取寫鎖失敗,需要在該目錄下后綴不大于該寫請求的節(jié)點上注冊Watcher通知,這樣當(dāng)該節(jié)點釋放后,則請求進程可以再次嘗試獲取寫鎖。
zookeeper實現(xiàn)分布式鎖的一些問題
當(dāng)然通過zk實現(xiàn)分布式鎖仍然存在很多問題,我們同樣按照網(wǎng)絡(luò)延遲、進程暫停的角度進行分析;-
進程1創(chuàng)建臨時節(jié)點/exclusive_lock/lock成功,拿到了鎖
-
進程1因為機器長時間GC而暫停
-
進程1無法給 Zookeeper 發(fā)送心跳,Zookeeper將臨時節(jié)點刪除
-
進程2創(chuàng)建臨時節(jié)點/exclusive_lock/lock 成功,拿到了鎖
-
進程1機器GC結(jié)束后恢復(fù),它仍然認為自己持有鎖(產(chǎn)生沖突)
可以使用類似Redisson的續(xù)約機制,通過一個守護線程進行zk臨時節(jié)點的維護;但是zk不會存在釋放了別人鎖的情況,所以不需要通過類似Lua的機制來釋放鎖;
fencing token算法
上述問題的關(guān)鍵在于進程在喚醒后,仍然以為自己持有鎖并進行共享資源的操作;因為操作系統(tǒng)中進程的切換或崩潰后恢復(fù),只會在原有的執(zhí)行序列位置繼續(xù)執(zhí)行,自然不可能自發(fā)的在喚醒后重新檢查自己是否仍然持有鎖;因此fencing算法提出了讓共享資源具有拒絕持有過期鎖的進程發(fā)起的請求的能力:
-
fencing算法通過給鎖加上一個序列,即每次請求進程成功獲取鎖時,鎖服務(wù)都會返回一個遞增的token;
-
接著請求進程拿著這個token去操作共享資源;
-
共享資源緩存token,并拒絕token值較小的客戶端請求。
通過該算法,可以解決上述的持有鎖后進程暫停帶來的影響;不過如果持有過期鎖的進程操作共享資源并沒有先后因果關(guān)系時,可以無需考慮使用該算法,該算法存在一定的代價。
總結(jié)
一般都是使用分布式鎖用作互斥,上述文章中列舉了NPC場景下的一些問題,并都給出了相應(yīng)的解決方案;具體使用時可以考慮場景本身對于數(shù)據(jù)絕對正確的敏感度,決定是否要使用代價更大的機制來進行保證。當(dāng)然RedLock還是不推薦使用,代價太大,還是建議使用主從 哨兵的機制進行redis集群的搭建。