怎么用Redis分布式鎖才能確保萬(wàn)無(wú)一失?
作者介紹
冷正磊,2018年2月加入去哪兒網(wǎng) DBA 團(tuán)隊(duì),主要負(fù)責(zé)機(jī)票業(yè)務(wù)的 MySQL 和 Redis 數(shù)據(jù)庫(kù)的運(yùn)維管理工作,以及數(shù)據(jù)庫(kù)自動(dòng)化運(yùn)維平臺(tái)部分功能的開(kāi)發(fā)工作,對(duì)數(shù)據(jù)庫(kù)技術(shù)具有濃厚興趣,具有多年 MySQL 和 Redis 運(yùn)維管理和性能優(yōu)化經(jīng)驗(yàn)。
一、背景
我們?nèi)粘T陔娚叹W(wǎng)站購(gòu)物時(shí)經(jīng)常會(huì)遇到一些高并發(fā)的場(chǎng)景,例如電商 App 上經(jīng)常出現(xiàn)的秒殺活動(dòng)、限量?jī)?yōu)惠券搶購(gòu),還有我們?nèi)ツ膬壕W(wǎng)的火車(chē)票搶票系統(tǒng)等,這些場(chǎng)景有一個(gè)共同特點(diǎn)就是訪(fǎng)問(wèn)量激增,雖然在系統(tǒng)設(shè)計(jì)時(shí)會(huì)通過(guò)限流、異步、排隊(duì)等方式優(yōu)化,但整體的并發(fā)還是平時(shí)的數(shù)倍以上,為了避免并發(fā)問(wèn)題,防止庫(kù)存超賣(mài),給用戶(hù)提供一個(gè)良好的購(gòu)物體驗(yàn),這些系統(tǒng)中都會(huì)用到鎖的機(jī)制。
對(duì)于單進(jìn)程的并發(fā)場(chǎng)景,可以使用編程語(yǔ)言及相應(yīng)的類(lèi)庫(kù)提供的鎖,如 Java 中的 synchronized 語(yǔ)法以及 ReentrantLock 類(lèi)等,避免并發(fā)問(wèn)題。
如果在分布式場(chǎng)景中,實(shí)現(xiàn)不同客戶(hù)端的線(xiàn)程對(duì)代碼和資源的同步訪(fǎng)問(wèn),保證在多線(xiàn)程下處理共享數(shù)據(jù)的安全性,就需要用到分布式鎖技術(shù)。
那么何為分布式鎖呢?分布式鎖是控制分布式系統(tǒng)或不同系統(tǒng)之間共同訪(fǎng)問(wèn)共享資源的一種鎖實(shí)現(xiàn),如果不同的系統(tǒng)或同一個(gè)系統(tǒng)的不同主機(jī)之間共享了某個(gè)資源時(shí),往往需要互斥來(lái)防止彼此干擾保證一致性。
一個(gè)相對(duì)安全的分布式鎖,一般需要具備以下特征:
- 互斥性?;コ馐擎i的基本特征,同一時(shí)刻鎖只能被一個(gè)線(xiàn)程持有,執(zhí)行臨界區(qū)操作。
- 超時(shí)釋放。通過(guò)超時(shí)釋放,可以避免死鎖,防止不必要的線(xiàn)程等待和資源浪費(fèi),類(lèi)似于 MySQL 的 InnoDB 引擎中的 innodblockwait_timeout 參數(shù)配置。
- 可重入性。一個(gè)線(xiàn)程在持有鎖的情況可以對(duì)其再次請(qǐng)求加鎖,防止鎖在線(xiàn)程執(zhí)行完臨界區(qū)操作之前釋放。
- 高性能和高可用。加鎖和釋放鎖的過(guò)程性能開(kāi)銷(xiāo)要盡可能的低,同時(shí)也要保證高可用,防止分布式鎖意外失效。
可以看出實(shí)現(xiàn)分布式鎖,并不是鎖住資源就可以了,還需要滿(mǎn)足一些額外的特征,避免出現(xiàn)死鎖、鎖失效等問(wèn)題。
二、分布式鎖的實(shí)現(xiàn)方式
目前實(shí)現(xiàn)分布式鎖的方式有很多,常見(jiàn)的主要有:
- Memcached 分布式鎖
利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情況下,才能 add 成功,也就意味著線(xiàn)程得到了鎖。
- Zookeeper 分布式鎖
利用 Zookeeper 的順序臨時(shí)節(jié)點(diǎn),來(lái)實(shí)現(xiàn)分布式鎖和等待隊(duì)列。ZooKeeper 作為一個(gè)專(zhuān)門(mén)為分布式應(yīng)用提供方案的框架,它提供了一些非常好的特性,如 ephemeral 類(lèi)型的 znode 自動(dòng)刪除的功能,同時(shí) ZooKeeper 還提供 watch 機(jī)制,可以讓分布式鎖在客戶(hù)端用起來(lái)就像一個(gè)本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖為止。
- Chubby
Google 公司實(shí)現(xiàn)的粗粒度分布式鎖服務(wù),有點(diǎn)類(lèi)似于 ZooKeeper,但也存在很多差異。Chubby 通過(guò) sequencer 機(jī)制解決了請(qǐng)求延遲造成的鎖失效的問(wèn)題。
- Redis 分布式鎖
基于 Redis 單機(jī)實(shí)現(xiàn)的分布式鎖,其方式和 Memcached 的實(shí)現(xiàn)方式類(lèi)似,利用 Redis 的 SETNX 命令,此命令同樣是原子性操作,只有在 key 不存在的情況下,才能 set 成功。而基于 Redis 多機(jī)實(shí)現(xiàn)的分布式鎖Redlock,是 Redis 的作者 antirez 為了規(guī)范 Redis 分布式鎖的實(shí)現(xiàn),提出的一個(gè)更安全有效的實(shí)現(xiàn)機(jī)制。
本文主要討論分析基于Redis的分布式鎖的幾種實(shí)現(xiàn)方式以及存在的問(wèn)題。
三、Redis分布式鎖
使用 Redis 作為分布式鎖,本質(zhì)上要實(shí)現(xiàn)的目標(biāo)就是一個(gè)進(jìn)程在 Redis 里面占據(jù)了僅有的一個(gè)“茅坑”,當(dāng)別的進(jìn)程也想來(lái)占坑時(shí),發(fā)現(xiàn)已經(jīng)有人蹲在那里了,就只好放棄或者等待稍后再試。
目前基于 Redis 實(shí)現(xiàn)分布式鎖主要有兩大類(lèi),一類(lèi)是基于單機(jī),另一類(lèi)是基于 Redis 多機(jī),不管是哪種實(shí)現(xiàn)方式,均需要實(shí)現(xiàn)加鎖、解鎖、鎖超時(shí)這三個(gè)分布式鎖的核心要素。
1、基于Redis單機(jī)實(shí)現(xiàn)的分布式鎖
1)使用 SETNX 指令
最簡(jiǎn)單的加鎖方式就是直接使用 Redis 的 SETNX 指令,該指令只在 key 不存在的情況下,將 key 的值設(shè)置為 value,若 key 已經(jīng)存在,則 SETNX 命令不做任何動(dòng)作。key 是鎖的唯一標(biāo)識(shí),可以按照業(yè)務(wù)需要鎖定的資源來(lái)命名。
比如在某商城的秒殺活動(dòng)中對(duì)某一商品加鎖,那么 key 可以設(shè)置為 lock_resource_id ,value 可以設(shè)置為任意值,在資源使用完成后,使用 DEL 刪除該 key 對(duì)鎖進(jìn)行釋放,整個(gè)過(guò)程如下:
很顯然,這種獲取鎖的方式很簡(jiǎn)單,但也存在一個(gè)問(wèn)題,就是我們上面提到的分布式鎖三個(gè)核心要素之一的鎖超時(shí)問(wèn)題,即如果獲得鎖的進(jìn)程在業(yè)務(wù)邏輯處理過(guò)程中出現(xiàn)了異常,可能會(huì)導(dǎo)致 DEL 指令一直無(wú)法執(zhí)行,導(dǎo)致鎖無(wú)法釋放,該資源將會(huì)永遠(yuǎn)被鎖住。
所以,在使用 SETNX 拿到鎖以后,必須給 key 設(shè)置一個(gè)過(guò)期時(shí)間,以保證即使沒(méi)有被顯式釋放,在獲取鎖達(dá)到一定時(shí)間后也要自動(dòng)釋放,防止資源被長(zhǎng)時(shí)間獨(dú)占。由于 SETNX 不支持設(shè)置過(guò)期時(shí)間,所以需要額外的 EXPIRE 指令,整個(gè)過(guò)程如下:
這樣實(shí)現(xiàn)的分布式鎖仍然存在一個(gè)嚴(yán)重的問(wèn)題,由于 SETNX 和 EXPIRE 這兩個(gè)操作是非原子性的, 如果進(jìn)程在執(zhí)行 SETNX 和 EXPIRE 之間發(fā)生異常,SETNX 執(zhí)行成功,但 EXPIRE 沒(méi)有執(zhí)行,導(dǎo)致這把鎖變得“長(zhǎng)生不老”,這種情況就可能出現(xiàn)前文提到的鎖超時(shí)問(wèn)題,其他進(jìn)程無(wú)法正常獲取鎖。
2)使用 SET 擴(kuò)展指令
為了解決 SETNX 和 EXPIRE 兩個(gè)操作非原子性的問(wèn)題,可以使用 Redis 的 SET 指令的擴(kuò)展參數(shù),使得 SETNX 和 EXPIRE 這兩個(gè)操作可以原子執(zhí)行,整個(gè)過(guò)程如下:
在這個(gè) SET 指令中:
- NX 表示只有當(dāng) lock_resource_id 對(duì)應(yīng)的 key 值不存在的時(shí)候才能 SET 成功。保證了只有第一個(gè)請(qǐng)求的客戶(hù)端才能獲得鎖,而其它客戶(hù)端在鎖被釋放之前都無(wú)法獲得鎖。
- EX 10 表示這個(gè)鎖10秒鐘后會(huì)自動(dòng)過(guò)期,業(yè)務(wù)可以根據(jù)實(shí)際情況設(shè)置這個(gè)時(shí)間的大小。
但是這種方式仍然不能徹底解決分布式鎖超時(shí)問(wèn)題:
- 鎖被提前釋放。假如線(xiàn)程 A 在加鎖和釋放鎖之間的邏輯執(zhí)行的時(shí)間過(guò)長(zhǎng)(或者線(xiàn)程 A 執(zhí)行過(guò)程中被堵塞),以至于超出了鎖的過(guò)期時(shí)間后進(jìn)行了釋放,但線(xiàn)程 A 在臨界區(qū)的邏輯還沒(méi)有執(zhí)行完,那么這時(shí)候線(xiàn)程 B 就可以提前重新獲取這把鎖,導(dǎo)致臨界區(qū)代碼不能?chē)?yán)格的串行執(zhí)行。
- 鎖被誤刪。假如以上情形中的線(xiàn)程A執(zhí)行完后,它并不知道此時(shí)的鎖持有者是線(xiàn)程 B,線(xiàn)程A會(huì)繼續(xù)執(zhí)行 DEL 指令來(lái)釋放鎖,如果線(xiàn)程 B 在臨界區(qū)的邏輯還沒(méi)有執(zhí)行完,線(xiàn)程 A 實(shí)際上釋放了線(xiàn)程 B 的鎖。
為了避免以上情況,建議不要在執(zhí)行時(shí)間過(guò)長(zhǎng)的場(chǎng)景中使用 Redis 分布式鎖,同時(shí)一個(gè)比較安全的做法是在執(zhí)行 DEL 釋放鎖之前對(duì)鎖進(jìn)行判斷,驗(yàn)證當(dāng)前鎖的持有者是否是自己。
具體實(shí)現(xiàn)就是在加鎖時(shí)將 value 設(shè)置為一個(gè)唯一的隨機(jī)數(shù)(或者線(xiàn)程 ID ),釋放鎖時(shí)先判斷隨機(jī)數(shù)是否一致,然后再執(zhí)行釋放操作,確保不會(huì)錯(cuò)誤地釋放其它線(xiàn)程持有的鎖,除非是鎖過(guò)期了被服務(wù)器自動(dòng)釋放,整個(gè)過(guò)程如下:
但判斷 value 和刪除 key 是兩個(gè)獨(dú)立的操作,并不是原子性的,所以這個(gè)地方需要使用 Lua 腳本進(jìn)行處理,因?yàn)?Lua 腳本可以保證連續(xù)多個(gè)指令的原子性執(zhí)行。
基于 Redis 單節(jié)點(diǎn)的分布式鎖基本完成了,但是這并不是一個(gè)完美的方案,只是相對(duì)完全一點(diǎn),因?yàn)樗](méi)有完全解決當(dāng)前線(xiàn)程執(zhí)行超時(shí)鎖被提前釋放后,其它線(xiàn)程乘虛而入的問(wèn)題。
3)使用 Redisson 的分布式鎖
怎么能解決鎖被提前釋放這個(gè)問(wèn)題呢?
可以利用鎖的可重入特性,讓獲得鎖的線(xiàn)程開(kāi)啟一個(gè)定時(shí)器的守護(hù)線(xiàn)程,每 expireTime/3 執(zhí)行一次,去檢查該線(xiàn)程的鎖是否存在,如果存在則對(duì)鎖的過(guò)期時(shí)間重新設(shè)置為 expireTime,即利用守護(hù)線(xiàn)程對(duì)鎖進(jìn)行“續(xù)命”,防止鎖由于過(guò)期提前釋放。
當(dāng)然業(yè)務(wù)要實(shí)現(xiàn)這個(gè)守護(hù)進(jìn)程的邏輯還是比較復(fù)雜的,可能還會(huì)出現(xiàn)一些未知的問(wèn)題。
目前互聯(lián)網(wǎng)公司在生產(chǎn)環(huán)境用的比較廣泛的開(kāi)源框架 Redisson 很好地解決了這個(gè)問(wèn)題,非常的簡(jiǎn)便易用,且支持 Redis 單實(shí)例、Redis M-S、Redis Sentinel、Redis Cluster 等多種部署架構(gòu)。
感興趣的朋友可以查閱下官方文檔或者源碼:
https://github.com/redisson/redisson/wiki
其實(shí)現(xiàn)原理如圖所示(圖中以 Redis 集群為例):
2、基于Redis多機(jī)實(shí)現(xiàn)的分布式鎖Redlock
以上幾種基于 Redis 單機(jī)實(shí)現(xiàn)的分布式鎖其實(shí)都存在一個(gè)問(wèn)題,就是加鎖時(shí)只作用在一個(gè) Redis 節(jié)點(diǎn)上,即使 Redis 通過(guò) Sentinel 保證了高可用,但由于 Redis 的復(fù)制是異步的,Master 節(jié)點(diǎn)獲取到鎖后在未完成數(shù)據(jù)同步的情況下發(fā)生故障轉(zhuǎn)移,此時(shí)其他客戶(hù)端上的線(xiàn)程依然可以獲取到鎖,因此會(huì)喪失鎖的安全性。
整個(gè)過(guò)程如下:
- 客戶(hù)端 A 從 Master 節(jié)點(diǎn)獲取鎖。
- Master 節(jié)點(diǎn)出現(xiàn)故障,主從復(fù)制過(guò)程中,鎖對(duì)應(yīng)的 key 沒(méi)有同步到 Slave 節(jié)點(diǎn)。
- Slave升 級(jí)為 Master 節(jié)點(diǎn),但此時(shí)的 Master 中沒(méi)有鎖數(shù)據(jù)。
- 客戶(hù)端 B 請(qǐng)求新的 Master 節(jié)點(diǎn),并獲取到了對(duì)應(yīng)同一個(gè)資源的鎖。
- 出現(xiàn)多個(gè)客戶(hù)端同時(shí)持有同一個(gè)資源的鎖,不滿(mǎn)足鎖的互斥性。
正因?yàn)槿绱?,?Redis 的分布式環(huán)境中,Redis 的作者 antirez 提供了 RedLock 的算法來(lái)實(shí)現(xiàn)一個(gè)分布式鎖,該算法大概是這樣的:
假設(shè)有 N(N>=5)個(gè) Redis 節(jié)點(diǎn),這些節(jié)點(diǎn)完全互相獨(dú)立,不存在主從復(fù)制或者其他集群協(xié)調(diào)機(jī)制,確保在這N個(gè)節(jié)點(diǎn)上使用與在 Redis 單實(shí)例下相同的方法獲取和釋放鎖。
獲取鎖的過(guò)程,客戶(hù)端應(yīng)執(zhí)行如下操作:
- 獲取當(dāng)前 Unix 時(shí)間,以毫秒為單位。
- 按順序依次嘗試從5個(gè)實(shí)例使用相同的 key 和具有唯一性的 value(例如 UUID)獲取鎖。當(dāng)向 Redis 請(qǐng)求獲取鎖時(shí),客戶(hù)端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如鎖自動(dòng)失效時(shí)間為10秒,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間。這樣可以避免服務(wù)器端 Redis 已經(jīng)掛掉的情況下,客戶(hù)端還在一直等待響應(yīng)結(jié)果。如果服務(wù)器端沒(méi)有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶(hù)端應(yīng)該盡快嘗試去另外一個(gè) Redis 實(shí)例請(qǐng)求獲取鎖。
- 客戶(hù)端使用當(dāng)前時(shí)間減去開(kāi)始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間。當(dāng)且僅當(dāng)從大多數(shù)(N/2+1,這里是3個(gè)節(jié)點(diǎn))的 Redis 節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功。
- 如果取到了鎖,key 的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)。
- 如果因?yàn)槟承┰?,獲取鎖失?。](méi)有在至少N/2+1個(gè) Redis 實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過(guò)了有效時(shí)間),客戶(hù)端應(yīng)該在所有的 Redis 實(shí)例上進(jìn)行解鎖(使用 Redis Lua 腳本)。
釋放鎖的過(guò)程相對(duì)比較簡(jiǎn)單:客戶(hù)端向所有 Redis 節(jié)點(diǎn)發(fā)起釋放鎖的操作,包括加鎖失敗的節(jié)點(diǎn),也需要執(zhí)行釋放鎖的操作,antirez 在算法描述中特別強(qiáng)調(diào)這一點(diǎn),這是為什么呢?
原因是可能存在某個(gè)節(jié)點(diǎn)加鎖成功后返回客戶(hù)端的響應(yīng)包丟失了,這種情況在異步通信模型中是有可能發(fā)生的:客戶(hù)端向服務(wù)器通信是正常的,但反方向卻是有問(wèn)題的。雖然對(duì)客戶(hù)端而言,由于響應(yīng)超時(shí)導(dǎo)致加鎖失敗,但是對(duì) Redis節(jié)點(diǎn)而言,SET 指令執(zhí)行成功,意味著加鎖成功。因此,釋放鎖的時(shí)候,客戶(hù)端也應(yīng)該對(duì)當(dāng)時(shí)獲取鎖失敗的那些 Redis 節(jié)點(diǎn)同樣發(fā)起請(qǐng)求。
除此之外,為了避免 Redis 節(jié)點(diǎn)發(fā)生崩潰重啟后造成鎖丟失,從而影響鎖的安全性,antirez 還提出了延時(shí)重啟的概念,即一個(gè)節(jié)點(diǎn)崩潰后不要立即重啟,而是等待一段時(shí)間后再進(jìn)行重啟,這段時(shí)間應(yīng)該大于鎖的有效時(shí)間。
關(guān)于 Redlock 的更深層次的學(xué)習(xí),感興趣的朋友可以查閱下官方文檔:https://redis.io/topics/distlock
四、總結(jié)
分布式系統(tǒng)設(shè)計(jì)是實(shí)現(xiàn)復(fù)雜性和收益的平衡,既要盡可能地安全可靠,也要避免過(guò)度設(shè)計(jì)。Redlock 確實(shí)能夠提供更安全的分布式鎖,但也是有代價(jià)的,需要更多的 Redis 節(jié)點(diǎn)。在實(shí)際業(yè)務(wù)中,一般使用基于單點(diǎn)的 Redis 實(shí)現(xiàn)分布式鎖就可以滿(mǎn)足絕大部分的需求,偶爾出現(xiàn)數(shù)據(jù)不一致的情況,可通過(guò)人工介入回補(bǔ)數(shù)據(jù)進(jìn)行解決,正所謂“技術(shù)不夠,人工來(lái)湊”!。