分佈式計算原理之分佈式協調與同步(1)——分佈式鎖

1:什麼是分佈式鎖

在分佈式互斥中,分佈式互斥博客地址,講述了同一時刻,同一個臨界資源只能同一個進程訪問,爲了維護數據的一致性,防止分佈式系統中的多個進程之間相互干擾,我們需要一種分佈式協調技術(某種機制)來對這些進程進行調度,來保證只有滿足某個條件的線程才能訪問資源,不滿足條件的線程只能等待,在下一輪競爭中重新滿足條件時才能訪問資源,這個分佈式協調技術的核心就是來實現這個分佈式鎖。

這部分的分佈式協調技術(機制),指的是爲了實現分佈式互斥,在某個地方做個標記,每個線程都可以看到這個標記,當標記不存在時,可以設置該標記,當標記被設置後,其他線程只能等待擁有該標記的線程執行完成,並釋放該標記後,才能去設置該標記和訪問臨界資源。這裏的標記,就是鎖

也就是說,鎖是實現多線程同時訪問同一臨界資源,保證同一時刻只有一個線程可訪問臨界資源所做的一種標記。與普通鎖不同的是,分佈式鎖是指分佈式環境下,系統部署在多個機器中,實現多進程分佈式互斥的一種鎖。

爲了保證多個進程能看到鎖,鎖被存在公共存儲(比如 Redis、Memcache、數據庫等三方存儲中),以實現多個進程併發訪問同一個臨界資源,同一時刻只有一個進程可訪問共享資源,確保數據的一致性。

2:使用分佈式鎖的場景

以電商爲例,如果某家商戶要售賣吹風機,庫存只有 2 個,但有 5 個來自不同地區的用戶{A,B,C,D,E}幾乎同時下單,那麼這 2 個吹風機到底究竟會被誰買到?在實際業務中,爲了高併發地接收大量用戶訂單請求,電商網站往往採取不同的策略,比如有些電商根據下單時間判斷誰可以購買成功,而有些電商則是根據付款時間來判斷。

但無論採用什麼樣的規則去判斷誰能購買成功,都必須要保證吹風機售出時,數據庫中更新的庫存是正確的。爲了便於理解,以下單時間作爲購買成功的判斷依據。我們能想到的最簡單方案就是,給吹風機的庫存數加一個鎖。當有一個用戶提交訂單後,後臺服務器給庫存數加一個鎖,根據該用戶的訂單修改庫存。而其他用戶必須等到鎖釋放以後,才能重新獲取庫存數,繼續購買。

在這裏,吹風機的庫存就是臨界資源,不同的購買者對應着多個進程,後臺服務器對臨界資源加的鎖就是告訴其他進程勿入。

但這裏存在着嚴重的問題:用戶 A 想買 1 個吹風機,用戶 B 想買 2 個吹風機。在理想狀態下,用戶 A 網速好先買走了 1 個,庫存還剩下 1 個,此時應該提示用戶 B 庫存不足,用戶 B 購買失敗。但實際情況是,用戶 A 和用戶 B 同時獲取到商品庫存還剩 2 個,用戶 A 買走 1 個,在用戶 A 更新庫存之前,用戶 B 又買走了 2 個,此時用戶 B 更新庫存,商品還剩 0 個。這時總共 2 個吹風機,卻賣出去了 3 個。

因此,在高併發場景下,如果只使用單機鎖將會出現不可預知的後果,爲了保證臨界資源同一時間只能被一個進程使用,從而確保數據的一致性,我們就需要引入分佈式鎖了。此外,在大規模分佈式系統中,單個機器的線程鎖無法管控多個機器對同一資源的訪問,這時使用分佈式鎖,就可以把整個集羣當作一個應用一樣去處理,實用性和擴展性更好。

3: 分佈式鎖的三種實現方法及對比

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

 

3.1: 基於數據庫實現分佈式鎖

要實現分佈式鎖,最直接的方式就是創建一張鎖表,然後通過操作該表中的數據來實現。

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

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

還是以電商賣吹風機的場景爲例。吹風機庫存是 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. 單點故障問題。一旦數據庫不可用,會導致整個系統崩潰。
  2. 死鎖問題。數據庫鎖沒有失效時間,未獲得鎖的進程只能一直等待已獲得鎖的進程主動釋放鎖。一旦已獲得鎖的進程掛掉或者解鎖操作失敗,會導致鎖記錄一直存在數據庫中,其他進程無法獲得鎖。

3.2: 基於緩存實現分佈式鎖

數據庫的性能限制了業務的併發量,對於高併發場景下,可以採用基於緩存實現分佈式鎖。

所謂基於緩存,也就是說把數據存放在計算機內存中,不需要寫入磁盤,減少了 IO 讀寫,提高性能。

Redis 通常可以使用 setnx(key, value) 函數來實現分佈式鎖。key 和 value 就是基於緩存的分佈式鎖的兩個屬性,其中 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 操作;
  • 很多緩存可以跨集羣部署,避免了單點故障問題;
  • 很多緩存服務都提供了可以用來實現分佈式鎖的方法,比如 Redis 的 setnx 方法等;
  • 可以直接設置超時時間來控制鎖的釋放,因爲這些緩存服務器一般支持自動刪除過期數據

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

 

3.3:基於ZooKeeper 實現分佈式鎖

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

  • 持久節點。這是默認的節點類型,一直存在於 ZooKeeper 中;
  • 持久順序節點。也就是說,在創建節點時,ZooKeeper 根據節點創建的時間順序對節點進行編號;
  • 臨時節點。與持久節點不同,當客戶端與 ZooKeeper 斷開連接後,該進程創建的臨時節點就會被刪除;
  • 臨時順序節點,就是按時間順序編號的臨時節點

 

根據它們的特徵,ZooKeeper 基於臨時順序節點實現了分佈鎖:

還是以電商售賣吹風機的場景爲例。假設用戶 A、B、C 同時在 雙11零點整提交了購買吹風機的請求,ZooKeeper 會採用如下方法來實現分佈式鎖:

  1. 在與該方法對應的持久節點 shared_lock 的目錄下,爲每個進程創建一個臨時順序節點。如下圖所示,吹風機就是一個擁有 shared_lock 的目錄,當有人買吹風機時,會爲他創建一個臨時順序節點。
  2. 每個進程獲取 shared_lock 目錄下的所有臨時節點列表,註冊子節點變更的 Watcher,並監聽節點。
  3. 每個節點確定自己的編號是否是 shared_lock 下所有子節點中最小的,若最小,則獲得鎖。例如,用戶 A 的訂單最先到服務器,因此創建了編號爲 1 的臨時順序節點 LockNode1。該節點的編號是持久節點目錄下最小的,因此獲取到分佈式鎖,可以訪問臨界資源,從而可以購買吹風機
  4. 。若本進程對應的臨時節點編號不是最小的,則分爲兩種情況:
    1. a.  本進程爲讀請求,如果比自己序號小的節點中有寫請求,則等待;
    2. b.  本進程爲寫請求,如果比自己序號小的節點中有讀請求,則等待。

例如,用戶 B 也想要買吹風機,但在他之前,用戶 C 想看看吹風機的庫存量。因此,用戶 B 只能等用戶 A 買完吹風機、用戶 C 查詢完庫存量後,才能購買吹風機。

 

3.4:三種實現方式對比

 

這裏的實現複雜性,是針對同樣的分佈式鎖的實現複雜性,與之前提到的基於數據庫的實現非常簡易不一樣。基於數據庫實現的分佈式鎖存在單點故障和死鎖問題,僅僅利用數據庫技術去解決單點故障和死鎖問題,是非常複雜的。而 ZooKeeper 已定義相關的功能組件,因此可以很輕易地解決設計分佈式鎖時遇到的各種問題。所以說,要實現一個完整的、無任何缺陷的分佈式鎖,ZooKeeper 是一個最簡單的選擇。

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

 

從上述分析可以看出,爲了確保分佈式鎖的可用性,我們在設計時應考慮到以下幾點:

  1. 互斥性,即在分佈式系統環境下,分佈式鎖應該能保證一個資源或一個方法在同一時間只能被一個機器的一個線程或進程操作。
  2. 具備鎖失效機制,防止死鎖。即使有一個進程在持有鎖的期間因爲崩潰而沒有主動解鎖,也能保證後續其他進程可以獲得鎖。
  3. 可重入性,即進程未釋放鎖時,可以多次訪問臨界資源。
  4. 有高可用的獲取鎖和釋放鎖的功能,且性能要好。

 

4:問題延伸

4.1:何解決分佈式鎖的羊羣效應問題?

在分佈式鎖問題中,會經常遇到羊羣效應。羊羣效應指的是在整個分佈式鎖的競爭過程中,大量的“Watcher 通知”和“子節點列表的獲取”操作重複運行,並且大多數節點的運行結果都是判斷出自己當前並不是編號最小的節點,繼續等待下一次通知,而不是執行業務邏輯。這就會對 ZooKeeper 服務器造成巨大的性能影響和網絡衝擊。更極端的是,如果同一時間多個節點對應的客戶端完成事務或事務中斷引起節點消失,ZooKeeper 服務器就會在短時間內向其他客戶端發送大量的事件通知。

那如何解決這個問題呢?具體方法可以分爲以下三步:

  1. 在與該方法對應的持久節點的目錄下,爲每個進程創建一個臨時順序節點;
  2. 每個進程獲取所有臨時節點列表,對比自己的編號是否最小,若最小,則獲得鎖;
  3. 若本進程對應的臨時節點編號不是最小的,則繼續判斷:
    1. 若本進程爲讀請求,則向比自己序號小的最後一個寫請求節點註冊 watch 監聽,當監聽到該節點釋放鎖後,則獲取鎖;
    2. 若本進程爲寫請求,則向比自己序號小的最後一個請求節點註冊 watch 監聽,當監聽到該節點釋放鎖後,獲取鎖

 

5:內容總結

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