ZooKeeper 分散式鎖實踐(下):讀寫鎖
作者 | Sunny
杏仁後端工程師,專注高併發和分散式程式設計,Golang愛好者。
在 ZooKeeper 分散式鎖實踐(上篇)排它鎖 中我們通過程式碼實踐瞭如何使用 ZooKeeper 元件來實現排他鎖。
排他鎖簡單易用,但是缺點也很明顯:
-
競爭壓力大:當鎖被佔用之後,其他獲取鎖的操作只能阻塞等待;當鎖釋放後,所有等待鎖的程序會在同一時刻爭搶鎖的使用權。
-
羊群響應:鎖釋放後,會通知所有等待鎖的程序,如果等待者特別多,一時間鎖的競爭壓力將會特別大。
簡單來說就是: 通知範圍太廣、鎖的粒度太大 。我們可以分別從這兩個層面去尋找解決方案:
-
縮小通知範圍:等待鎖的小夥伴們按先來後到的順序排隊吧,排好隊了,接下來我只需要關心我前面一個節點的狀態,當前一個節點被釋放,我再去搶鎖。
-
縮小鎖的粒度:鎖不關心業務,但是可以簡單地通過操作的讀、寫性質來二分鎖的粒度:
-
讀鎖:又稱共享鎖,如果前面沒有寫節點,可以直接上鎖;當前面有寫節點時,則等待距離自己最近的寫節點釋放( 刪除 )。
-
寫鎖:如果前面沒有節點,可以直接上鎖;如果前面有節點,則等待前一個節點釋放( 刪除 )。
思考:為什麼不是關注前面距離自己最近的寫節點?
如果兩個寫節點之間有讀節點,必需等待讀節點釋放之後再進行寫節點請求,否則會有不可重複讀的問題。
資料結構 和排他鎖一樣,我們通過 ZooKeeper 的節點來表示一個讀寫鎖的父節點,如 /SHARE_LOCK
,通過父節點下的臨時自增子節點來表示一個讀寫操作請求,如 /SHARE_LOCK/R_0000000001
。整體資料結構如下圖所示。
演算法
獲取鎖
獲取鎖的演算法步驟:
-
開始嘗試獲取鎖
-
如果持久化父節點不存在,則建立父節點
-
如果當前臨時自增子節點不存在,則建立子節點
-
獲取父節點下的所有子節點
-
在所有子節點中,查詢序號比當前子節點小的前置子節點( 最近的兄節點 )有兩種情況:
-
讀請求:查詢比自己小的前置**寫**子節點 ( 最近的兄節點 )
-
寫請求:查詢比自己小的前置子節點 ( 最近的兄節點 )
-
如果沒有更小的前置子節點,則持有鎖
-
如果有更小的前置子節點,則監聽該子節點被釋放( 刪除 )的事件
-
釋放 ( 刪除 )子節點事件被觸發後,重複第 1 步
釋放鎖
釋放鎖的演算法與排他鎖部分的釋放鎖演算法相似,這裡不再贅述。
加鎖、解鎖流程
加鎖、解鎖完整的流程圖。
程式碼實現
子節點定義
子節點屬性
-
lockName
讀寫鎖的名稱,即父節點的名稱 -
name
子節點的名稱,格式為 :{請求型別:R/W}_{自增序號}
( 子節點的路徑為:{lockName}/{name}
) -
seq
子節點的自增序號,通過解析name
屬性_
下劃線分隔符後面的數字字串來獲取序號( ZooKeeper 建立臨時自增節點時會自動分配 Int 範圍內的序號 ) -
isWrite
子節點是否為寫請求,通過解析name
屬性_
下劃線分隔符前面的英文字元來判斷請求型別 : -
R :讀請求
-
W :寫請求
讀寫鎖定義和初始設定
讀寫鎖的屬性
-
lockName
讀寫鎖的名稱,即父節點的路徑 -
locker
獲取鎖的請求方,即鎖的持有者,釋放鎖時需要驗證請求者與鎖的持有者是否一致 -
isWrite
請求型別: -
讀:
false
-
寫:
true
讀寫鎖的初始設定
-
連線到 ZooKeeper 例項
-
連線後,如果父節點不存在,則建立父節點
嘗試獲取鎖
嘗試獲取鎖的演算法實現
-
獲取或建立 ZooKeeper 子節點
-
獲取當前子節點後,遍歷所有的子節點,查詢:
-
front
離當前子節點最近的兄節點:序號比當前子節點的序號小、且在小於當前序號的子節點中序號是最大的 -
fontWrite
離當前子節點最近的寫、兄節點:序號比當前子節點的序號小、且在小於當前序號的子節點中序號是最大的、且為寫子節點 -
查詢後,返回序號更小的兄節點:
-
讀請求:返回最近的寫、兄節點,用於 Watch 監聽釋放( 刪除 )事件
-
寫請求:返回最近的兄節點,用於 Watch 監聽釋放( 刪除 )事件
-
如果沒有更小的子節點,返回
None
,表示成功地獲取了鎖
同步獲取鎖
同步獲取鎖的演算法實現
-
嘗試獲取鎖
-
如果沒有兄節點,則成功持有鎖
-
如果得到更小的兄節點,則監聽該兄節點的釋放( 刪除 )事件
-
收到兄節點的釋放( 刪除 )事件通知後,重複第 1 步
測試驗證
最後,通過一個簡單的測試方法來驗證讀寫鎖的加、解鎖過程:
測試結果
測試結果、分析
# | 請求方 | 操作 | 輸出 |
---|---|---|---|
1 | LOCK1_讀 | 加鎖:成功 | [LOCK1] : Lock |
2 | LOCK2_寫 | 加鎖:等待,因為有未釋放的兄節點 LOCK1 | |
3 | LOCK3_寫 | 加鎖:等待,因為有未釋放的兄節點 LOCK2 | |
4 | LOCK4_讀 | 加鎖:等待,因為有未釋放的兄、寫節點 LOCK3 | |
5 | LOCK5_讀 | 加鎖:等待,因為有未釋放的兄、寫節點 LOCK3 | |
6 | LOCK1_讀 | 解鎖:成功,通知到 LOCK2 | [LOCK1] : Unlock |
7 | LOCK2_寫 | 收到通知,嘗試加鎖:成功 | [LOCK2] : Lock |
8 | LOCK2_寫 | 解鎖:成功,通知到 LOCK3 | [LOCK2] : Unlock |
9 | LOCK3_寫 | 收到通知,加鎖成功 | [LOCK3] : Lock |
10 | LOCK3_寫 | 解鎖成功,通知到 LOCK4、LOCK5 | [LOCK3] : Unlock |
11 | LOCK4_讀 | 收到通知,嘗試加鎖:成功 | [LOCK4] : Lock |
12 | LOCK5_讀 | 收到通知,嘗試加鎖:成功 | [LOCK5] : Lock |
13 | LOCK4_讀 | 解鎖:成功 | [LOCK4] : Unlock |
14 | LOCK5_讀 | 解鎖:成功 | [LOCK5] : Unlock |
尾聲
通過 ZooKeeper 分散式鎖實踐,對它的介面最直觀的感受就是 簡單 。雖然它沒有直接提供加鎖、解鎖這樣的原語,但是當你瞭解了它的資料結構、介面和事件設計之後,加鎖、解鎖功能簡直呼之欲出,實現起來毫無障礙,一切都是那麼地合理、妥當。
而 ZooKeeper 的能力遠不止於此,就像前面提到的它能夠十分輕鬆地實現諸如:資料釋出/訂閱、負載均衡、命名服務、分散式協調/通知、叢集管理、Master選舉、分散式鎖、分散式佇列這些小菜,不得不佩服 ZooKeeper 設計者的抽象能力。本篇只是淺嘗了 ZooKeeper 的基本能力,有關它的設計思路、實現細節仍待進一步發掘和探索。
全文完
以下文章您可能也會感興趣:
-
原來你是這樣的 Stream —— 淺析 Java Stream 實現原理
-
分散式鎖實踐之一:基於 Redis 的實現
-
ConcurrentHashMap 的 size 方法原理分析
-
從 ThreadLocal 的實現看雜湊演算法
我們正在招聘 Java 工程師,歡迎有興趣的同學投遞簡歷到 [email protected] 。
杏仁技術站
長按左側二維碼關注我們,這裡有一群熱血青年期待著與您相會。