分佈式技術架構原理解析之協調與同步(五)分佈式鎖

前言

前面的文章詳細介紹了“分佈式互斥”,解釋了同一臨界資源(共享資源)同一時刻只能被一個程序訪問的問題,也就是說只有獲得訪問權限的進程纔可以訪問共享資源,而此時其他進程必須等待擁有該權限的進程釋放權限。那麼在訪問共享資源時,這個權限是如何設置或產生的呢?以及設置或產生這個權限的工作原理是什麼?本文就來介紹分佈式鎖是如何解決這個問題的。

1、分佈式鎖介紹

1.1 什麼是鎖

通常在單機多線程環境中,會有多個線程訪問同一個共享資源(臨界資源)的情況,此時,爲了保證數據的一致性,會有某種機制來控制讓滿足某條件的線程訪問資源,不滿足的等待,直到下一輪競爭中滿足條件後纔可訪問資源。
而這個機制就是:爲了實現分佈式互斥,需要在某處設置一個所有線程都能看到的標記,當標記被設置後,其他線程只能等待擁有該標記的線程執行完成,並釋放該標記後,才能去設置該標記和訪問共享資源。這個標記,就是我們常說的
總結:在單機環境中鎖是實現多線程訪問同一共享資源時,保證同一時刻只有一個線程可訪問該資源而設置的一種標記

1.2 分佈式鎖與單機環境鎖的區別

分佈式鎖爲了實現多個進程併發訪問同一個臨界資源,同一時刻只有一個進程可訪問共享資源,確保數據的一致性。而與單機環境下鎖的主要區別是:

  1. 運行在分佈式環境:分佈式鎖是在分佈式環境下,系統部署在多個機器中,實現多進程分佈式互斥的一種鎖;
  2. 鎖被公共存儲:爲了保證多個進程能看到鎖,鎖被存在公共存儲(比如 Redis、Memcache、數據庫等三方存儲中);

1.3 分佈式鎖應用場景

我們先看一個實際場景的例子:某電商要售賣某大牌手機,庫存只有 2 個,但有多個來自不同地區的用戶幾乎同時下單,如果規定按照下單時間作爲購買成功的判斷依據,最終需要保證手機售出時,數據庫中更新的庫存是正確的。
**解決上述問題單機環境鎖的方案是:**給手機的庫存數加一個鎖。當有一個用戶提交訂單後,後臺服務器給庫存數加一個鎖,根據該用戶的訂單修改庫存。而其他用戶必須等到鎖釋放以後,才能重新獲取庫存數,繼續購買。在這裏,手機的庫存就是共享資源,不同的購買者對應着多個進程,後臺服務器對共享資源加的鎖就是告訴其他進程“非請勿入”。
但是上述方法能夠解決問題嗎?當然沒這麼簡單。想象一下,用戶 A 想買 1 個手機,用戶 B 想買 2 個手機。在理想狀態下,用戶 A 網速好先買走了 1 個,庫存還剩下 1 個,此時應該提示用戶 B 庫存不足,用戶 B 購買失敗。但實際情況是,用戶 A 和用戶 B 同時獲取到商品庫存還剩 2 個,用戶 A 買走 1 個,在用戶 A 更新庫存之前,用戶 B 又買走了 2 個,此時用戶 B 更新庫存,商品還剩 0 個。這時,電商就頭大了,總共 2 個吹風機,卻賣出去了 3 個。
所以,如果只使用單機鎖將會出現不可預知的後果。因此,在高併發場景下,爲了保證臨界資源同一時間只能被一個進程使用,從而確保數據的一致性,我們就需要引入分佈式鎖了。此外,在大規模分佈式系統中,單個機器的線程鎖無法管控多個機器對同一資源的訪問,這時使用分佈式鎖,就可以把整個集羣當作一個應用一樣去處理,實用性和擴展性更好。

1.4 分佈式鎖的實現方法

實現分佈式鎖的 3 種主流方法:

  • 基於數據庫實現分佈式鎖,這裏的數據庫指的是關係型數據庫;
  • 基於緩存實現分佈式鎖;
  • 基於 ZooKeeper 實現分佈式鎖;

1.4.1 基於數據庫實現分佈式鎖

基於數據庫實現分佈式鎖的方式就是創建一張鎖表,然後通過操作該表中的數據來實現。當我們要鎖住某個資源時,就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。數據庫對共享資源做了唯一性約束,如果有多個請求被同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,操作成功的那個線程就獲得了訪問共享資源的鎖,可以進行操作。
實現過程舉例:
下面,我們還是以上面電商售賣手機的場景爲例。手機庫存是 2 個,有 3 個來自不同地區的用戶{A,B,C}想要購買,其中用戶 A 想買 1 個,用戶 B 想買 2 個,用戶 C 想買 1 個。具體實現過程如下:

  • 用戶 A 和用戶 B 幾乎同時下單,但用戶 A 的下單請求最先到達服務器。

  • 該商家的產品數據庫中增加了一條關於用戶 A 的記錄,用戶 A 獲得了鎖,他的訂單請求被處理,服務器修改手機庫存數,減去 1 後還剩下 1 個。

  • 當用戶 A 的訂單請求處理完成後,有關用戶 A 的記錄被刪除,服務器開始處理用戶 B 的訂單請求。

  • 這時,庫存只有 1 個了,無法滿足用戶 B 的訂單需求,因此用戶 B 購買失敗。

  • 從數據庫中,刪除用戶 B 的記錄,服務器開始處理用戶 C 的訂單請求,庫存中 1 個吹風機滿足用戶 C 的訂單需求。

  • 數據庫中增加了一條關於用戶 C 的記錄,用戶 C 獲得了鎖,他的訂單請求被處理,服務器修改吹風機數量,減去 1 後還剩下 0 個。
    在這裏插入圖片描述
    小結:
    基於數據庫實現的分佈式鎖比較簡易,絕招在於創建一張鎖表,爲申請者在鎖表裏建立一條記錄,記錄建立成功則獲得鎖,消除記錄則釋放鎖。
    通常實際應用在局部場景中使用數據庫鎖實現對資源的互斥訪問。
    該方法依賴於數據庫,主要有兩個缺點

  • 單點故障問題:一旦數據庫不可用,會導致整個系統崩潰。

  • 死鎖問題:數據庫鎖沒有失效時間,未獲得鎖的進程只能一直等待已獲得鎖的進程主動釋放鎖。一旦已獲得鎖的進程掛掉或者解鎖操作失敗,會導致鎖記錄一直存在數據庫中,其他進程無法獲得鎖。

1.4.2 基於緩存實現分佈式鎖

基於數據庫實現的分佈式鎖,是最容易理解的。但是,因爲數據庫需要落到硬盤上,頻繁讀取數據庫會導致 IO 開銷大,因此這種分佈式鎖適用於併發量低,對性能要求低的場景。
對於雙 11、雙 12 等需求量激增的場景,數據庫鎖是無法滿足其性能要求的,而基於緩存實現分佈式鎖的方式,非常適合解決這種場景下的問題。所謂基於緩存,也就是說把數據存放在計算機內存中,不需要寫入磁盤,減少了 IO 讀寫。
Redis-基於緩存實現分佈式鎖的方式:
Redis 通常可以使用 setnx(key, value) 函數來實現分佈式鎖。keyvalue 就是基於緩存的分佈式鎖的兩個屬性,其中 key 表示鎖 id,value = currentTime + timeOut,表示當前時間 + 超時時間。也就是說,某個進程獲得 key 這把鎖後,如果在 value 的時間內未釋放鎖,系統就會主動釋放鎖setnx 函數的返回值有 0 和 1:

  • 返回 1:說明該服務器獲得鎖,setnx 將 key 對應的 value 設置爲當前時間 + 鎖的有效時間。

  • 返回0:說明其他服務器已經獲得了鎖,進程不能進入臨界區。該服務器可以不斷嘗試 setnx 操作,以獲得鎖。
    實現過程舉例:
    仍然以電商售賣手機的場景爲例,說明基於緩存實現的分佈式鎖,假設現在庫存數量是足夠的。實現過程如下:

  • 用戶 A 的請求因爲網速快,最先到達 Server2,setnx 操作返回 1,並獲取到購買吹風機的鎖;

  • 用戶 B 和用戶 C的請求,幾乎同時到達了 Server1 和 Server3,但因爲這時 Server2獲取到了吹風機數據的鎖,所以只能加入等待隊列。

  • Server2 獲取到鎖後,負責管理吹風機的服務器執行業務邏輯,只用了 1s就完成了訂單。

  • 訂單請求完成後,刪除鎖的 key,從而釋放鎖。

  • 此時,排在第二順位的 Server1獲得了鎖,可以訪問吹風機的數據資源。

  • 但不巧的是,Server1 在完成訂單後發生了故障,無法主動釋放鎖。

  • 於是,排在第三順位的 Server3 只能等設定的有效時間(比如 30 分鐘)到期,鎖自動釋放後,才能訪問吹風機的數據資源,也就是說用戶 C 只能到00:30:01 以後才能繼續搶購。
    在這裏插入圖片描述
    小結:
    總結來說,Redis 通過隊列來維持進程訪問共享資源的先後順序。Redis 鎖主要基於 setnx 函數實現分佈式鎖,當進程通過 setnx<key,value> 函數返回 1 時,表示已經獲得鎖。排在後面的進程只能等待前面的進程主動釋放鎖,或者等到時間超時才能獲得鎖。
    基於緩存實現的分佈式鎖的優勢表現在以下幾個方面:

  • 性能更好:數據被存放在內存,而不是磁盤,避免了頻繁的 IO 操作。

  • 避免單獨故障:很多緩存可以跨集羣部署,避免了單點故障問題。

  • 避免死鎖:可以直接設置超時時間來控制鎖的釋放,因爲這些緩存服務器一般支持自動刪除過期數據。
    這個方案的不足是:

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

1.4.3 基於 ZooKeeper 實現分佈式鎖

ZooKeeper 基於樹形數據存儲結構實現分佈式鎖,來解決多個進程同時訪問同一臨界資源時,數據的一致性問題。ZooKeeper 的樹形數據存儲結構主要由 4 種節點構成:

  • 持久節點:這是默認的節點類型,一直存在於ZooKeeper 中。
  • 持久順序節點:也就是說,在創建節點時,ZooKeeper根據節點創建的時間順序對節點進行編號。
  • 臨時節點:與持久節點不同,當客戶端與 ZooKeeper斷開連接後,該進程創建的臨時節點就會被刪除。
  • 臨時順序節點:就是按時間順序編號的臨時節點。
    根據它們的特徵,ZooKeeper基於臨時順序節點實現了分佈鎖
    實現過程舉例:
    還是以電商售賣手機的場景爲例:假設用戶 A、B、C 同時在 11 月 11 日的零點整提交了購買手機的請求,ZooKeeper 會採用如下方法來實現分佈式鎖
  1. 在與該方法對應的持久節點 shared_lock 的目錄下,爲每個進程創建一個臨時順序節點。如下圖所示,手機就是一個擁有 shared_lock 的目錄,當有人買手機時,會爲他創建一個臨時順序節點。
  2. 每個進程獲取 shared_lock 目錄下的所有臨時節點列表,註冊子節點變更的 Watcher,並監聽節點。
  3. 每個節點確定自己的編號是否是 shared_lock 下所有子節點中最小的,若最小,則獲得鎖。例如,用戶 A 的訂單最先到服務器,因此創建了編號爲 1 的臨時順序節點 LockNode1。該節點的編號是持久節點目錄下最小的,因此獲取到分佈式鎖,可以訪問臨界資源,從而可以購買手機。
  4. 若本進程對應的臨時節點編號不是最小的,則分爲兩種情況:a. 本進程爲讀請求,如果比自己序號小的節點中有寫請求,則等待;b. 本進程爲寫請求,如果比自己序號小的節點中有讀請求,則等待。
    例如,用戶 B 也想要買手機,但在他之前,用戶 C 想看看手機的庫存量。因此,用戶 B 只能等用戶 A 買完手機、用戶 C 查詢完庫存量後,才能購買手機。
    在這裏插入圖片描述
    小結:
    使用 ZooKeeper 可以完美解決設計分佈式鎖時遇到的各種問題,比如單點故障、不可重入、死鎖等問題。雖然 ZooKeeper 實現的分佈式鎖,幾乎能涵蓋所有分佈式鎖的特性,且易於實現,但需要頻繁地添加和刪除節點,所以性能不如基於緩存實現的分佈式鎖。

1.4.4 三種分佈式鎖實現方式對比

在這裏插入圖片描述
總結來說,ZooKeeper 分佈式鎖的可靠性最高,有封裝好的框架,很容易實現分佈式鎖的功能,並且幾乎解決了數據庫鎖和緩存式鎖的不足,因此是實現分佈式鎖的首選方法。

3、總結

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章