分布式鎖的三種實現原理


分布式鎖

1. 基於數據庫實現分布式鎖

要實現分布式鎖,最簡單的方式就是創建一張鎖表,然后通過操作該表中的數據來實現。

當我們要鎖住某個資源時,就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。數據庫對共享資源做了唯一性約束,如果有多個請求被同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,操作成功的那個線程就獲得了訪問共享資源的鎖,可以進行操作。

基於數據庫實現的分布式鎖,是最容易理解的。但是,因為數據庫需要落到硬盤上,頻繁讀取數據庫會導致 IO 開銷大,因此這種分布式鎖適用於並發量低,對性能要求低的場景。對於雙 11、雙 12 等需求量激增的場景,數據庫鎖是無法滿足其性能要求的。而在平日的購物中,我們可以在局部場景中使用數據庫鎖實現對資源的互斥訪問。

優缺點

可以看出,基於數據庫實現分布式鎖比較簡單,絕招在於創建一張鎖表,為申請者在鎖表里建立一條記錄,記錄建立成功則獲得鎖,消除記錄則釋放鎖。該方法依賴於數據庫,主要有兩個缺點:

  • 單點故障問題。一旦數據庫不可用,會導致整個系統崩潰。
  • 死鎖問題。數據庫鎖沒有失效時間,未獲得鎖的進程只能一直等待已獲得鎖的進程主動釋放鎖。一旦已獲得鎖的進程掛掉或者解鎖操作失敗,會導致鎖記錄一直存在數據庫中,其他進程無法獲得鎖。

2. 基於緩存實現分布式鎖

數據庫的性能限制了業務的並發量,那么對於雙 11、雙 12 等需求量激增的場景是否有解決方法呢?

基於緩存實現分布式鎖的方式,非常適合解決這種場景下的問題。所謂基於緩存,也就是說把數據存放在計算機內存中,不需要寫入磁盤,減少了 IO 讀寫。接下來,我以 Redis 為例與你展開這部分內容。

Redis 通常可以使用 setnx(key, value) 函數來實現分布式鎖。key 和 value 就是基於緩存的分布式鎖的兩個屬性,其中 key 表示鎖 id,value = currentTime + timeOut,表示當前時間 + 超時時間。也就是說,某個進程獲得 key 這把鎖后,如果在 value 的時間內未釋放鎖,系統就會主動釋放鎖。

setnx 函數的返回值有 0 和 1:

  • 返回 1,說明該服務器獲得鎖,setnx 將 key 對應的 value 設置為當前時間 + 鎖的有效時間。
  • 返回 0,說明其他服務器已經獲得了鎖,進程不能進入臨界區。該服務器可以不斷嘗試 setnx 操作,以獲得鎖。

總結來說,Redis 通過隊列來維持進程訪問共享資源的先后順序。Redis 鎖主要基於 setnx 函數實現分布式鎖,當進程通過 setnx<key,value> 函數返回 1 時,表示已經獲得鎖。排在后面的進程只能等待前面的進程主動釋放鎖,或者等到時間超時才能獲得鎖。

優缺點

相對於基於數據庫實現分布式鎖的方案來說,基於緩存實現的分布式鎖的優勢表現在以下幾個方面:

  • 性能更好。數據被存放在內存,而不是磁盤,避免了頻繁的 IO 操作。
  • 很多緩存可以跨集群部署,避免了單點故障問題。
  • 很多緩存服務都提供了可以用來實現分布式鎖的方法,比如 Redis 的 setnx 方法等。
  • 可以直接設置超時時間來控制鎖的釋放,因為這些緩存服務器一般支持自動刪除過期數據。

這個方案的不足是,通過超時時間來控制鎖的失效時間,並不是十分靠譜,因為一個進程執行時間可能比較長,或受系統進程做內存回收等影響,導致時間超時,從而不正確地釋放了鎖。

3. 基於 ZooKeeper 實現分布式鎖

定義鎖

ZooKeeper 通過一個數據節點來表示一個鎖,類似於“/shared_lock/[Hostname]-請求類型-序號” 的臨時順序節點,例如 /shared_lock/192.168.0.1-R-0000000001,這個節點就代表了一個鎖,如圖所示。

image

獲取鎖

在需要獲取鎖時,所有客戶端都會到/shared_lock這個節點下面創建一個臨時順序節點:

  • 如果當前是讀請求,那么就創建如 /shared_lock/192.168.0.1-R-0000000001 的節點;

  • 如果當前是寫請求,那么就創建如 /shared_lock/192.168.0.1-W-0000000001 的節點。

判斷讀寫順序

由於不同的事務都可以同時對同一個數據對象進行讀取操作,而更新操作必須在當前沒有任何事務進行讀寫操作的情況下進行。基於這個原則,大致可以分為如下4個步驟:

  1. 創建完節點后,獲取/shared_lock節點下的所有子節點,並對該節點注冊所有子節點變更的 Watcher 監聽。

  2. 確定自己的節點序號在所有子節點中的順序。

  3. 對於讀請求:

    • 如果沒有比自己序號小的子節點,或是所有比自己序號小的子節點都是讀請求,那么表明自己已經成功獲取到了讀鎖,同時開始執行讀取邏輯。

    • 如果比自己序號小的子節點中有寫請求,那么就需要進入等待。

      對於寫請求:

    • 如果自己不是序號最小的子節點,那么就需要進入等待。

  4. 接收到 Watcher 通知后,重復步驟1。

釋放鎖

/exclusive_lock/lock 是一個臨時節點,在以下兩種情況下可能釋放鎖:

  • 當前獲取鎖的客戶端機器發生宕機,那么ZooKeeper 上的這個臨時節點就會被移除。
  • 正常執行完業務邏輯后,客戶端就會主動將自己創建的臨時節點刪除。

無論在什么情況下移除了lock節點,ZooKeeper 都會通知所有在 /exclusive_lock 節點上注冊了子節點變更Watcher 監聽的客戶端。這些客戶端在接收到通知后,再次重新發起分布式鎖獲取,即重復“獲取鎖”過程。

羊群效應

上面講解的這個鎖實現,大體上能夠滿足一般的分布式集群競爭鎖的需求,並且性能都還可以——這里說的一般場景是指集群規模不是特別大,一般是在10台機器以內。但是如果機器規模擴大之后,會有什么問題呢?我們着重來看上面“判斷讀寫順序”過程的步驟3,結合下圖給出的實例,看看實際運行中的情況。

image

針對圖中的實際情況,我們看看會發生什么事情。

  1. 192.168.0.1 這台機器首先進行讀操作,完成讀操作后將節點/192.168.0.1- R-0000000001刪除。
  2. 余下的4台機器均收到了這個節點被移除的通知,然后重新從/shared_lock節點上獲取一份新的子節點列表。
  3. 每個機器判斷自己的讀寫順序。其中192.168.0.2這台機器檢測到自己已經是序號最小的機器了,於是開始進行寫操作,而余下的其他機器發現沒有輪到自己進行讀取或更新操作,於是繼續等待。
  4. 繼續……

很明顯,我們看到,192.168.0.1這個客戶端在移除自己的共享鎖后,ZooKeeper 發送了子節點變更Watcher通知給所有機器,然而這個通知除了給192.168.0.2這台機器產生實際影響外,對於余下的其他所有機器都沒有任何作用。

在這整個分布式鎖的競爭過程中,大量的“Watcher通知”和“子節點列表獲取”兩個操作重復運行,並且絕大多數的運行結果都是判斷出自己並非是序號最小的節點,從而繼續等待下一次通知——這個看起來顯然不怎么科學。

客戶端無端地接收到過多和自己並不相關的事件通知,如果在集群規模比較大的情況下,不僅會對ZooKeeper服務器造成巨大的性能影響和網絡沖擊,更為嚴重的是,如果同一時間有多個節點對應的客戶端完成事務或是事務中斷引起節點消失,ZooKeeper服務器就會在短時間內向其余客戶端發送大量的事件通知——這就是所謂的羊群效應。

上面這個ZooKeeper分布式共享鎖實現中出現羊群效應的根源在於,沒有找准客戶端真正的關注點。我們再來回顧一下上面的分布式鎖競爭過程,它的核心邏輯在於:判斷自己是否是所有子節點中序號最小的。於是,很容易可以聯想到,每個節點對應的客戶端只需要關注比自己序號小的那個相關節點的變更情況就可以了——而不需要關注全局的子列表變更情況。

改進后的分布式鎖實現

現在我們來看看如何改進上面的分布式鎖實現。首先,我們需要肯定的一點是,上面提到的鎖實現,從整體思路上來說完全正確。這里主要的改動在於:每個鎖競爭者,只需要關注 /shared_lock 節點下序號比自己小的那個節點是否存在即可,具體實現如下。

  1. 客戶端調用 create() 方法創建一個類似於“/shared_lock/[Hostname]-請求類型-序號”的臨時順序節點。

  2. 客戶端調用 getChildren() 接口來獲取所有已經創建的子節點列表,注意,這里不注冊任何Watcher。

  3. 如果無法獲取共享鎖,那么就調用exist()來對比自己小的那個節點注冊Watcher。注意,這里“比自己小的節點”只是一個籠統的說法,具體對於讀請求和寫請求不一樣。

    • 讀請求:向比自己序號小的最后一個寫請求節點注冊 Watcher 監聽。

    • 寫請求:向比自己序號小的最后一個節點注冊 Watcher 監聽。

  4. 等待Watcher通知,繼續進入步驟2。

注意

看到這里,相信很多讀者都會覺得改進后的分布式鎖實現相對來說比較麻煩。確實如此,如同在多線程並發編程實踐中,我們會去盡量縮小鎖的范圍——對於分布式鎖實現的改進其實也是同樣的思路。那么對於開發人員來說,是否必須按照改進后的思路來設計實現自己的分布式鎖呢?答案是否定的。在具體的實際開發過程中,我們提倡根據具體的業務場景和集群規模來選擇適合自己的分布式鎖實現:在集群規模不大、網絡資源豐富的情況下,第一種分布式鎖實現方式是簡單實用的選擇;而如果集群規模達到一定程度,並且希望能夠精細化地控制分布式鎖機制,那么不妨試試改進版的分布式鎖實現。

優缺點

可以看到,使用 ZooKeeper 可以完美解決設計分布式鎖時遇到的各種問題,比如單點故障、不可重入、死鎖等問題。雖然 ZooKeeper 實現的分布式鎖,幾乎能涵蓋所有分布式鎖的特性,且易於實現,但需要頻繁地添加和刪除節點,所以性能不如基於緩存實現的分布式鎖。 Zookeeper實現的分布式鎖在中小型公司的普及率不高,尤其是非 Java 技術棧的公司使用的較少,如果只是為了實現分布式鎖而重新搭建一套 ZooKeeper 集群,顯然實現成本和維護成本太高。

4. 三種實現方式對比

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM