大家好,我是 沐子,
分布式鎖的話題,很多文章已經寫爛了,我為什么還要寫這篇文章呢?
因為我發現網上 90% 的文章,并沒有把這個問題真正講清楚,導致很多讀者看了很多文章,依舊云里霧里,例如下面這些問題,你能清晰地回答上來嗎?
資料庫通過樂觀鎖怎么實作分布式鎖?
基于 Redis 如何實作一個分布式鎖?
Redis 如何避免死鎖?
Redis 如何合理的設定超時時間?
Zookeeper如何規避羊群效應?
三種分布式鎖的優缺點分別是什么?
這篇文章,我就來把這些問題徹底講清楚,
讀完這篇文章,你不僅可以徹底了解分布式鎖,還會對「分布式系統」有更加深刻的理解,
一、 為什么需要分布式鎖
為了保證一個方法在高并發情況下的同一時間只能被同一個執行緒執行,在傳統單體應用單機部署的情況下,可以使用Java并發處理相關的API(如ReentrantLcok或synchronized)進行互斥控制,但是,隨著業務發展的需要,原單體單機部署的系統被演化成分布式系統后,由于分布式系統多執行緒、多行程并且分布在不同機器上,這將使原單機部署情況下的并發控制鎖策略失效,為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題,
二、 常見的分布式鎖方案
我們在使用分布式鎖的時候,大部分同學可能都忽略了一點,那就是分布式鎖經常出現哪些問題,以及如何解決,
可用問題:無論何時都要保證鎖服務的可用性(這是系統正常執行鎖操作的基礎),
死鎖問題:客戶端一定可以獲得鎖,即使鎖住某個資源的客戶端在釋放鎖之前崩潰或者網路不可達(這是避免死鎖的設計原則),
腦裂問題:集群同步時產生的資料不一致,導致新的行程有可能拿到鎖,但之前的行程以為自己還有鎖,那么就出現兩個行程拿到了同一個鎖的問題,
總的來說,設計分布式鎖服務,至少要解決上面最核心的幾個問題,才能評估鎖的優劣,一般分布式鎖有三種常見的實作方式:
1. 資料庫樂觀鎖;
2. 基于分布式快取Redis的分布式鎖;
3. 基于ZooKeeper的分布式鎖
1. 基于關系型資料庫實作分布式鎖
1)基于悲觀鎖的方式實作分布式鎖
基于關系型資料庫(如 MySQL) 來實作分布式鎖是任何階段的研發同學都需要掌握的,做法如下:先查詢資料庫是否存在記錄,為了防止幻讀取(幻讀取:事務 A 按照一定條件進行資料讀取,這期間事務 B 插入了相同搜索條件的新資料,事務 A 再次按照原先條件進行讀取時,發現了事務 B 新插入的資料 )通過資料庫行鎖 select for update 鎖住這行資料,然后將查詢和插入的 SQL 在同一個事務中提交,以訂單表為例:
select id from order where order_id = xxx for update
基于關系型資料庫實作分布式鎖比較簡單,不過你要注意,基于 MySQL 行鎖的方式會出現交叉死鎖,比如事務 1 和事務 2 分別取得了記錄 1 和記錄 2 的排它鎖,然后事務 1 又要取得記錄 2 的排它鎖,事務 2 也要獲取記錄 1 的排它鎖,那這兩個事務就會因為相互鎖等待,產生死鎖,
當然,你可以通過“超時控制”解決交叉死鎖的問題,但在高并發情況下,出現的大部分請求都會排隊等待,所以“基于關系型資料庫實作分布式鎖”的方式在性能上存在缺陷,
2)基于樂觀鎖的方式實作分布式鎖
在資料庫層面,select for update 是悲觀鎖,會一直阻塞直到事務提交,所以為了不產生鎖等待而消耗資源,你可以基于樂觀鎖的方式來實作分布式鎖,比如基于版本號的方式,首先在資料庫增加一個 int 型欄位 ver,然后在 SELECT 同時獲取 ver 值,最后在 UPDATE 的時候檢查 ver 值是否為與第 2 步或得到的版本值相同,
//SELECT 同時獲取 ver 值
select amount, old_ver from order where order_id = xxx
// UPDATE 的時候檢查 ver 值是否與第 2 步獲取到的值相同
update order set ver = old_ver + 1, amount = yyy where order_id = xxx and ver = old_ver
此時,如果更新結果的記錄數為1,就表示成功,如果更新結果的記錄數為 0,就表示已經被其他應用更新過了,需要做例外處理,
這個策略源于 mysql 的 mvcc 機制,使用這個策略其實本身沒有什么問題,主要的問題就是對資料表侵入較大,我們要為每個表設計一個版本號欄位,然后寫一條判斷 sql 每次進行判斷,增加了資料庫操作的次數,在高并發的要求下,對資料庫連接的開銷也是無法忍受的,
2. 基于分布式快取Redis實作分布式鎖
因為資料庫的性能限制了業務的并發量,所以針對“ 618 和雙 11 大促”等請求量劇增的場景,需要引入基于快取的分布式鎖,這個方案可以避免大量請求直接訪問資料庫,提高系統的回應能力,基于快取實作的分布式鎖,就是將資料僅存放在系統的記憶體中,不寫入磁盤,從而減少 I/O 讀寫,接下來,我以 Redis 為例講解如何實作分布式鎖,
我們從最簡單的開始講起,
想要實作分布式鎖,必須要求 Redis 有「互斥」的能力,我們可以使用 SETNX 命令,這個命令表示SET if Not eXists,即如果 key 不存在,才會設定它的值,否則什么也不做,
兩個客戶端行程可以執行這個命令,達到互斥,就可以實作一個分布式鎖,
客戶端 1 申請加鎖,加鎖成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客戶端1,加鎖成功
客戶端 2 申請加鎖,因為它后到達,加鎖失敗:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客戶端2,加鎖失敗
此時,加鎖成功的客戶端,就可以去操作「共享資源」,例如,修改 MySQL 的某一行資料,或者呼叫一個 API 請求,
操作完成后,還要及時釋放鎖,給后來者讓出操作共享資源的機會,如何釋放鎖呢?
也很簡單,直接使用 DEL 命令洗掉這個 key 即可:
127.0.0.1:6379> DEL lock // 釋放鎖
(integer) 1
這個邏輯非常簡單,整體的路程就是這樣:
但是,它存在一個很大的問題,當客戶端 1 拿到鎖后,如果發生下面的場景,就會造成「死鎖」:
程式處理業務邏輯例外,沒及時釋放鎖
行程掛了,沒機會釋放鎖
這時,這個客戶端就會一直占用這個鎖,而其它客戶端就「永遠」拿不到這把鎖了,怎么解決這個問題呢?
如何避免死鎖?
我們很容易想到的方案是在 Redis 中實作時,就是給這個 key 設定一個「過期時間」,這里我們假設,操作共享資源的時間不會超過 10s,那么在加鎖時,給這個 key 設定 10s 過期即可:
127.0.0.1:6379> SETNX lock 1 // 加鎖
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10s后自動過期
(integer) 1
這樣一來,無論客戶端是否例外,這個鎖都可以在 10s 后被「自動釋放」,其它客戶端依舊可以拿到鎖,
但這樣真的沒問題嗎?
還是有問題,
現在的操作,加鎖、設定過期是 2 條命令,有沒有可能只執行了第一條,第二條卻「來不及」執行的情況發生呢?例如:
SETNX 執行成功,執行 EXPIRE 時由于網路問題,執行失敗
SETNX 執行成功,Redis 例外宕機,EXPIRE 沒有機會執行
SETNX 執行成功,客戶端例外崩潰,EXPIRE 也沒有機會執行
總之,這兩條命令不能保證是原子操作(一起成功),就有潛在的風險導致過期時間設定失敗,依舊發生「死鎖」問題,
怎么辦?
在 Redis 2.6.12 版本之前,我們需要想盡辦法,保證 SETNX 和 EXPIRE 原子性執行,還要考慮各種例外情況如何處理,
但在 Redis 2.6.12 之后,Redis 擴展了 SET 命令的引數,用這一條命令就可以了:
// 一條命令保證原子性執行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
這樣就解決了死鎖問題,也比較簡單,
我們再來看分析下,它還有什么問題?
試想這樣一種場景:
1.客戶端 1 加鎖成功,開始操作共享資源
2.客戶端 1 操作共享資源的時間,「超過」了鎖的過期時間,鎖被「自動釋放」
3.客戶端 2 加鎖成功,開始操作共享資源
4.客戶端 1 操作共享資源完成,釋放鎖(但釋放的是客戶端 2 的鎖)
看到了么,這里存在兩個嚴重的問題:
1.鎖過期:客戶端 1 操作共享資源耗時太久,導致鎖被自動釋放,之后被客戶端 2 持有
2.釋放別人的鎖:客戶端 1 操作共享資源完成后,卻又釋放了客戶端 2 的鎖
導致這兩個問題的原因是什么?我們一個個來看,
第一個問題,可能是我們評估操作共享資源的時間不準確導致的,
例如,操作共享資源的時間「最慢」可能需要 15s,而我們卻只設定了 10s 過期,那這就存在鎖提前過期的風險,
過期時間太短,那增大冗余時間,例如設定過期時間為 20s,這樣總可以了吧?
這樣確實可以「緩解」這個問題,降低出問題的概率,但依舊無法「徹底解決」問題,
為什么?
原因在于,客戶端在拿到鎖之后,在操作共享資源時,遇到的場景有可能是很復雜的,例如,程式內部發生例外、網路請求超時等等,
既然是「預估」時間,也只能是大致計算,除非你能預料并覆寫到所有導致耗時變長的場景,但這其實很難,
有什么更好的解決方案嗎?
別急,關于這個問題,我會在后面詳細來講對應的解決方案,
我們繼續來看第二個問題,
第二個問題在于,一個客戶端釋放了其它客戶端持有的鎖,
想一下,導致這個問題的關鍵點在哪?
重點在于,每個客戶端在釋放鎖時,都是「無腦」操作,并沒有檢查這把鎖是否還「歸自己持有」,所以就會發生釋放別人鎖的風險,這樣的解鎖流程,很不「嚴謹」!
如何解決這個問題呢?
有一個更加安全的方案是為 set 指令的 value 引數設定為一個亂數,釋放鎖時先匹配亂數是否一致,然后再洗掉 key,但是匹配 value 和洗掉 key 不是一個原子操作,Redis 也沒有提供類似于 delifequals 這樣的指令,這就需要使用 Lua 腳本來處理了,因為 Lua 腳本可以保證連續多個指令的原子性執行,
// 釋放鎖時,先比較 unique_value 是否相等,避免鎖的誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return0
end
以上,就是基于 Redis 的 SET 命令和 Lua 腳本在 Redis 單節點上完成了分布式鎖的加鎖、解鎖,不過在實際面試中,你不能僅停留在操作上,因為這并不能滿足應對面試需要掌握的知識深度, 所以你還要清楚基于 Redis 實作分布式鎖的優缺點;Redis 的超時時間設定問題;站在架構設計層面上 Redis 怎么解決集群情況下分布式鎖的可靠性問題,需要注意的是,你不用一股腦全部將其說出來,而是要做好準備,以便跟上面試官的思路,同頻溝通,
基于 Redis 實作分布式鎖的優缺點
基于資料庫實作分布式鎖的方案來說,基于快取實作的分布式鎖主要的優點主要有三點,
a. 性能高效(這是選擇快取實作分布式鎖最核心的出發點),
b. 實作方便,很多研發工程師選擇使用 Redis 來實作分布式鎖,很大成分上是因為 Redis 提供了 setnx 方法,實作分布式鎖很方便,但是需要注意的是,在 Redis2.6.12 的之前的版本中,由于加鎖命令和設定鎖過期時間命令是兩個操作(不是原子性的),當出現某個執行緒操作完成 setnx 之后,還沒有來得及設定過期時間,執行緒就掛掉了,就會導致當前執行緒設定 key 一直存在,后續的執行緒無法獲取鎖,最終造成死鎖的問題,所以要選型 Redis 2.6.12 后的版本或通過 Lua 腳本執行加鎖和設定超時時間(Redis 允許將 Lua 腳本傳到 Redis 服務器中執行, 腳本中可以呼叫多條 Redis 命令,并且 Redis 保證腳本的原子性),
c. 避免單點故障(因為 Redis 是跨集群部署的,自然就避免了單點故障),
當然,基于 Redis 實作分布式鎖也存在缺點,主要是不合理設定超時時間,以及 Redis 集群的資料同步機制,都會導致分布式鎖的不可靠性,
如何合理設定超時時間
通過超時時間來控制鎖的失效時間,不太靠譜,比如在有些場景中,一個執行緒 A 獲取到了鎖之后,由于業務代碼執行時間可能比較長,導致超過了鎖的超時時間,自動失效,后續執行緒 B 又意外的持有了鎖,當執行緒 A 再次恢復后,通過 del 命令釋放鎖,就錯誤的將執行緒 B 中同樣 key 的鎖誤洗掉了,
所以,如果鎖的超時時間設定過長,會影響性能,如果設定的超時時間過短,有可能業務阻塞沒有處理完成,能否合理設定超時時間,是基于快取實作分布式鎖很難解決的一個問題,
那么如何合理設定超時時間呢? 你可以基于續約的方式設定超時時間:先給鎖設定一個超時時間,然后啟動一個守護執行緒,讓守護執行緒在一段時間后,重新設定這個鎖的超時時間,實作方式就是:寫一個守護執行緒,然后去判斷鎖的情況,當鎖快失效的時候,再次進行續約加鎖,當主執行緒執行完成后,銷毀續約鎖即可,不過這種方式實作起來相對復雜,我建議你結合業務場景進行回答,所以針對超時時間的設定,要站在實際的業務場景中進行衡量,
Redis 如何解決集群情況下分布式鎖的可靠性?
由于 Redis 集群資料同步到各個節點時是異步的,如果在 Redis 主節點獲取到鎖后,在沒有同步到其他節點時,Redis 主節點宕機了,此時新的 Redis 主節點依然可以獲取鎖,所以多個應用服務就可以同時獲取到鎖,其實 Redis 官方已經設計了一個分布式鎖演算法 Redlock 解決了這個問題,而如果你能基于 Redlock 原理回答出怎么解決 Redis 集群節點實作分布式鎖的問題,會成為面試的加分項,那官方是怎么解決的呢?
為了避免 Redis 實體故障導致鎖無法作業的問題,Redis 的開發者 Antirez 設計了分布式鎖演算法 Redlock,引入該演算法后即使有某個 Redis 實體發生故障,因為鎖的資料在其他實體上也有保存,所以客戶端仍然可以正常地進行鎖操作,鎖的資料也不會丟失,那 Redlock 演算法是如何做到的呢?我們假設目前有 N 個獨立的 Redis 實體, 客戶端先按順序依次向 N 個 Redis 實體執行加鎖操作,這里的加鎖操作和在單實體上執行的加鎖操作一樣,但是需要注意的是,Redlock 演算法設定了加鎖的超時時間,為了避免因為某個 Redis 實體發生故障而一直等待的情況,當客戶端完成了和所有 Redis 實體的加鎖操作之后,如果有超過半數的 Redis 實體成功的獲取到了鎖,并且總耗時沒有超過鎖的有效時間,那么就是加鎖成功,
3. 基于Zookeeper實作分布式鎖
在介紹 ZooKeeper 分布式鎖前需要先了解一下 ZooKeeper 中節點(Znode),ZooKeeper 的資料存盤資料模型是一棵樹(Znode Tree),由斜杠(/)的進行分割的路徑,就是一個 Znode(如 /locks/my_lock),每個 Znode 上都會保存自己的資料內容,同時還會保存一系列屬性資訊,Znode 又分為以下四種型別:

Zookeeper實作分布鎖的大致思想為:每個客戶端對某個方法加鎖時,在 Zookeeper 上與該方法對應的指定節點的目錄下,生成一個唯一的臨時有序節點,判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個,當釋放鎖的時候,只需將這個臨時節點洗掉即可,同時,其可以避免服務宕機導致的鎖無法釋放,而產生的死鎖問題,
1)排它鎖
排他鎖,又稱寫鎖或獨占鎖,如果事務T1對資料物件O1加上了排他鎖,那么在整個加鎖期間,只允許事務T1對O1進行讀取或更新操作,其他任務事務都不能對這個資料物件進行任何操作,直到T1釋放了排他鎖,
排他鎖核心是保證當前有且僅有一個事務獲得鎖,并且鎖釋放之后,所有正在等待獲取鎖的事務都能夠被通知到,
Zookeeper 的強一致性特性,能夠很好地保證在分布式高并發情況下節點的創建一定能夠保證全域唯一性,即Zookeeper將會保證客戶端無法重復創建一個已經存在的資料節點,可以利用Zookeeper這個特性,實作排他鎖,
1??定義鎖:通過Zookeeper上的資料節點來表示一個鎖
2??獲取鎖:客戶端通過呼叫 create 方法創建表示鎖的臨時節點,可以認為創建成功的客戶端獲得了鎖,同時可以讓沒有獲得鎖的節點在該節點上注冊Watcher監聽,以便實時監聽到lock節點的變更情況
3??釋放鎖:以下兩種情況都可以讓鎖釋放
當前獲得鎖的客戶端發生宕機或例外,那么Zookeeper上這個臨時節點就會被洗掉
正常執行完業務邏輯,客戶端主動洗掉自己創建的臨時節點
基于Zookeeper實作排他鎖流程:
2)共享鎖
共享鎖,又稱讀鎖,如果事務T1對資料物件O1加上了共享鎖,那么當前事務只能對O1進行讀取操作,其他事務也只能對這個資料物件加共享鎖,直到該資料物件上的所有共享鎖都被釋放,
共享鎖與排他鎖的區別在于,加了排他鎖之后,資料物件只對當前事務可見,而加了共享鎖之后,資料物件對所有事務都可見,
1??定義鎖:通過Zookeeper上的資料節點來表示一個鎖,是一個類似于 /lockpath/[hostname]-請求型別-序號 的臨時順序節點
2??獲取鎖:客戶端通過呼叫 create 方法創建表示鎖的臨時順序節點,如果是讀請求,則創建 /lockpath/[hostname]-R-序號 節點,如果是寫請求則創建 /lockpath/[hostname]-W-序號節點
3??判斷讀寫順序:大概分為4個步驟
1)創建完節點后,獲取 /lockpath 節點下的所有子節點,并對該節點注冊子節點變更的Watcher監聽
2)確定自己的節點序號在所有子節點中的順序
3.1)對于讀請求:1. 如果沒有比自己序號更小的子節點,或者比自己序號小的子節點都是讀請求,那么表明自己已經成功獲取到了共享鎖,同時開始執行讀取邏輯 2. 如果有比自己序號小的子節點有寫請求,那么等待
3.2)對于寫請求,如果自己不是序號最小的節點,那么等待
4)接收到Watcher通知后,重復步驟1)
4??釋放鎖:與排他鎖邏輯一致
基于Zookeeper實作共享鎖流程:

3)羊群效應
在實作共享鎖的 “判斷讀寫順序” 的第1個步驟是:創建完節點后,獲取 /lockpath 節點下的所有子節點,并對該節點注冊子節點變更的Watcher監聽,這樣的話,任何一次客戶端移除共享鎖之后,Zookeeper將會發送子節點變更的Watcher通知給所有機器,系統中將有大量的 “Watcher通知” 和 “子節點串列獲取” 這個操作重復執行,然后所有節點再判斷自己是否是序號最小的節點(寫請求)或者判斷比自己序號小的子節點是否都是讀請求(讀請求),從而繼續等待下一次通知,
然而,這些重復操作很多都是 “無用的”,實際上每個鎖競爭者只需要關注序號比自己小的那個節點是否存在即可,
當集群規模比較大時,這些 “無用的” 操作不僅會對Zookeeper造成巨大的性能影響和網路沖擊,更為嚴重的是,如果同一時間有多個客戶端釋放了共享鎖,Zookeeper服務器就會在短時間內向其余客戶端發送大量的事件通知–這就是所謂的 “羊群效應”,
改進后的分布式鎖實作:
1??客戶端呼叫 create 方法創建一個類似于 /lockpath/[hostname]-請求型別-序號 的臨時順序節點,
2??客戶端呼叫 getChildren 方法獲取所有已經創建的子節點串列(這里不注冊任何Watcher),
3??如果無法獲取任何共享鎖,那么呼叫 exist 來對比自己小的那個節點注冊Watcher
讀請求:向比自己序號小的最后一個寫請求節點注冊Watcher監聽
寫請求:向比自己序號小的最后一個節點注冊Watcher監聽
4??等待Watcher監聽,繼續進入步驟2??
Zookeeper羊群效應改進前后Watcher監聽圖:

三、 三種分布式鎖對比
1. 資料庫分布式鎖實作的優點及缺點
a. 優點:
理解起來簡單,不需要維護額外的第三方中間件(比如Redis,Zk)
b. 缺點:
1.db操作性能較差,并且有鎖表的風險
2.非阻塞操作失敗后,需要輪詢,占用cpu資源;
3.長時間不commit或者長時間輪詢,可能會占用較多連接資源
2. Redis(快取)分布式鎖實作的優點及缺點
a. 優點:
對于Redis實作簡單,性能對比ZK和Mysql較好,如果不需要特別復雜的要求,那么自己就可以利用setNx進行實作,如果自己需要復雜的需求的話那么可以利用或者借鑒Redission,對于一些要求比較嚴格的場景來說的話可以使用RedLock,
b. 缺點:
1.鎖洗掉失敗 過期時間不好控制
2.非阻塞,操作失敗后,需要輪詢,占用cpu資源;
3. ZK分布式鎖實作的優點及缺點
a.優點:
ZK可以不需要關心鎖超時時間,實作起來有現成的第三方包,比較方便,并且支持讀寫鎖,ZK獲取鎖會按照加鎖的順序,所以其是公平鎖,對于高可用利用ZK集群進行保證,
b.缺點:
性能不如redis實作,主要原因是寫操作(獲取鎖釋放鎖)都需要在Leader上執行,然后同步到follower,
根據性能、可靠性、實作的復雜性和理解的難易程度等方面,對上面的三種方案總結如下:

總之上面幾種方式,哪種方式都無法做到完美,就像CAP一樣,在復雜性、可靠性、性能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道,
Redlock解決Redis 集群節點實作分布式鎖的問題,但是RedLock真的安全嗎?歡迎讀者們在評論區留言,一起探討學習,
最后,如果我的文章對你有所幫助或者有所啟發,歡迎關注公眾號(微信搜索公眾號:首席架構師專欄),里面有許多技術干貨,也有我對技術的思考和感悟,還有作為架構師的驗驗分享;關注后回復 【面試題】,有我準備的面試題、架構師大型專案實戰視頻等福利 , 小編會帶著你一起學習、成長,讓我們一起加油!!!
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/348294.html
標籤:其他
