分布式鎖的由來
現(xiàn)在的業(yè)務(wù)應(yīng)用通常都是微服務(wù)架構(gòu)十偶,這也意味著一個(gè)應(yīng)用會(huì)部署多個(gè)進(jìn)程蚂会,那這多個(gè)進(jìn)程如果需要修改 MySQL 中的同一行記錄時(shí)转捕,為了避免操作亂序?qū)е聰?shù)據(jù)錯(cuò)誤作岖,此時(shí),我們就需要引入「分布式鎖」來解決這個(gè)問題了五芝。想要實(shí)現(xiàn)分布式鎖痘儡,必須借助一個(gè)外部系統(tǒng),所有進(jìn)程都去這個(gè)系統(tǒng)上申請「加鎖」枢步。而這個(gè)外部系統(tǒng)沉删,必須要實(shí)現(xiàn)「互斥」的能力,即兩個(gè)請求同時(shí)進(jìn)來醉途,只會(huì)給一個(gè)進(jìn)程返回成功矾瑰,另一個(gè)返回失敗(或等待)隘擎。這個(gè)外部系統(tǒng)殴穴,可以是 MySQL,也可以是 Redis 或 Zookeeper货葬。但為了追求更好的性能采幌,我們通常會(huì)選擇使用 Redis 或 Zookeeper 來做。
如何實(shí)現(xiàn)分布式鎖
想要實(shí)現(xiàn)分布式鎖震桶,必須要求 Redis 有「互斥」的能力休傍,我們可以使用 SETNX 命令,這個(gè)命令表示SET if Not eXists蹲姐,即如果 key 不存在磨取,才會(huì)設(shè)置它的值,否則什么也不做淤堵。兩個(gè)客戶端進(jìn)程可以執(zhí)行這個(gè)命令寝衫,達(dá)到互斥,就可以實(shí)現(xiàn)一個(gè)分布式鎖拐邪。
客戶端 1 申請加鎖慰毅,加鎖成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客戶端1,加鎖成功
客戶端 2 申請加鎖扎阶,因?yàn)樗蟮竭_(dá)汹胃,加鎖失敗:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客戶端2东臀,加鎖失敗
此時(shí)着饥,加鎖成功的客戶端,就可以去操作「共享資源」惰赋,例如宰掉,修改 MySQL 的某一行數(shù)據(jù)呵哨,或者調(diào)用一個(gè) API 請求。操作完成后轨奄,還要及時(shí)釋放鎖孟害,給后來者讓出操作共享資源的機(jī)會(huì)。如何釋放鎖呢挪拟?也很簡單挨务,直接使用 DEL 命令刪除這個(gè) key 即可:
127.0.0.1:6379> DEL lock // 釋放鎖
(integer) 1
但是,它存在一個(gè)很大的問題玉组,當(dāng)客戶端 1 拿到鎖后谎柄,如果發(fā)生下面的場景,就會(huì)造成「死鎖」:
- 程序處理業(yè)務(wù)邏輯異常惯雳,沒及時(shí)釋放鎖
- 進(jìn)程掛了朝巫,沒機(jī)會(huì)釋放鎖
避免死鎖的方案
在申請鎖時(shí),給這把鎖設(shè)置一個(gè)「租期」吨凑。在 Redis 中實(shí)現(xiàn)時(shí)就是給這個(gè) key 設(shè)置一個(gè)「過期時(shí)間」捍歪。這里我們假設(shè)操作共享資源的時(shí)間不會(huì)超過 10s户辱,那么在加鎖時(shí)鸵钝,給這個(gè) key 設(shè)置 10s 過期即可:
127.0.0.1:6379> SETNX lock 1 // 加鎖
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10s后自動(dòng)過期
(integer) 1
這樣一來,無論客戶端是否異常庐镐,這個(gè)鎖都可以在 10s 后被「自動(dòng)釋放」恩商,其它客戶端依舊可以拿到鎖。如果這樣操作的話加鎖必逆、設(shè)置過期是 2 條命令怠堪,有沒有可能只執(zhí)行了第一條,第二條卻「來不及」執(zhí)行的情況發(fā)生呢名眉?例如:
- SETNX 執(zhí)行成功粟矿,執(zhí)行 EXPIRE 時(shí)由于網(wǎng)絡(luò)問題執(zhí)行失敗
- SETNX 執(zhí)行成功,Redis 異常宕機(jī)损拢,EXPIRE 沒有機(jī)會(huì)執(zhí)行
- SETNX 執(zhí)行成功陌粹,客戶端異常崩潰,EXPIRE 也沒有機(jī)會(huì)執(zhí)行
總之福压,這兩條命令不能保證是原子操作(一起成功)掏秩,就有潛在的風(fēng)險(xiǎn)導(dǎo)致過期時(shí)間設(shè)置失敗,依舊發(fā)生「死鎖」問題荆姆。
在 Redis 2.6.12 版本之前蒙幻,我們需要想盡辦法,保證 SETNX 和 EXPIRE 原子性執(zhí)行胆筒,還要考慮各種異常情況如何處理邮破。但在 Redis 2.6.12 之后,Redis 擴(kuò)展了 SET 命令的參數(shù),用這一條命令就可以了:
// 一條命令保證原子性執(zhí)行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
這樣就解決了死鎖問題抒和,也比較簡單队询。
試想這樣一種場景:
客戶端 1 加鎖成功開始操作共享資源,客戶端 1 操作共享資源的時(shí)間构诚,「超過」了鎖的過期時(shí)間蚌斩,鎖被「自動(dòng)釋放」
客戶端 2 加鎖成功,開始操作共享資源
客戶端 1 操作共享資源完成范嘱,釋放鎖(但釋放的是客戶端 2 的鎖)
這里存在兩個(gè)嚴(yán)重的問題:
- 鎖過期:客戶端 1 操作共享資源耗時(shí)太久送膳,導(dǎo)致鎖被自動(dòng)釋放,之后被客戶端 2 持有
- 釋放別人的鎖:客戶端 1 操作共享資源完成后丑蛤,卻又釋放了客戶端 2 的鎖
第一個(gè)問題叠聋,可能是我們評估操作共享資源的時(shí)間不準(zhǔn)確導(dǎo)致的。
例如受裹,操作共享資源的時(shí)間「最慢」可能需要 15s碌补,而我們卻只設(shè)置了 10s 過期,那這就存在鎖提前過期的風(fēng)險(xiǎn)棉饶。過期時(shí)間太短厦章,那增大冗余時(shí)間,例如設(shè)置過期時(shí)間為 20s照藻,這樣總可以了吧袜啃?這樣確實(shí)可以「緩解」這個(gè)問題,降低出問題的概率幸缕,但依舊無法「徹底解決」問題群发。原因在于,客戶端在拿到鎖之后发乔,在操作共享資源時(shí)熟妓,遇到的場景有可能是很復(fù)雜的,例如栏尚,程序內(nèi)部發(fā)生異常起愈、網(wǎng)絡(luò)請求超時(shí)等等。既然是「預(yù)估」時(shí)間抵栈,也只能是大致計(jì)算告材,除非你能預(yù)料并覆蓋到所有導(dǎo)致耗時(shí)變長的場景,但這其實(shí)很難古劲。
第二個(gè)問題在于斥赋,一個(gè)客戶端釋放了其它客戶端持有的鎖。
重點(diǎn)在于产艾,每個(gè)客戶端在釋放鎖時(shí)疤剑,都是「無腦」操作滑绒,并沒有檢查這把鎖是否還「歸自己持有」,所以就會(huì)發(fā)生釋放別人鎖的風(fēng)險(xiǎn)隘膘,這樣的解鎖流程疑故,很不「嚴(yán)謹(jǐn)」!
如何解決鎖的唯一性
解決辦法是:客戶端在加鎖時(shí)弯菊,設(shè)置一個(gè)只有自己知道的「唯一標(biāo)識(shí)」進(jìn)去纵势。例如,可以是自己的線程 ID管钳,也可以是一個(gè) UUID(隨機(jī)且唯一)钦铁。
// 鎖的VALUE設(shè)置為UUID
// 假設(shè) 20s 操作共享時(shí)間完全足夠,先不考慮鎖自動(dòng)過期的問題才漆。
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
在釋放鎖時(shí)牛曹,要先判斷這把鎖是否還歸自己持有再釋放鎖操作。
if redis.get("lock") == $uuid:
redis.del("lock")
這里釋放鎖使用的是 GET + DEL 兩條命令醇滥,這時(shí)又會(huì)遇到我們前面講的原子性問題了黎比。
- 客戶端 1 執(zhí)行 GET,判斷鎖是自己的
- 客戶端 2 執(zhí)行了 SET 命令鸳玩,強(qiáng)制獲取到鎖(雖然發(fā)生概率比較低阅虫,但我們需要嚴(yán)謹(jǐn)?shù)乜紤]鎖的安全性模型)
- 客戶端 1 執(zhí)行 DEL,卻釋放了客戶端 2 的鎖
由此可見怀喉,這兩個(gè)命令還是必須要原子執(zhí)行才行书妻。
我們可以把這個(gè)邏輯船响,寫成 Lua 腳本躬拢,讓 Redis 來執(zhí)行。因?yàn)?Redis 處理每一個(gè)請求是「單線程」執(zhí)行的见间,在執(zhí)行一個(gè) Lua 腳本時(shí)聊闯,其它請求必須等待,直到這個(gè) Lua 腳本處理完成米诉,這樣一來菱蔬,GET + DEL 之間就不會(huì)插入其它命令了。
// 判斷鎖是自己的史侣,才釋放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
基于 Redis 實(shí)現(xiàn)的分布式鎖拴泌,一個(gè)嚴(yán)謹(jǐn)?shù)牡牧鞒倘缦拢?br>
- 加鎖:SET lock_key expire_time NX
- 操作共享資源
- 釋放鎖:Lua 腳本,先 GET 判斷鎖是否歸屬自己惊橱,再 DEL 釋放鎖