一文徹底弄清楚分布式鎖
關(guān)于實現(xiàn)強(qiáng)一致性的手段,可以使用多種方式來進(jìn)行實現(xiàn)盈魁,有分布式事務(wù)杨耙,有一致性算法飘痛,還有分布式鎖等等宣脉,那么這篇文章我們就圍繞分布式鎖這個話題來進(jìn)行展開,首先竹祷,我們會先探究它的原理羊苟,然后結(jié)合實際應(yīng)用蜡励,對目前較為常見的分布式鎖實現(xiàn)方式及注意事項進(jìn)行詳細(xì)的分析。
首先彭则、大家可以先思考三個問題俯抖?
什么時候需要加鎖
分布式鎖有哪些特征
如何用數(shù)據(jù)庫實現(xiàn)分布式鎖
如何用Redis實現(xiàn)分布式鎖(單機(jī)版+集群版)
什么時候需要加鎖
我們先給出答案:
有并發(fā)芬萍,多線程(這里指的是資源的使用者多搔啊,也就是在多任務(wù)環(huán)境下才可能需要鎖的存在负芋,多個任務(wù)想同時使用一個資源才有競爭的可能)
有寫操作(這里指的是資源的使用目的,如果是多個任務(wù)都是讀請求的話莽龟,那反正這個資源就在那里蠕嫁,沒有人改它,不同任務(wù)來讀取的結(jié)果都是一樣的毯盈,也就沒有必要去控制誰先讀誰后讀)
有競爭關(guān)系(這里指的是對資源的訪問方式是互斥的剃毒,我們這個資源雖然是共享的,同但一時刻只能有一個任務(wù)占用它搂赋,不能同時共用赘阀,只能有一個任務(wù)占有它,這個時候我們需要給它上鎖)
這么說可能有些同學(xué)覺得抽象脑奠,我舉個栗子,可能不是很優(yōu)雅但是比較形象:
就比如說你家里有一個廁所坑位捺信,我們叫它為共享資源酌媒,你可以上,你家里人也可以上迄靠。好秒咨,如果現(xiàn)在家里只有你一個人,是不是你想什么時候上就什么時候上掌挚?想蹲多久都可以雨席,想在里面睡覺也可以。這個時候就不存在競爭了吠式,你不鎖門也沒人闖進(jìn)來陡厘。然后,如果現(xiàn)在家里不止你一個人了特占,你的家里人也在了糙置,但是呢,大家都不需要蹲坑是目,可能只是都想來看看有沒有人在里面而已谤饭,看完就走了,那這個時候你還用不用進(jìn)去然后鎖門懊纳,然后再出來揉抵。最后,家里有多個人然后都憋不住了嗤疯,是不是就得搶坑位了冤今,先搶到的人,為了能夠安靜地順利地茂缚。戏罢。屋谭。它就需要把門鎖上了,免得其他人進(jìn)來干擾龟糕。
這么說可以理解了吧戴而,一多二寫三互斥,如果還不理解就把上面的場景再腦補(bǔ)一下翩蘸。
那如何上鎖呢?
在單機(jī)環(huán)境下淮逊,也就是單個JVM環(huán)境下多線程對共享資源的并發(fā)更新處理催首,我們可以簡單地使用JDK提供的ReentrantLock對共享資源進(jìn)行加鎖處理。
ReentrantLock lock = new ReentrantLock();
try {
lock.lock();
//處理共享資源
} finally {
lock.unlock();
}</pre>
那如果是在微服務(wù)架構(gòu)多實例的環(huán)境下泄鹏,每一個服務(wù)都有多個節(jié)點郎任,我們?nèi)绻€是按照之前的方式來做,就會出現(xiàn)這樣的情況:
這個時候再用ReentrantLock就沒辦法控制了备籽,因為這時候這些任務(wù)是跨JVM的舶治,不再是簡單的單體應(yīng)用了,需要協(xié)同多個節(jié)點信息车猬,共同獲取鎖的競爭情況霉猛。
這時候就需要另一種形式的鎖——分布式鎖:
通常是把鎖和應(yīng)用分開部署,把這個鎖做成一個公用的組件珠闰,然后多個不同應(yīng)用的不同節(jié)點惜浅,都去共同訪問這個組件(這個組件有多種實現(xiàn)方式,有些可能并不是嚴(yán)格意義上的分布式鎖伏嗜,這里為了方便演示坛悉,我們暫不做嚴(yán)格區(qū)分,統(tǒng)稱為分布式鎖)承绸。
分布式鎖實現(xiàn)方式
上面了解鎖概念和原理之后裸影,接下來我們就來看一看,分布式鎖比較常見的實現(xiàn)方式有哪些军熏,看一看它們之間具體有什么差異轩猩,理解它們各自的優(yōu)缺點,知道哪種實現(xiàn)方式更容易羞迷、哪種性能更高界轩、哪種運行更穩(wěn)定,我們才能夠在實際應(yīng)用中選擇合適的實現(xiàn)方式衔瓮。還是像我們前面說的那樣浊猾,并非一定要使用哪一種方式,合適最重要热鞍。
基于數(shù)據(jù)庫實現(xiàn)的分布式鎖
第一種方式葫慎,我們可以利用數(shù)據(jù)庫來實現(xiàn)衔彻,比如說我們創(chuàng)建一張表,每條記錄代表一個共享資源的鎖偷办,其中有一個status字段代表鎖的狀態(tài)艰额,L 代表 Locked ,U 代表 Unlocked椒涯。
那比如有一個線程要來更新商品庫存柄沮,它先根據(jù)商品ID找到代表該共享資源的鎖,然后執(zhí)行下面這個語句
update T t
set t.status = 'L'
where t.resource_id = '123456'
and t.owner = 'new owner'
and t.status = 'U';
如果這條語句執(zhí)行成功了并且返回的影響記錄數(shù)是1废岂,那么說明了獲取鎖成功了祖搓,就可以繼續(xù)執(zhí)行更新商品庫存的操作,然后釋放鎖時湖苞,則將status從 L 改為 U 即可.
我們上面只說了上鎖和解鎖操作拯欧,那如果這個鎖已經(jīng)被其他任務(wù)占用了,也就是 status = ‘L’财骨,這個時候這個語句就更新不到數(shù)據(jù)镐作,也就意味著獲取不到鎖,程序是不是只能等著隆箩,那要怎么等该贾?這是我們面臨的一個問題,因為數(shù)據(jù)庫和我們的應(yīng)用程序之間捌臊,除了發(fā)出執(zhí)行語句和返回結(jié)果靶庙,基本就沒有其他交互了,它很難給應(yīng)用程序發(fā)出通知娃属,這樣就很難做到通過事件監(jiān)聽機(jī)制來獲取到釋放鎖的事件六荒,所以程序只能輪詢地去嘗試獲取鎖.
這會導(dǎo)致一個致命的問題,就是這種類似自旋鎖的阻塞方式矾端,對數(shù)據(jù)庫資源消耗極大掏击,原本數(shù)據(jù)庫的性能相對較差,即便加上連接池秩铆,性能也遠(yuǎn)無法跟一些緩存中間件相比砚亭,而現(xiàn)在程序為了搶鎖拼命發(fā)出update語句,對數(shù)據(jù)庫性能來說更是雪上加霜殴玛,而在分布式環(huán)境中捅膘,尤其需要使用分布式鎖的場景,基本上都是要求支持高并發(fā)的滚粟,這就出現(xiàn)一個悖論了寻仗,這一點基本上也宣告了數(shù)據(jù)庫在大部分需要分布式鎖的場景中都用不上。
基于單機(jī)版Redis實現(xiàn)的分布式鎖
既然數(shù)據(jù)庫性能不夠好凡壤,我們看一下用緩存中間件署尤,也就是我們最經(jīng)常使用的Redis耙替,如果用來實現(xiàn)鎖要怎么樣做。Redis的特點就是性能非常好曹体,拿它跟數(shù)據(jù)庫比的話俗扇,你會發(fā)現(xiàn)它的性能好到爆炸。有些同學(xué)平時可能也有用過Redis來實現(xiàn)鎖箕别,但是你采用的實現(xiàn)方式很有可能并不是真正的分布式鎖铜幽,通常我們稱它為單機(jī)版的Redis鎖更合適,我們先來了解這個單機(jī)版的鎖串稀,因為這種實現(xiàn)方式在實際的應(yīng)用中也用的很多啥酱。后面再對比一下它與Redis作者提出的Redlock的具體區(qū)別。
對于單機(jī)版Redis鎖的實現(xiàn)主要有以下幾個步驟
第一步會先向Redis獲取鎖厨诸,然后返回是否獲取成功,如果獲取成功了那就可以開始操作共享資源禾酱,這段時間這個鎖就被占用了微酬。操作完成之后就可以釋放鎖,最后判斷一下鎖是否釋放成功颤陶。大體就分為獲取鎖颗管、使用鎖、還有釋放鎖這三大步驟滓走。
那么這三個步驟使用Redis是如何實現(xiàn)呢垦江?
首先獲取鎖,獲取鎖只需要下面這一條命令即可
SET key value NX PX|EX time
變量 | 解析 | |
---|---|---|
key | 這個是作為鎖的唯一標(biāo)識搅方,用于獲取和釋放鎖比吭,為了在不同使用者之間保持一致,直接以共享資源命名會更好姨涡。 | |
value | 這個是作為使用者的唯一標(biāo)識衩藤,用來表示當(dāng)前持有鎖的是具體哪個使用者,可以起到一個標(biāo)記的作用涛漂,為什么要這樣呢赏表,一會我們看一下鎖的釋放就知道。 | |
NX | 它是Redis的語義匈仗,表示這個key不存在的時候才能set成功瓢剿,這里起到了互斥性的保證,滿足一個鎖最基本的特性悠轩。(如果不加這個語義限制间狂,那么第一個線程獲取鎖之后,任務(wù)還沒執(zhí)行完火架,第二個線程再來獲取前标,就會把值給覆蓋掉坠韩,那么就起不到互斥的效果。) | |
PX | EX | 是緩存過期時間的設(shè)置炼列,表示多少毫秒或者多少秒過期只搁,是一個時間單位的區(qū)別 |
那如何釋放鎖呢,通常我們會使用引入Lua腳本俭尖,我們看一下下面這個語句塊
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
那么這個lua腳本的語義是執(zhí)行這個腳本時氢惋,當(dāng)輸入的KEYS[1]在Redis里面的值等于輸入的AEGV[1]時,則刪除這個原有的KEY稽犁,即代表釋放鎖操作(這里查不到返回0焰望,也可能是因為鎖已經(jīng)過期了,前面我們獲取鎖的時候設(shè)置了過期時間)
KEYS[1]:它代表的是獲取鎖時輸入的key已亥,也就是共享資源名稱
ARGV[1]:它代表的是獲取鎖時輸入的value熊赖,這個value的唯一性決定了使用者只能刪除自身已經(jīng)獲取的鎖,不會誤刪除別人的虑椎。
我們可以看到上面代碼里面有一個判斷震鹉,要保證獲取鎖和釋放鎖是同一個使用者。比如說有這種情況:
有一個客戶端A獲取到鎖之后去執(zhí)行業(yè)務(wù)操作捆姜,然后由于某些原因传趾,這個操作的時間,耗時比較長泥技,超過了鎖的有效期浆兰,這個時候鎖就自動釋放了,那么這個時候另一個客戶端B可能馬上就獲取到鎖珊豹,然后也去執(zhí)行業(yè)務(wù)邏輯簸呈,在它還沒執(zhí)行完的時候,客戶端A的流程處理完了店茶,然后就執(zhí)行到釋放鎖的步驟蝶棋,這個時候如果沒有上面說的那個判斷,那么就有可能發(fā)生這樣的情況:客戶端A忽妒,把客戶端B持有的鎖玩裙,給釋放掉了!
那么除了正常的獲取鎖和釋放鎖之外段直,單機(jī)版的Redis鎖有沒有哪些地方需要注意的呢吃溅?我們先來思考一下這個問題:
為什么需要設(shè)置緩存的過期時間?這里是作為鎖的有效期
定義了這個鎖鸯檬,它對應(yīng)的操作在正常情況下所需要的操作時間决侈,如果超過了這個時間,鎖就會被自動釋放掉
我們想象一下這種場景喧务,當(dāng)一個使用者獲取鎖成功之后赖歌,假如它崩潰了(導(dǎo)致它崩潰有很多原因比如發(fā)生網(wǎng)絡(luò)分區(qū)枉圃,應(yīng)用發(fā)生GC把業(yè)務(wù)執(zhí)行流程給阻塞住了,或者時鐘發(fā)生變化導(dǎo)致它無法和Redis節(jié)點進(jìn)行通信庐冯,發(fā)生這些情況我們就簡單說它崩潰了)這時會發(fā)生什么情況呢孽亲,這個時候這個對應(yīng)的鎖就一直不會過期了,因為有互斥的機(jī)制所以其他使用者嘗試獲取鎖都set不成功展父,也無辦法釋放返劲,因為釋放時會判斷使用者是否是鎖的持有者。因此我們可以看到栖茉,獲取鎖一定要給它設(shè)置過期時間篮绿,也就是這個鎖是有租期的,使用者必須在這個規(guī)定的租期內(nèi)完成對共享資源的操作吕漂,租期一到亲配,如果使用者沒有主動釋放,那么鎖也會自動過期惶凝。
那第二個問題吼虎,為什么釋放鎖的時候,要引入Lua腳本梨睁?
這里我們先說一下結(jié)論,再來解釋一下為什么娜饵。其實這里是為了保證操作原子性坡贺。包括獲取鎖的set命令,也需要原子性的保障箱舞。
假如不考慮原子性遍坟,我們上面的獲取鎖和釋放鎖,按照功能邏輯的話晴股,是不是換成以下的寫法也可以:
SET key value NX PX|EX time
=>
set key value;
expire key time;
code
=>
get key == value
del key</pre>
這樣會有什么問題呢愿伴,我們先看看獲取鎖的命令,使用者執(zhí)行第一條成功了才會執(zhí)行第二條电湘,那如果執(zhí)行第一條成功之后使用者崩潰了隔节,當(dāng)它再連上的時候是不是就變成了我們上面說的那種情況,沒有設(shè)置鎖的過期時間寂呛。
那釋放鎖的過程怎诫,拆成兩條命令之后,又會導(dǎo)致什么問題呢贷痪,我們來看一下這種場景
假如使用者A完成任務(wù)之后準(zhǔn)備釋放自己持有的鎖幻妓,它先通過get key得到一個值,用來判斷出這個鎖確實是自己持有的鎖劫拢,并且還沒有釋放肉津,這時候A由于某種原因强胰,它還是崩潰了,造成崩潰的原因我們上面說了有多種情況妹沙,就阻塞了一段時間偶洋,在這段時間鎖恰好因為超時自動釋放掉了,然后初烘,使用者B剛好來獲取鎖涡真,也就是執(zhí)行了上面的set命令,然后呢使用者A恢復(fù)了肾筐,比如GC完成哆料,然后 就開始執(zhí)行它的第二步操作,也就是del key操作吗铐,那是不是剛好东亦,就把使用者B的鎖給刪除掉了。相當(dāng)于鎖的持有者和釋放者就不一致了唬渗,從而導(dǎo)致了鎖狀態(tài)出現(xiàn)錯亂典阵。
前面我們從鎖的獲取和釋放流程,結(jié)合Redis命令的特性镊逝,分析了單機(jī)版Redis為什么要這么實現(xiàn)壮啊,分析了這種實現(xiàn)方式的必要性以及可能出現(xiàn)的異常場景。那么我們再從更宏觀的維度來看撑蒜,這種單機(jī)版Redis鎖最大的風(fēng)險是什么呢歹啼?
如果這個Redis實例掛了,那就意味著整個鎖機(jī)制失效了座菠,這時使用者無法獲取和釋放鎖狸眼,進(jìn)一步導(dǎo)致使用者無法正常使用共享資源,從而出現(xiàn)阻塞浴滴、訪問失敗或者訪問沖突等異常拓萌;還有可能因為共享資源失去了鎖的保護(hù) ,引起數(shù)據(jù)不一致升略,導(dǎo)致業(yè)務(wù)上一系列連鎖反應(yīng)微王。
那如何規(guī)避這種單點的問題呢?
有的同學(xué)可能會首先想到使用持久化機(jī)制品嚣。
那么這種方式其實是通過利用Redis本身的AOF持久化機(jī)制骂远,來保存每一條請求,如果Redis掛了腰根,這個時候直接重新拉起激才,再通過AOF文件進(jìn)行數(shù)據(jù)恢復(fù)。
但這種方式還是有一些缺點的:
假如說我們把AOF的同步機(jī)制設(shè)置為每秒鐘同步一次,那這種情況下Redis的AOF持久化機(jī)制并不能保證完全不丟數(shù)據(jù)瘸恼,也就是可能恢復(fù)之后少了某個鎖的數(shù)據(jù)劣挫,這樣其他使用者就可以獲取到這個鎖,導(dǎo)致狀態(tài)錯亂
假如說我們把它設(shè)置為Always东帅,就是每個操作都要同步压固,這樣的話會嚴(yán)重降低Redis的性能,發(fā)揮不出它的優(yōu)勢靠闭。
還有一點就是AOF文件的恢復(fù)一般比較耗時帐我,這個時間不可控,取決于文件的大小愧膀,也就是文件越大拦键,所需要的恢復(fù)時間越長,那恢復(fù)期間鎖就是不可用的狀態(tài)檩淋。
第二種是使用主從高可用芬为,將單點變成多點模式來解決單點故障的風(fēng)險,也就是:
使用主從(或者一主多從)進(jìn)行高可用部署蟀悦,當(dāng)主節(jié)點掛了媚朦,從節(jié)點接手相關(guān)任務(wù)并保持鎖機(jī)不變。
那這種方式也是存在一些問題的:
首先主從復(fù)制它是異步的日戈,所以這種方式也會存在數(shù)據(jù)丟失的風(fēng)險询张。然后主從高可用機(jī)制它發(fā)現(xiàn)主節(jié)點不可用,到完成主從切換也是需要一定時間的浙炼,這個時間跟鎖的過期時間需要平衡好份氧,否則當(dāng)從節(jié)點接受之后,這個鎖的狀態(tài)及正確性是不可控的鼓拧。
從上面的分析我們可以看到半火,單機(jī)版Redis在高可用方面還是存在不少問題的越妈。如果我們的應(yīng)用場景需要支持高并發(fā)季俩,并且對它在這些特殊情況下的問題可以容忍的話,那用這種方式也沒有問題梅掠,比較它的實現(xiàn)方式相對簡單酌住,并且性能也比較好,所以主要還是要結(jié)合業(yè)務(wù)場景來進(jìn)行選擇阎抒。
那么有沒有更具高可用的分布式鎖實現(xiàn)方式呢酪我?接下來我們繼續(xù)介紹Redlock的運行原理和機(jī)制,它在高可用性方面有更好的保障且叁,當(dāng)然相對也有一些實現(xiàn)代價都哭,相比之下它會復(fù)雜一些。
基于Redis的高可用分布式鎖——RedLock
RedLock基本情況
Redis作者提出來的高可用分布式鎖
由多個完全獨立的Redis節(jié)點組成,注意是完全獨立欺矫,而不是主從關(guān)系或者集群關(guān)系纱新,并且一般是要求分開機(jī)器部署的
利用分布式高可以系統(tǒng)中大多數(shù)存活即可用的原則來保證鎖的高可用
針對每個單獨的節(jié)點,獲取鎖和釋放鎖的操作穆趴,完全采用我們上面描述的單機(jī)版的方式
RedLock工作流程
獲取鎖
獲取當(dāng)前時間T1脸爱,作為后續(xù)的計時依據(jù);
-
按順序地未妹,依次向5個獨立的節(jié)點來嘗試獲取鎖
- (SET resource_name my_random_value NX PX 30000)
-
計算獲取鎖總共花了多少時間簿废,判斷獲取鎖成功與否
時間:T2-T1
多數(shù)節(jié)點的鎖(N/2+1)
當(dāng)獲取鎖成功后的有效時間,要從初始的時間減去第三步算出來的消耗時間
如果沒能獲取鎖成功络它,盡快釋放掉鎖族檬。
這里需要注意兩點:
- 為什么要順序地向節(jié)點發(fā)起命令,那么我們反過來想酪耕,假如不順序地發(fā)起命令會產(chǎn)生什么問題导梆?那么我們想一下假如有3個客戶端同時來搶鎖,客戶端A先獲取到1號和2號節(jié)點迂烁,客戶端B先獲取到3號4號節(jié)點看尼,客戶端C先獲取到5號節(jié)點,那么這時候就滿足不了多數(shù)原則盟步,5個節(jié)點的情況下藏斩,最少需要3個節(jié)點都獲取到鎖,才可以滿足
- 客戶端在向每個節(jié)點嘗試獲取鎖的時候却盘,有一個超時時間限制狰域,而且這個時間遠(yuǎn)小于鎖的有效期,比如說幾毫秒到幾十毫秒之間黄橘,這樣的機(jī)制是為了防止在向某一個節(jié)點獲取鎖的時候兆览,等待的時間過長,從而導(dǎo)致獲取鎖的整體時間過長塞关。比如說在獲取鎖的時候抬探,有的節(jié)點會出現(xiàn)問題導(dǎo)致連接不上,那么這個時候就應(yīng)該盡快地轉(zhuǎn)移到下一個節(jié)點繼續(xù)嘗試帆赢,因為最終的結(jié)果我們只需要滿足多數(shù)可用原則即可
釋放鎖
向所有節(jié)點發(fā)起釋放鎖的操作小压,不管這些節(jié)點有沒有成功設(shè)置過
正常情況下RedLock的運行狀態(tài)
client1和client2,對Redis節(jié)點A-E進(jìn)行搶鎖操作椰于,如圖怠益,client1先搶到節(jié)點ABC,超過半數(shù)瘾婿,因此持有分布式鎖蜻牢,在持有鎖期間烤咧,client2搶鎖都是失敗的,當(dāng)時序=6時抢呆,client1才處理完業(yè)務(wù)流程釋放分布式鎖髓削,這時候client2才有可能搶鎖成功。
那么RedLock的主要流程就是這樣镀娶,獲取鎖和釋放鎖立膛,那么這個號稱是真正的分布式鎖,它相比前面單機(jī)版的鎖梯码,很明顯的一個點就是它不再是單點的是吧宝泵,所以在高可用性上面,它是比單機(jī)版的鎖有提升的轩娶。
但是儿奶,RedLock 是否就是一個很完美的解決方案呢?在一些特殊場景下會不會存在什么不足的地方鳄抒?你也可以思考思考闯捎,歡迎留言交流。
此外许溅,除了redis以外 瓤鼻,其實我們可以用ZooKeeper來實現(xiàn)分布式鎖 。實際上這Redis實現(xiàn)分布式鎖的方式雖然性能比較高贤重,但是在一些特殊場景下茬祷,它還是不夠健壯,相比之下并蝗,ZooKeeper
它的設(shè)計定位就是用來做分布式協(xié)調(diào)的工作祭犯,更加注重一致性,非常適合用來做分布式鎖滚停,總的來說使用ZooKeeper
去實現(xiàn)分布式鎖相比Redis的話會更加健壯一些沃粗。具體的方案和實現(xiàn)方式需要對ZooKeeper有一些了解,這個后面相關(guān)篇章再作介紹键畴。