淺析redis與zookeeper構(gòu)建分布式鎖的異同
進(jìn)程請(qǐng)求分布式鎖時(shí)一般包含三個(gè)階段:1. 進(jìn)程請(qǐng)求獲取鎖;2. 獲取到鎖的進(jìn)程持有鎖并執(zhí)行業(yè)務(wù)邏輯;3. 獲取到鎖的進(jìn)程釋放鎖;下文會(huì)按照這個(gè)三個(gè)階段進(jìn)行分析。
單機(jī)Redis
獲取鎖
從一開始的請(qǐng)求進(jìn)程通過SETNX命令獲取鎖;127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
-> 因?yàn)榇嬖谶M(jìn)程通過SETNX命令獲取到鎖后,執(zhí)行業(yè)務(wù)邏輯期間掛掉,未能釋放鎖,導(dǎo)致死鎖的場(chǎng)景,引入了超時(shí)機(jī)制用于打破死鎖形成的條件之一(獲取到鎖的進(jìn)程一直持有鎖),使得鎖即使在獲取鎖的進(jìn)程崩潰后仍可以通過超時(shí)機(jī)制得到釋放;
127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
127.0.0.1:6379> EXPIRE redis_locks 60
(integer) 1
-> 引入超時(shí)機(jī)制后,獲取鎖存在兩條命令,SETNX EXPIRE,前者用于加鎖,后者用于設(shè)置鎖的過期時(shí)間,即加鎖過程不再具有原子性;因此亦存在進(jìn)程通過SETNX獲取到鎖后還未執(zhí)行EXPIRE便掛掉的場(chǎng)景,同樣會(huì)導(dǎo)致死鎖;因此Redis在2.6.12版本后擴(kuò)展了SET命令的參數(shù),使得通過一條命令SET Key Value EX 10 NX即可實(shí)現(xiàn)SETNX EXPIRE的效果,保證了獲取鎖的原子性。
127.0.0.1:6379> SET redis_locks 1 EX 60 NX
OK
釋放鎖
從一開始的獲取到鎖的進(jìn)程執(zhí)行完業(yè)務(wù)邏輯后調(diào)用DEL命令釋放鎖;-> 引入超時(shí)機(jī)制后使得鎖的釋放多了一個(gè)渠道;如果獲取到鎖的進(jìn)程執(zhí)行業(yè)務(wù)邏輯的過程中因?yàn)?/span>GC等原因造成進(jìn)程暫停,并且因?yàn)檫M(jìn)程暫停導(dǎo)致鎖觸發(fā)超時(shí)機(jī)制使得鎖被釋放,另一個(gè)進(jìn)程獲取鎖成功,而當(dāng)前進(jìn)程重新運(yùn)行時(shí)并不知道自身的鎖已經(jīng)被釋放,會(huì)繼續(xù)執(zhí)行業(yè)務(wù)邏輯并且釋放鎖,而這個(gè)鎖是被另一個(gè)進(jìn)程持有的;即一個(gè)客戶端釋放了其他客戶端持有的鎖,而要解決這個(gè)問題顯然要給鎖加上一個(gè)持有者的唯一標(biāo)識(shí),如UUID,當(dāng)進(jìn)程準(zhǔn)備釋放鎖時(shí),首先檢查鎖的標(biāo)識(shí)確認(rèn)該鎖是否屬于自身,只有鎖屬于自身時(shí)才會(huì)進(jìn)行釋放;
// uuid可以通過 UUID.randomUUID().toString()獲取
127.0.0.1:6379> SET redis_locks $uuid EX 60 NX
OK
-> 引入唯一標(biāo)識(shí)后,鎖的釋放需要檢查鎖標(biāo)識(shí)、釋放鎖兩個(gè)步驟,顯然兩個(gè)步驟并不是原子的;在極端情況下,仍然會(huì)存在檢查鎖標(biāo)識(shí)時(shí)該鎖尚且屬于自身,而檢查完后鎖就因?yàn)槌瑫r(shí)被釋放了,此時(shí)另一個(gè)進(jìn)程獲取到了鎖,從而導(dǎo)致當(dāng)前進(jìn)程仍然存在釋放其他進(jìn)程鎖的可能性;因此也需要將這兩個(gè)步驟變?yōu)樵拥?,一般是通過Lua腳本來實(shí)現(xiàn);
--- 原子腳本中包含兩個(gè)步驟:1)判斷當(dāng)前鎖是否是自己的 2)鎖是自己的進(jìn)行釋放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
優(yōu)化:自動(dòng)續(xù)期
上述流程雖然已經(jīng)解決了進(jìn)程持有鎖并進(jìn)行業(yè)務(wù)邏輯時(shí),鎖已經(jīng)因?yàn)檫^期而自動(dòng)釋放這個(gè)場(chǎng)景下當(dāng)前進(jìn)程釋放其他進(jìn)程鎖的問題,而且當(dāng)前進(jìn)程也可以將業(yè)務(wù)邏輯繼續(xù)運(yùn)行完成;但如果當(dāng)前業(yè)務(wù)邏輯存在先后因果關(guān)系時(shí),如Read And Modify, Check Then Act等,可能會(huì)導(dǎo)致數(shù)據(jù)一致性的問題;舉個(gè)例子:
-
該業(yè)務(wù)邏輯用于對(duì)數(shù)據(jù)庫某值進(jìn)行加一,則先獲取鎖的進(jìn)程讀取數(shù)據(jù)庫當(dāng)前值為1,然后便因?yàn)镚C陷入進(jìn)程暫停導(dǎo)致鎖超時(shí);
-
此時(shí)另一個(gè)進(jìn)程獲取到了鎖,并從數(shù)據(jù)庫讀取當(dāng)前值為1,并進(jìn)行 1后寫入,此時(shí)數(shù)據(jù)庫值為2;
-
接著第一個(gè)進(jìn)程從GC中醒來,繼續(xù)執(zhí)行業(yè)務(wù)邏輯,對(duì)之前讀到的值1進(jìn)行 1后寫入,此時(shí)數(shù)據(jù)庫值仍為2;而這造成了更新丟失的問題;
在Java生態(tài)環(huán)境中,Redisson通過看門狗機(jī)制實(shí)現(xiàn)了自動(dòng)續(xù)期的功能,我們只需要進(jìn)行引用即可;并且Redisson的SDK中實(shí)現(xiàn)了很多功能,如可重入鎖、樂觀鎖、公平鎖、讀寫鎖以及下面集群版會(huì)提到的RedLock。
集群redis
一般生產(chǎn)環(huán)境通過主從 哨兵機(jī)制構(gòu)建redis集群,并通過寫主讀從的機(jī)制對(duì)外提供服務(wù),對(duì)于寫服務(wù)只要主庫寫入成功便返回客戶端,并通過RDB AOF的機(jī)制進(jìn)行異步的主從狀態(tài)同步;那么在寫主讀從策略下的redis集群中,一個(gè)進(jìn)程通過SET x x EX x NX命令寫主redis并成功獲取到鎖,并且該命令尚未通過AOF進(jìn)行網(wǎng)絡(luò)同步,如果此時(shí)主redis崩潰,哨兵會(huì)進(jìn)行主備切換,而顯然從庫中一定是沒有這個(gè)鎖對(duì)應(yīng)的鍵-值對(duì)的,因此如果此時(shí)其他進(jìn)程嘗試獲取鎖便可能會(huì)獲取成功,而這會(huì)造成該鎖機(jī)制不再滿足互斥性;
上述問題的關(guān)鍵在于因?yàn)橹鲝南⑼酱嬖谝欢ǖ臏笮?,因此redis的作者提出了 RedLock 的機(jī)制用于解決上述的問題,RedLock的使用存在一個(gè)前提:不部署從庫和哨兵機(jī)制,但主庫要部署多個(gè),官方推薦為5個(gè)(奇數(shù)); 如下圖所示:

獲取鎖
如果一個(gè)進(jìn)程想要在redis集群中獲取到鎖,那么必須使得該進(jìn)程獲取到鎖這件事在redis集群實(shí)例間達(dá)成共識(shí),而達(dá)成共識(shí)一般通過Quorum機(jī)制,即少數(shù)服從多數(shù);因此在redLock算法中,一個(gè)進(jìn)程需要依次向5個(gè)實(shí)例發(fā)送SET lock uuid EX 60 NX請(qǐng)求,并且記錄響應(yīng)結(jié)果,如果有3(5/2 1,半數(shù) 1)以上實(shí)例返回加鎖成功,那么該進(jìn)程則成功獲取到鎖;獲取鎖失敗則需要向集群中所有redis實(shí)例發(fā)起釋放鎖的請(qǐng)求(通過Lua腳本釋放鎖);
上述為不考慮網(wǎng)絡(luò)延遲、進(jìn)程暫停、時(shí)鐘漂移這三個(gè)會(huì)導(dǎo)致數(shù)據(jù)一致性問題的方案,接著基于網(wǎng)絡(luò)的部分同步模型來對(duì)算法做進(jìn)一步的安全性探討;
1. 考慮超出上界的網(wǎng)絡(luò)延遲
進(jìn)程請(qǐng)求鎖后同步等待redis服務(wù)端的響應(yīng)結(jié)果,如果此時(shí)因?yàn)?/span>網(wǎng)絡(luò)延遲超出上界的緣故,導(dǎo)致請(qǐng)求進(jìn)程收到redis實(shí)例返回的加鎖成功的響應(yīng)時(shí),當(dāng)前鎖已經(jīng)超過EX規(guī)定的時(shí)間并自動(dòng)過期;而該進(jìn)程對(duì)此并不知情仍然進(jìn)行下一步的業(yè)務(wù)邏輯并在之后釋放鎖,而這會(huì)造成與redis單機(jī)版類似的問題-當(dāng)前進(jìn)程釋放了其他進(jìn)程的鎖;因此redLock在當(dāng)前進(jìn)程嘗試獲取鎖時(shí)會(huì)先獲取當(dāng)前時(shí)間戳T1,等客戶端收到來自redis服務(wù)端的響應(yīng)時(shí),再次獲取當(dāng)前時(shí)間戳T2,并判斷T2-T1 > EX Time,不等式成立時(shí)當(dāng)前客戶端才會(huì)認(rèn)為自己加鎖成功,否則加鎖失敗;2. 考慮超出上界的進(jìn)程暫停
如果進(jìn)程已經(jīng)獲取到鎖后發(fā)生較長(zhǎng)時(shí)間的GC亦會(huì)如單機(jī)版redis一樣,導(dǎo)致當(dāng)前客戶端釋放其他客戶端的鎖,解決方案類似,通過使用Lua腳本進(jìn)行鎖的釋放;3. 考慮超出上界的時(shí)鐘漂移
如果請(qǐng)求鎖的客戶端獲取到60s的鎖后進(jìn)行業(yè)務(wù)邏輯的處理,而此時(shí)redis集群中一些實(shí)例在同步NTP時(shí)間時(shí),發(fā)生了大的跳躍,造成一些實(shí)例上的鎖提前過期了,這可能會(huì)導(dǎo)致同時(shí)有兩個(gè)客戶端持有集群的redis鎖;舉個(gè)例子:-
客戶端A在第一次加鎖時(shí)獲取了redis集群實(shí)例[1,2,3]的成功響應(yīng),而[4,5]被其他客戶端加鎖,但按照半數(shù)以上的原則,只有客戶端A獲取到了鎖;
-
此時(shí)實(shí)例3發(fā)生了時(shí)鐘跳躍導(dǎo)致實(shí)例3上的鎖提前過期,而此時(shí)另一個(gè)客戶端B請(qǐng)求加鎖時(shí)獲取到了[3,4,5]三個(gè)實(shí)例的成功響應(yīng),導(dǎo)致客戶端B也獲取到了鎖;
4. RedLock獲取鎖的過程
綜上所述,RedLock 獲取鎖的過程如下:-
請(qǐng)求進(jìn)程記錄下當(dāng)前時(shí)間戳T1;
-
請(qǐng)求進(jìn)程依次請(qǐng)求redis實(shí)例獲取鎖,并且每個(gè)請(qǐng)求都會(huì)設(shè)置超時(shí)時(shí)間(該超時(shí)時(shí)間遠(yuǎn)小于鎖的有效時(shí)間),如果請(qǐng)求進(jìn)程收到響應(yīng)或超過超時(shí)時(shí)間則繼續(xù)向下一個(gè)redis實(shí)例申請(qǐng)加鎖;
-
如果請(qǐng)求進(jìn)程獲得了半數(shù)以上的redis集群實(shí)例響應(yīng),則獲取當(dāng)前時(shí)間戳T2,判斷T2-T1 > EX Time,如果不成立則獲取鎖失??;
釋放鎖
釋放鎖不僅需要通過Lua腳本進(jìn)行釋放,而且考慮到加鎖期間存在一些redis實(shí)例中已經(jīng)添加鎖成功,但是響應(yīng)超時(shí)了,而這對(duì)于當(dāng)前鎖的持有者是不知情的,因此持有鎖的進(jìn)程需要向集群中所有的redis實(shí)例發(fā)送請(qǐng)求釋放鎖;zookeeper實(shí)現(xiàn)分布式鎖的優(yōu)勢(shì)
因?yàn)閦k基于全序廣播算法ZAB的緣故,zk對(duì)于每個(gè)進(jìn)程發(fā)起的獲取鎖的請(qǐng)求,都會(huì)分配一個(gè)全局唯一遞增的ZXID,即ZXID越小,請(qǐng)求越早到達(dá)zk;并且因?yàn)閦k對(duì)于每個(gè)請(qǐng)求的處理都會(huì)通過執(zhí)行ZAB算法在集群各個(gè)節(jié)點(diǎn)間達(dá)成共識(shí),所以ZXID最小的請(qǐng)求會(huì)獲取到鎖。因此zk不需要依賴額外的RedLock機(jī)制來實(shí)現(xiàn)分布式共識(shí);這也是zk實(shí)現(xiàn)分布式鎖的一個(gè)優(yōu)勢(shì)。獲取鎖:獲取鎖即為在zk中創(chuàng)建一個(gè)臨時(shí)節(jié)點(diǎn),例如/exclusive_lock/lock;創(chuàng)建臨時(shí)節(jié)點(diǎn)成功的進(jìn)程則獲取到鎖,創(chuàng)建失敗的進(jìn)程則加鎖失敗;我們可以通過開源的zk客戶端,如ZkClient、Curator的create()方法進(jìn)行節(jié)點(diǎn)的創(chuàng)建;
釋放鎖:因?yàn)?/span>臨時(shí)節(jié)點(diǎn)的特性,釋放鎖存在兩種情況:1. 獲取鎖的進(jìn)程刪除臨時(shí)節(jié)點(diǎn)便釋放了所持有的鎖;2. 獲取鎖的進(jìn)程掛了,與zk斷連后該臨時(shí)節(jié)點(diǎn)會(huì)自動(dòng)刪除,即自動(dòng)釋放鎖;而這是通過zk獲取鎖的第二個(gè)優(yōu)勢(shì)-沒有鎖過期帶來的煩惱;回憶一下:redis引入超時(shí)過期機(jī)制是為了解決獲取鎖節(jié)點(diǎn)宕機(jī)的問題,并且因?yàn)檫@個(gè)超時(shí)過期帶來了很多的問題場(chǎng)景。
并且可以直接通過zk的順序節(jié)點(diǎn)和Watcher機(jī)制實(shí)現(xiàn)讀寫鎖、樂觀鎖;
Watcher機(jī)制:通過Watcher機(jī)制,客戶端可以向如下的/read_write_lock目錄節(jié)點(diǎn)注冊(cè)子節(jié)點(diǎn)變更的Watcher監(jiān)聽,這樣當(dāng)該目錄下子節(jié)點(diǎn)發(fā)生增減時(shí),zk會(huì)將該事件通知所有注冊(cè)的客戶端;
順序節(jié)點(diǎn):在順序節(jié)點(diǎn)目錄下的子節(jié)點(diǎn),zk會(huì)為節(jié)點(diǎn)維護(hù)創(chuàng)建的先后順序,并在節(jié)點(diǎn)名稱后綴中增加節(jié)點(diǎn)創(chuàng)建的次序值:

-
首先需要定義一個(gè)機(jī)制用于區(qū)分讀寫請(qǐng)求,可以在寫入節(jié)點(diǎn)值時(shí)增加Read or Write進(jìn)行區(qū)分;因?yàn)轫樞蚬?jié)點(diǎn)后綴大小標(biāo)識(shí)了請(qǐng)求的先后性,如上圖所示:表明zk先后收到了兩個(gè)獲取Read鎖、一個(gè)獲取Wrtie鎖的請(qǐng)求;
-
接著對(duì)于一個(gè)進(jìn)程獲取讀鎖的請(qǐng)求,如果此時(shí)/read_write_lock目錄下沒有包含Write的節(jié)點(diǎn),則直接創(chuàng)建節(jié)點(diǎn)并返回獲取成功;如果此時(shí)存在獲取讀鎖的節(jié)點(diǎn),則獲取失敗,不過仍然會(huì)在目錄下創(chuàng)建節(jié)點(diǎn),但需要在該寫鎖節(jié)點(diǎn)上注冊(cè)Watcher監(jiān)聽,當(dāng)該寫鎖節(jié)點(diǎn)刪除后,原請(qǐng)求進(jìn)程可以嘗試重新獲取讀鎖;
-
對(duì)于一個(gè)獲取寫鎖的請(qǐng)求,如果此時(shí)/read_write_lock目錄下Write節(jié)點(diǎn)已經(jīng)是存活的后綴最小的節(jié)點(diǎn),則獲取寫鎖成功;如果在該請(qǐng)求前仍然存在其他節(jié)點(diǎn),則獲取寫鎖失敗,需要在該目錄下后綴不大于該寫請(qǐng)求的節(jié)點(diǎn)上注冊(cè)Watcher通知,這樣當(dāng)該節(jié)點(diǎn)釋放后,則請(qǐng)求進(jìn)程可以再次嘗試獲取寫鎖。
zookeeper實(shí)現(xiàn)分布式鎖的一些問題
當(dāng)然通過zk實(shí)現(xiàn)分布式鎖仍然存在很多問題,我們同樣按照網(wǎng)絡(luò)延遲、進(jìn)程暫停的角度進(jìn)行分析;-
進(jìn)程1創(chuàng)建臨時(shí)節(jié)點(diǎn)/exclusive_lock/lock成功,拿到了鎖
-
進(jìn)程1因?yàn)闄C(jī)器長(zhǎng)時(shí)間GC而暫停
-
進(jìn)程1無法給 Zookeeper 發(fā)送心跳,Zookeeper將臨時(shí)節(jié)點(diǎn)刪除
-
進(jìn)程2創(chuàng)建臨時(shí)節(jié)點(diǎn)/exclusive_lock/lock 成功,拿到了鎖
-
進(jìn)程1機(jī)器GC結(jié)束后恢復(fù),它仍然認(rèn)為自己持有鎖(產(chǎn)生沖突)
可以使用類似Redisson的續(xù)約機(jī)制,通過一個(gè)守護(hù)線程進(jìn)行zk臨時(shí)節(jié)點(diǎn)的維護(hù);但是zk不會(huì)存在釋放了別人鎖的情況,所以不需要通過類似Lua的機(jī)制來釋放鎖;
fencing token算法
上述問題的關(guān)鍵在于進(jìn)程在喚醒后,仍然以為自己持有鎖并進(jìn)行共享資源的操作;因?yàn)椴僮飨到y(tǒng)中進(jìn)程的切換或崩潰后恢復(fù),只會(huì)在原有的執(zhí)行序列位置繼續(xù)執(zhí)行,自然不可能自發(fā)的在喚醒后重新檢查自己是否仍然持有鎖;因此fencing算法提出了讓共享資源具有拒絕持有過期鎖的進(jìn)程發(fā)起的請(qǐng)求的能力:
-
fencing算法通過給鎖加上一個(gè)序列,即每次請(qǐng)求進(jìn)程成功獲取鎖時(shí),鎖服務(wù)都會(huì)返回一個(gè)遞增的token;
-
接著請(qǐng)求進(jìn)程拿著這個(gè)token去操作共享資源;
-
共享資源緩存token,并拒絕token值較小的客戶端請(qǐng)求。
通過該算法,可以解決上述的持有鎖后進(jìn)程暫停帶來的影響;不過如果持有過期鎖的進(jìn)程操作共享資源并沒有先后因果關(guān)系時(shí),可以無需考慮使用該算法,該算法存在一定的代價(jià)。
總結(jié)
一般都是使用分布式鎖用作互斥,上述文章中列舉了NPC場(chǎng)景下的一些問題,并都給出了相應(yīng)的解決方案;具體使用時(shí)可以考慮場(chǎng)景本身對(duì)于數(shù)據(jù)絕對(duì)正確的敏感度,決定是否要使用代價(jià)更大的機(jī)制來進(jìn)行保證。當(dāng)然RedLock還是不推薦使用,代價(jià)太大,還是建議使用主從 哨兵的機(jī)制進(jìn)行redis集群的搭建。