11 張圖深入理解分布式鎖原理
什么是分布式鎖?它能干什么
單體系統(tǒng)中,在高并發(fā)場景下想要訪問共享資源的時候,我們需要通過加鎖的方式來保證共享資源并發(fā)的安全性,確保在同一時刻只有一個線程對共享資源進行操作。相信大家對于 Java 提供的 synchronized 關(guān)鍵字以及 Lock 鎖都不陌生,在實際的項目中大家都使用過。如下圖所示,在同一個 JVM 進程中,Thread1 獲得鎖之后,對共享資源進行操作,其他線程未獲得鎖的線程只能等待 Thread1 釋放后才能進行對應(yīng)的操作。

但是隨著業(yè)務(wù)的不斷發(fā)展,原先的單體應(yīng)用被拆分為多個微服務(wù),每個微服務(wù)又會部署多個實例,于是就形成了當下的微服務(wù)架構(gòu)。處理共享資源的請求來自不同的服務(wù)實例,也就是在不同的 JVM 進程中。原先的單體服務(wù)中的加鎖方式在分布式場景下不能滿足共享資源的并發(fā)訪問要求。因此我們需要一種適用于分布式場景下的共享資源安全的處理機制,此時應(yīng)對這種問題的分布式鎖就應(yīng)運而生了。
既然 JVM 進程管不到其他服務(wù)實例的線程,那么可以借助于外部組件能力來實現(xiàn)不同服務(wù)實例對于共享資源的統(tǒng)一管控,這種能力我們可以稱之為分布式鎖。因此分布式鎖的本質(zhì)就是在不同服務(wù)實例之外建立一種獲取鎖的機制,形成一種并發(fā)互斥能力來確保不同線程對于共享資源的并發(fā)安全,從而實現(xiàn)在微服務(wù)架構(gòu)中同一時刻只有一個線程可以對共享資源進行操作。對于分布式鎖來說,實際就是需要一個外部的狀態(tài)存儲系統(tǒng)來實現(xiàn)原子化的排他性操作。

通過對于分布式鎖的需求分析,總結(jié)了如下的分布式鎖四大特性,分別是多節(jié)點、加鎖速度快、排他性以及鎖過期實現(xiàn)機制。

分布式鎖實現(xiàn)方案
2.1 基于數(shù)據(jù)庫的分布式鎖實現(xiàn)方案
2.1.1實現(xiàn)原理
通過數(shù)據(jù)庫的方式實現(xiàn)分布式鎖的效果,實際就是借助于數(shù)據(jù)庫的唯一性約束特性或者 for update 來實現(xiàn)。這里以唯一性約束來舉個栗子,在電商領(lǐng)域的庫存服務(wù)負責對商品的庫存進行扣減,首先創(chuàng)建一張專門存放鎖信息的鎖表,那么庫存服務(wù)在進行庫存操作之前,先向數(shù)據(jù)庫中的鎖表插入一條鎖資源數(shù)據(jù)。
create?table?‘distributed_lock’?(
‘id’?BIGINT?NOT?NULL?AUTO_INCREMENT,
‘resource_lock_key‘?varchar(64)?NOT?NULL
PRIMARY?KEY(‘id’),
UNIQUE?KEY?‘uk_resource_lock_key‘?(‘resource_lock_key‘)?USING?BTREE
)
大致的交互流程如下:
1、當庫存服務(wù)進行手機庫存扣減的時候,首先先向數(shù)據(jù)庫中的鎖表當中插入一條資源鎖信息;
2、如果插入成功,則表示庫存服務(wù) 1 可以對手機庫存進行庫存扣減操作;
3、此時庫存服務(wù) 2 也要對庫存進行操作,于是同樣插入數(shù)據(jù)到鎖表中;
4、但是由于鎖表設(shè)置了唯一性約束,鎖信息插入失敗,庫存服務(wù)進行等待;
5、庫存服務(wù) 1 執(zhí)行完庫存扣減之后,刪除鎖表的信息;
6、庫存服務(wù) 2 嘗試插入資源鎖信息,發(fā)現(xiàn)可以插入成功,繼續(xù)執(zhí)行后續(xù)操作。

2.1.2 方案分析
基于數(shù)據(jù)庫的實現(xiàn)方式,看起來還是比較容易理解的。但是實際上還是有一些問題存在的,我們一起來分析下。
1、性能問題:由于是插入數(shù)據(jù)數(shù)據(jù)需要落盤存儲,如果平凡進行讀寫的話會影響數(shù)據(jù)庫性能,另外由于使用唯一鍵進行判斷也會一定程度上影響數(shù)據(jù)庫性能,因此數(shù)據(jù)庫方案適用于并發(fā)量不到的簡單場景;
2、數(shù)據(jù)庫如果單點部署的話會存在單點故障問題,如果數(shù)據(jù)庫出現(xiàn)故障,可能會導(dǎo)致平臺中的業(yè)務(wù)異常;
3、死鎖問題:在上文介紹中,包含了插入數(shù)據(jù)庫的獲取鎖的步驟,還包含了刪除鎖信息的釋放鎖的過程,但是如果庫存服務(wù) 1 在加鎖之后掛掉了,無法進行鎖的釋放,而其他服務(wù)又無法獲取到鎖就會造成死鎖的問題。當然了我們可以通過一個定時任務(wù)去檢查鎖表中是不是有過時的鎖資源。但是這樣無疑增加了分布式鎖實現(xiàn)的復(fù)雜性。
4、不支持可重入:如果想要實現(xiàn)可重入鎖,還需要增加主機、線程名等字段來進行標注,通過這幾個字段來判斷和當前信息是否一致,如果一致則認為已經(jīng)獲取到了鎖。
鑒于以上的這些問題,有沒有其他的分布式實現(xiàn)方案可以避免上述存在的問題呢?我們再往下來看。
2.2 基于 Redis 的分布式鎖實現(xiàn)方案
2.2.1 基于 sentnx 命令的實現(xiàn)原理
Redis 作為一塊高性能的數(shù)據(jù)庫中間件,經(jīng)常被當做緩存在項目中使用。因此通過 Redis 實現(xiàn)分布式鎖,也是比較常見的實現(xiàn)方案。一樣的道理,通過 Redis 實現(xiàn)分布式鎖也需要通過它實現(xiàn)鎖的互斥的能力。實際上就是利用了 sentnx(set if not exists)命令。同時該命令是否能夠設(shè)置成功,決定服務(wù)是否可以拿到對應(yīng)的分布式鎖。
127.0.0.1:6379>?setnx?stockLock?10.12.35.12_stockService?
(integer)?1

如上圖所示,大致的加鎖以及釋放鎖的過程其實和數(shù)據(jù)庫的分布式鎖方案還是比較類似的。只不過將其中向數(shù)據(jù)庫插入數(shù)據(jù)的步驟替換成了向 Redis 獲取鎖的步驟,由于 Redis 是基于內(nèi)存進行操作的,因此性能上比基于數(shù)據(jù)庫的分布式鎖方案更好一點。
2.2.2 原理分析
上述基于 Redis 的方案的方案在性能上具有優(yōu)勢,我們再來分析下,這個使用命令的方式有沒有什么問題。實際上和前面的數(shù)據(jù)庫方案類似,Redis 也會有死鎖問題,當獲取鎖之后如果庫存服務(wù) 1 掛掉了,庫存服務(wù) 2 就獲取不到鎖了。因此我們要對其進行優(yōu)化。那么問題的本質(zhì)是如何讓鎖可以釋放,因此我們需要在設(shè)置鎖的時候加上過期時間,這樣即使庫存服務(wù) 1 掛了,無法主動釋放鎖,那么到了過期時間后鎖失效,庫存服務(wù) 2 依然可以獲取鎖,不會再造成死鎖問題。

另外還應(yīng)該注意的是,在我們設(shè)置鎖的時候,還需要帶有自身服務(wù)的業(yè)務(wù)屬性,否則容易造成錯亂。為什么這么說呢?舉個栗子,庫存服務(wù)在加完鎖之后開始執(zhí)行扣減庫存的任務(wù),當扣減庫存完成之后,服務(wù)掛了,原先需要刪除的鎖資源,等到過期之后被 Redis 刪除,此時庫存服務(wù) 2 可以繼續(xù)申請鎖,如果此時庫存服務(wù) 1 恢復(fù)了,它并不知道鎖資源已經(jīng)釋放,起來后立馬刪除了庫存服務(wù) 2 加的鎖,那么此時就會出現(xiàn)兩個問題:
1、庫存服務(wù)執(zhí)行完庫存扣減之后,回頭來進行鎖資源釋放的時候,發(fā)現(xiàn)鎖實際已經(jīng)不在了;
2、當庫存服務(wù) 1 恢復(fù)后發(fā)現(xiàn)鎖還在,立馬刪除了該鎖,完成了它掛掉之前未完成的工作。但是實際上這個鎖是庫存服務(wù) 2 加的鎖,如果此時庫存服務(wù) 3 也要嘗試加鎖,發(fā)現(xiàn)可以加鎖成功,和庫存服務(wù) 2 一樣同樣對庫存進行操作,那么此時就會出現(xiàn)線程安全問題。

經(jīng)過上文的分析,這個問題的根源就是在加鎖的時候沒有具體區(qū)分到底是哪個服務(wù)加的鎖。因此在執(zhí)行命令的時候,我們需要將帶有服務(wù)實例關(guān)聯(lián)屬性的設(shè)置為 value,這樣在進行鎖獲取的時候檢查下當前鎖的持有者是誰,如果不是服務(wù)實例自己則不能執(zhí)行刪除操作。
那這樣是不是就完美解決問題了呢?實際上還是有問題存在的,有同學(xué)會說,怎么這么多問題?實際上這種方案的實現(xiàn)就是在各種不完美的方案中逐漸找到相對完美的方案。
上文提到的獲取鎖判斷是不是自己方服務(wù)實例加的鎖,再執(zhí)行刪除鎖的過程實際并不是原子的。因此還是會出現(xiàn)并發(fā)安全問題,這個問題可以通過 lua 腳本來解決,在 lua 腳本中實現(xiàn)這個邏輯,而不是在客戶端中實現(xiàn)。
但是實際上還是有問題沒有解決,比如說我們在加鎖的時候會設(shè)置過期時間,但是過期時間應(yīng)該設(shè)置多長時間呢?設(shè)置短了的話,出現(xiàn)網(wǎng)絡(luò)超時或者服務(wù)還沒有執(zhí)行完業(yè)務(wù),鎖就失效了。設(shè)置長了話,其他服務(wù)節(jié)點等待獲取鎖的時間就會變長,降低了服務(wù)的性能。
2.2.3 基于 Redisson 實現(xiàn)
Redisson 實際上就是一個封裝了 Redis 操作的客戶端,實現(xiàn)了對于常見的 Redis 操作的封裝。如對于 Redis 的設(shè)置鎖的步驟以及刪除鎖的步驟都進行了封裝。在設(shè)置鎖的操作中,還引入了自動給鎖續(xù)期的機制,SDK 檢測到業(yè)務(wù)未完成,但是鎖要到期后,執(zhí)行定續(xù)期。這樣并可以動態(tài)的調(diào)節(jié)過期時間,避免鎖在業(yè)務(wù)未完成情況下被釋放的問題。

同時還封裝了刪除鎖的時候執(zhí)行的業(yè)務(wù)判斷后再刪除的邏輯,這樣我們在使用 Redisson 操作 Redis 的時候,就和我們使用 JDK 一樣。
2.2.4 RedLock
為了解決 Redis 作為分布式鎖存在的單點問題,Redis 的作者又提出了 Redlock 的解決方案,該解決方案依賴多個 Redis 的 Master 節(jié)點,官方推薦使用 5 個 Master 節(jié)點,他們彼此之間是獨立的。大致的交互步驟如下所示:
1、首先獲取當前節(jié)點的系統(tǒng)時間;
2、客戶端嘗試向所有的 Redis 實例順序地發(fā)送加鎖的請求(官方推薦 Redis 集群至少 5 個實例),在設(shè)置鎖的過程中,使用相同的 key 以及隨機值 value,同時請求的超時時間需要遠小于鎖的有效時間。這樣做的目的是為了防止節(jié)點不可用的時候?qū)е抡埱箧i的時候被阻塞,當實例沒響應(yīng)的時候可以快速跳過,向下一個節(jié)點繼續(xù)請求鎖。
3、假設(shè) Redis 集群規(guī)模為 5,那么如果客戶端在大多數(shù)實例中(超過 3 個實例)獲得了鎖,同時計算了當前的時間減去步驟 1 中獲得的時間,這個事件差如果小于鎖的有效時間,那么此時可以認為加鎖成功,可以操作執(zhí)行后續(xù)的業(yè)務(wù);
4、如果不滿足步驟 3 是條件,那么就表示加鎖失敗,客戶端需要向所有的 Redis 節(jié)點發(fā)起鎖釋放請求。

2.2.5 方案分析
為什么 Redlock 要在集群中多個實例上加鎖呢?實際目的是通過鎖的冗余來實現(xiàn)分布式鎖的高容錯性。試想一下如果只有一個 Redis 實例,一旦它掛掉了,客戶端就無法進行加鎖操作了或者鎖信息就會丟失,影響業(yè)務(wù)功能。通過在集群中多實例中冗余鎖信息,即使出現(xiàn) Redis 掛了的情況,其他節(jié)點中依然存在鎖信息,從而提升了分布式鎖的可用性。
那么為什么還要計算幾所時間呢?由于我們加鎖的時候,每個節(jié)點都設(shè)置了超時時間,如果整個加鎖的時間過長,整個過程的累加時間超過了鎖的有效時間,那么加鎖完成之后就會哦出現(xiàn)鎖失效的情況了,因此我們需要確保加鎖的事件盡可能的短,這也是為什么加鎖請求都有超時時間的原因了,發(fā)現(xiàn)超時立馬跳到下一個節(jié)點,避免單個節(jié)點耗時過長。
雖然 Redlock 看上去是比較完善的分布式解決方案,但是實際上這個方案是比較重的,需要維護一個 Redis 集群,另外過程中依賴系統(tǒng)時間,但是如果出現(xiàn)了時間跳變,那么對于整個分布式鎖都有非常大的影響。
2.3 基于 Zookeeper 的分布式鎖實現(xiàn)方案
2.3.1 實現(xiàn)原理
Zookeeper 是一個分布式的應(yīng)用協(xié)調(diào)服務(wù)中間件,通過它也可以實現(xiàn)分布式鎖的效果,這里介紹的是基于臨時有序的 ZNode 分布式鎖實現(xiàn)方案。在介紹方案之前,先補充下 Zookeeper 中和分布式鎖息息相關(guān)的特性。
我們來看下 Zookeeper 的數(shù)據(jù)結(jié)構(gòu),實際上它是一種樹形模型,類似于 Linux 的文件系統(tǒng)。Zookeeper 使用類似于文件目錄的層級目錄數(shù)據(jù)結(jié)構(gòu)來組織自身的數(shù)據(jù)存儲節(jié)點,這些節(jié)點就被稱作為 ZNode,每個節(jié)點都用一個以斜杠(/)分隔的路徑來表示,而且每個節(jié)點都有父節(jié)點(根節(jié)點除外)。另外在 Zookeeper 中,如果我們使用不同的創(chuàng)建參數(shù),可以創(chuàng)建不同類型的 ZNode。
1、持久化 ZNode:當 createMode 為 PERSISTENT 會創(chuàng)建持久化 ZNode,節(jié)點存儲的數(shù)據(jù)會永久保存在 Zookeeper 中,如果 createMode 為 PERSISTENT_SEQUENTIAL,則會創(chuàng)建有序持久化 ZNode,和之前的持久化節(jié)點不通的是,有序持久化節(jié)點的節(jié)點名稱會附加上全局有序的遞增序號;
2、臨時 ZNode:當 createMode 為 EPHEMERAL 時,創(chuàng)建的節(jié)點臨時節(jié)點,在與客戶端的 session 過期后,對應(yīng)的臨時節(jié)點也會被刪除。當 createMode 為 EPHEMERAL_SEQUENTIAL 時創(chuàng)建出來的為有序的臨時節(jié)點,當 session 過期之后,節(jié)點及其存儲的數(shù)據(jù)也是會被刪除的。

通過上述對于節(jié)點特性的描述,可以看出來它的全局遞增有序以及過期刪除的特性與分布式鎖實現(xiàn)的原理非常契合。因此通過 Zookeeper 實現(xiàn)分布式鎖的大致可以分為以下幾個步驟:
1、首先創(chuàng)建一個持久化節(jié)點也就是父節(jié)點,這個持久化節(jié)點代表著一個分布式鎖實例;
2、當有線程想要申請分布式鎖的時候,則在該持久化節(jié)點下創(chuàng)建臨時有序節(jié)點;
3、如果此時新建的臨時有序節(jié)點是該父節(jié)點小所有有序節(jié)點中序號最小的節(jié)點,那么此時就表示申請到了分布式鎖;
4、如果新建的臨時節(jié)點當前不是最小序號的節(jié)點,則需要不斷檢查是否最小,知道最終獲取到鎖,或者節(jié)點超時。實際上這個是通過 Zookeeper 的 watch 機制實現(xiàn)的,在當前節(jié)點的上一序號的節(jié)點設(shè)置監(jiān)聽器,檢查是否為最小節(jié)點的任務(wù)可以一直阻塞,直到收到上一節(jié)點被刪除的時間事件,則喚醒檢查事件,檢查當前節(jié)點是不是最小序號節(jié)點。
5、當線程執(zhí)行完業(yè)務(wù)之后,可以手動刪除該臨時節(jié)點以便于釋放持有的鎖。另外即使服務(wù)掛掉,由于對應(yīng)的 session 失效,對應(yīng)的臨時節(jié)點也會被刪除,防止出現(xiàn)死鎖問題。

和 Redisson 類似,我們在實際使用 Zookeeper 作為分布式鎖的時候可以用 Curator 來作為開發(fā) SDK,它同樣封裝了很多實現(xiàn),包括可重入鎖的實現(xiàn),減輕了使用者的負擔。
2.3.2 方案分析
看上去通過 Zookeeper 實現(xiàn)分布式鎖還是比較好的一種解決方案,但是它是完美的嗎?從上面的分布式鎖的流程可知,客戶端線程想要獲取鎖就需要創(chuàng)建臨時節(jié)點,這個時候客戶端和 Zookeeper 之間就會維護一個 session,來表示該客戶端還在排隊等待獲取鎖。因此這個方案的潛在問題就在于一旦出現(xiàn)網(wǎng)絡(luò)異常,或者客戶端發(fā)生 STW GC,那么就可能導(dǎo)致 session 關(guān)閉,從而導(dǎo)致臨時節(jié)點被關(guān)閉,此時就會出現(xiàn)原來客戶端持有的鎖被刪除了,如果有另外的客戶端過來加鎖的話可以成功獲取,那么此時就出現(xiàn)并發(fā)安全問題了。因此在這種極端條件下,Zookeeper 的分布式鎖實現(xiàn)方案也不是 100%保證安全的。
另外實際上還有基于 etcd 的分布式鎖實現(xiàn)方案,其基本原理和 Zookeeper 差不多,感興趣的同學(xué)可以再進行了解下。
分布式鎖方案改怎么選?
通過上述幾種分布式鎖方案原理的闡述以及問題分析,每個方案都有自己的長處以及缺點。所以在實際項目落地的時候,我么需要結(jié)合實際來進行分布式鎖方案的選擇。比如如果平臺中本身已經(jīng)有 Redis 集群了,但是沒有 Zookeeper 集群,那么我們就可以借助于現(xiàn)有的基礎(chǔ)實施來落地分布式鎖,不需要再去維護一套 Zookeeper 集群。
另外根據(jù)實際的業(yè)務(wù)場景,如果并發(fā)量并不是很高,也可以通過簡單的數(shù)據(jù)庫的分布式鎖方案來實現(xiàn)。
總結(jié)
本文首先對從單機時代到分布式場景下的分布式鎖的產(chǎn)生的背景進行了分析,通過對分布式鎖的本質(zhì)問題的探究,引出了數(shù)據(jù)庫分布式鎖方案、Redis 分布式鎖方案以及 Zookeeper 分布式鎖方案,并對每一種方案的優(yōu)點以及不足進行了分析,相信大家可以在落地實現(xiàn)分布式鎖的時候可以按照自身的情況選擇合適的方案。

