分布式技術(shù)原理與實(shí)戰(zhàn)45講--第11講:如何使用 Redi 快速實(shí)現(xiàn)分布式鎖
本課時(shí)我們來(lái)討論如何使用 Redis 快速實(shí)現(xiàn)分布式鎖。
分布式鎖有很多種解決方案,前面簡(jiǎn)單介紹過(guò),Redis 可以通過(guò) set key 方式來(lái)實(shí)現(xiàn)分布式鎖,但實(shí)際情況要更加復(fù)雜,比如如何確保臨界資源的串行執(zhí)行,如何及時(shí)釋放,都是需要額外考慮的。
今天這一課時(shí)要講的是一個(gè)完備的分布式鎖應(yīng)該具備哪些特性,以及如何使用 Redis 來(lái)一步步優(yōu)化實(shí)現(xiàn)。
分布式鎖需要具有哪些特點(diǎn)
先來(lái)看一下,一個(gè)完備的分布式鎖,需要支持哪些特性?

一般來(lái)說(shuō),生產(chǎn)環(huán)境可用的分布式鎖需要滿足以下幾點(diǎn):
互斥性,互斥是鎖的基本特征,同一時(shí)刻只能有一個(gè)線程持有鎖,執(zhí)行臨界操作;
超時(shí)釋放,超時(shí)釋放是鎖的另一個(gè)必備特性,可以對(duì)比 MySQL InnoDB 引擎中的 innodb_lock_wait_timeout 配置,通過(guò)超時(shí)釋放,防止不必要的線程等待和資源浪費(fèi);
可重入性,在分布式環(huán)境下,同一個(gè)節(jié)點(diǎn)上的同一個(gè)線程如果獲取了鎖之后,再次請(qǐng)求還是可以成功;
高性能和高可用,加鎖和解鎖的開(kāi)銷(xiāo)要盡可能的小,同時(shí)也需要保證高可用,防止分布式鎖失效;
支持阻塞和非阻塞性,對(duì)比 Java 語(yǔ)言中的 wait() 和 notify() 等操作,這個(gè)一般是在業(yè)務(wù)代碼中實(shí)現(xiàn),比如在獲取鎖時(shí)通過(guò) while(true) 或者輪詢來(lái)實(shí)現(xiàn)阻塞操作。
可以看到,實(shí)現(xiàn)一個(gè)相對(duì)完備的分布式鎖,并不是鎖住資源就可以了,還需要滿足一些額外的特性,否則會(huì)在業(yè)務(wù)開(kāi)發(fā)中出現(xiàn)各種各樣的問(wèn)題。
下面我們以 Redis 實(shí)現(xiàn)分布式鎖為例,看一下如何優(yōu)化分布式鎖的具體實(shí)現(xiàn)。
使用 setnx 實(shí)現(xiàn)分布式鎖
Redis 支持 setnx 指令,只在 key 不存在的情況下,將 key 的值設(shè)置為 value,若 key 已經(jīng)存在,則 setnx 命令不做任何動(dòng)作。使用 setnx 實(shí)現(xiàn)分布式鎖的方案,獲取鎖的方法很簡(jiǎn)單,只要以該鎖為 key,設(shè)置一個(gè)隨機(jī)的值即可。如果 setnx 返回 1,則說(shuō)明該進(jìn)程獲得鎖;如果 setnx 返回 0,則說(shuō)明其他進(jìn)程已經(jīng)獲得了鎖,進(jìn)程不能進(jìn)入臨界區(qū);如果需要阻塞當(dāng)前進(jìn)程,可以在一個(gè)循環(huán)中不斷嘗試 setnx 操作。
if(setnx(key,value)==1){ ? ? try{ ? ? ? ?//業(yè)務(wù)處理 ? ? }finally{ ? ? ? //釋放鎖 ? ? ? del(key) ? ? }}
釋放鎖時(shí)只要?jiǎng)h除對(duì)應(yīng)的 key 就可以,為了防止系統(tǒng)業(yè)務(wù)進(jìn)程出現(xiàn)異常導(dǎo)致鎖無(wú)法釋放,使用 Java 中的 try-catch-finally 來(lái)完成鎖的釋放。
對(duì)比一下上面說(shuō)的分布式鎖特性,使用這種方式實(shí)現(xiàn)分布式鎖的問(wèn)題很明顯:不支持超時(shí)釋放鎖,如果進(jìn)程在加鎖后宕機(jī),則會(huì)導(dǎo)致鎖無(wú)法刪除,其他進(jìn)程無(wú)法獲得鎖。
使用 setnx 和 expire 實(shí)現(xiàn)
在分布式鎖的實(shí)現(xiàn)中,依賴(lài)業(yè)務(wù)線程進(jìn)行鎖的釋放,如果進(jìn)程宕機(jī),那么就會(huì)出現(xiàn)死鎖。Redis 在設(shè)置一個(gè) key 時(shí),支持設(shè)置過(guò)期時(shí)間,利用這一點(diǎn),可以在緩存中實(shí)現(xiàn)鎖的超時(shí)釋放,解決死鎖問(wèn)題。
在使用 setnx 獲取鎖之后,通過(guò) expire 給鎖加一個(gè)過(guò)期時(shí)間,利用 Redis 的緩存失效策略,進(jìn)行鎖的超時(shí)清除。
偽代碼如下:
if(setnx(key,value)==1){ ? ? expire(key,expireTime) ? ? try{ ? ? ? ?//業(yè)務(wù)處理 ? ? }finally{ ? ? ? //釋放鎖 ? ? ? del(key) ? ? }}
通過(guò)設(shè)置過(guò)期時(shí)間,避免了占鎖到釋放鎖的過(guò)程發(fā)生異常而導(dǎo)致鎖無(wú)法釋放的問(wèn)題,但是在 Redis 中,setnx 和 expire 這兩條命令不具備原子性。如果一個(gè)線程在執(zhí)行完 setnx 之后突然崩潰,導(dǎo)致鎖沒(méi)有設(shè)置過(guò)期時(shí)間,那么這個(gè)鎖就會(huì)一直存在,無(wú)法被其他線程獲取。
使用 set 擴(kuò)展命令實(shí)現(xiàn)
為了解決這個(gè)問(wèn)題,在 Redis 2.8 版本中,擴(kuò)展了 set 命令,支持 set 和 expire 指令組合的原子操作,解決了加鎖過(guò)程中失敗的問(wèn)題。
set 擴(kuò)展參數(shù)的語(yǔ)法如下:
redis> SET key value expireTime nx
nx 表示僅在鍵不存在時(shí)設(shè)置,這樣可以在同一時(shí)間內(nèi)完成設(shè)置值和設(shè)置過(guò)期時(shí)間這兩個(gè)操作,防止設(shè)置過(guò)期時(shí)間異常導(dǎo)致的死鎖。那么這種方式還存在問(wèn)題嗎?
使用 setex 方式看起來(lái)解決了鎖超時(shí)的問(wèn)題,但在實(shí)際業(yè)務(wù)中,如果對(duì)超時(shí)時(shí)間設(shè)置不合理,存在這樣一種可能:在加鎖和釋放鎖之間的業(yè)務(wù)邏輯執(zhí)行的太長(zhǎng),以至于超出了鎖的超時(shí)限制,緩存將對(duì)應(yīng) key 刪除,其他線程可以獲取鎖,出現(xiàn)對(duì)加鎖資源的并發(fā)操作。
我們來(lái)模擬下這種情況:
客戶端 A 獲取鎖的時(shí)候設(shè)置了 key 的過(guò)期時(shí)間為 2 秒,客戶端 A 在獲取到鎖之后,業(yè)務(wù)邏輯方法執(zhí)行了 3 秒;
客戶端 A 獲取的鎖被 Redis 過(guò)期機(jī)制自動(dòng)釋放,客戶端 B 請(qǐng)求鎖成功,出現(xiàn)并發(fā)執(zhí)行;
客戶端 A 執(zhí)行完業(yè)務(wù)邏輯后,釋放鎖,刪除對(duì)應(yīng)的 key;
對(duì)應(yīng)鎖已經(jīng)被客戶端 B 獲取到了,客戶端A釋放的鎖實(shí)際是客戶端B持有的鎖。
可以看到,第一個(gè)線程的邏輯還沒(méi)執(zhí)行完,第二個(gè)線程也成功獲得了鎖,加鎖的代碼或者資源并沒(méi)有得到嚴(yán)格的串行操作,同時(shí)由于疊加了刪除和釋放鎖操作,導(dǎo)致了加鎖的混亂。
如何避免這個(gè)問(wèn)題呢?首先,基于 Redis 的分布式鎖一般是用于耗時(shí)比較短的瞬時(shí)性任務(wù),業(yè)務(wù)上超時(shí)的可能性較小;其次,在獲取鎖時(shí),可以設(shè)置 value 為一個(gè)隨機(jī)數(shù),在釋放鎖時(shí)進(jìn)行讀取和對(duì)比,確保釋放的是當(dāng)前線程持有的鎖,一般是通過(guò) Redis 結(jié)合 Lua 腳本的方案實(shí)現(xiàn);最后,需要添加完備的日志,記錄上下游數(shù)據(jù)鏈路,當(dāng)出現(xiàn)超時(shí),則需要檢查對(duì)應(yīng)的問(wèn)題數(shù)據(jù),并且進(jìn)行人工修復(fù)。
分布式鎖的高可用
上面分布式鎖的實(shí)現(xiàn)方案中,都是針對(duì)單節(jié)點(diǎn) Redis 而言的,在生產(chǎn)環(huán)境中,為了保證高可用,避免單點(diǎn)故障,通常會(huì)使用 Redis 集群。
集群下分布式鎖存在哪些問(wèn)題
集群環(huán)境下,Redis 通過(guò)主從復(fù)制來(lái)實(shí)現(xiàn)數(shù)據(jù)同步,Redis 的主從復(fù)制(Replication)是異步的,所以單節(jié)點(diǎn)下可用的方案在集群的環(huán)境中可能會(huì)出現(xiàn)問(wèn)題,在故障轉(zhuǎn)移(Failover) 過(guò)程中喪失鎖的安全性。
由于 Redis 集群數(shù)據(jù)同步是異步的,假設(shè) Master 節(jié)點(diǎn)獲取到鎖后在未完成數(shù)據(jù)同步的情況下,發(fā)生節(jié)點(diǎn)崩潰,此時(shí)在其他節(jié)點(diǎn)依然可以獲取到鎖,出現(xiàn)多個(gè)客戶端同時(shí)獲取到鎖的情況。
我們模擬下這個(gè)場(chǎng)景,按照下面的順序執(zhí)行:
客戶端 A 從 Master 節(jié)點(diǎn)獲取鎖;
Master 節(jié)點(diǎn)宕機(jī),主從復(fù)制過(guò)程中,對(duì)應(yīng)鎖的 key 還沒(méi)有同步到 Slave 節(jié)點(diǎn)上;
Slave 升級(jí)為 Master 節(jié)點(diǎn),于是集群丟失了鎖數(shù)據(jù);
其他客戶端請(qǐng)求新的 Master 節(jié)點(diǎn),獲取到了對(duì)應(yīng)同一個(gè)資源的鎖;
出現(xiàn)多個(gè)客戶端同時(shí)持有同一個(gè)資源的鎖,不滿足鎖的互斥性。
可以看到,單實(shí)例場(chǎng)景和集群環(huán)境實(shí)現(xiàn)分布式鎖是不同的,關(guān)于集群下如何實(shí)現(xiàn)分布式鎖,Redis 的作者 Antirez(Salvatore Sanfilippo)提出了 Redlock 算法,我們一起看一下。
Redlock 算法的流程
Redlock 算法 是在單 Redis 節(jié)點(diǎn)基礎(chǔ)上引入的 高可用模式,Redlock 基于 N 個(gè)完全獨(dú)立的 Redis 節(jié)點(diǎn),一般是 大于 3 的奇數(shù)個(gè)(通常情況下 N 可以設(shè)置為 5),可以基本保證集群內(nèi)各個(gè)節(jié)點(diǎn)不會(huì)同時(shí)宕機(jī)。
假設(shè)當(dāng)前集群有 5 個(gè)節(jié)點(diǎn),運(yùn)行 Redlock 算法的客戶端依次執(zhí)行下面各個(gè)步驟,來(lái)完成獲取鎖的操作:
客戶端記錄當(dāng)前系統(tǒng)時(shí)間,以毫秒為單位;
依次嘗試從 5 個(gè) Redis 實(shí)例中,使用相同的 key 獲取鎖,當(dāng)向 Redis 請(qǐng)求獲取鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間,避免因?yàn)榫W(wǎng)絡(luò)故障出現(xiàn)的問(wèn)題;
客戶端使用當(dāng)前時(shí)間減去開(kāi)始獲取鎖時(shí)間就得到了獲取鎖使用的時(shí)間,當(dāng)且僅當(dāng)從半數(shù)以上的 Redis 節(jié)點(diǎn)獲取到鎖,并且當(dāng)使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功;
如果獲取到了鎖,key 的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間,減少超時(shí)的幾率;
如果獲取鎖失敗,客戶端應(yīng)該在所有的 Redis 實(shí)例上進(jìn)行解鎖,即使是上一步操作請(qǐng)求失敗的節(jié)點(diǎn),防止因?yàn)榉?wù)端響應(yīng)消息丟失,但是實(shí)際數(shù)據(jù)添加成功導(dǎo)致的不一致。
在 Redis 官方推薦的 Java 客戶端 Redisson 中,內(nèi)置了對(duì) RedLock 的實(shí)現(xiàn)。下面是官方網(wǎng)站的鏈接,感興趣的同學(xué)可以去了解一下:
redis-distlock
redisson-wiki
分布式系統(tǒng)設(shè)計(jì)是實(shí)現(xiàn)復(fù)雜性和收益的平衡,考慮到集群環(huán)境下的一致性問(wèn)題,也要避免過(guò)度設(shè)計(jì)。在實(shí)際業(yè)務(wù)中,一般使用基于單點(diǎn)的 Redis 實(shí)現(xiàn)分布式鎖就可以,出現(xiàn)數(shù)據(jù)不一致,通過(guò)人工手段去回補(bǔ)。
總結(jié)
今天分享了如何使用 Redis 來(lái)逐步優(yōu)化分布式鎖實(shí)現(xiàn)的相關(guān)內(nèi)容,包括一個(gè)完備的分布式鎖應(yīng)該支持哪些特性,使用 Redis 實(shí)現(xiàn)分布式鎖的幾種不同方式,最后簡(jiǎn)單介紹了一下 Redis 集群下的 RedLock 算法。