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