1. 程式人生 > 實用技巧 >分散式鎖的常見實現思路

分散式鎖的常見實現思路

分散式鎖的常見實現思路

一. 概述

1.1 引言

當前參與的專案中會遇到一些執行緒安全問題,由於業務是多節點部署的,Java的單機的併發同步手段synchronizedjava.util.concurrent包已經不太夠用了,這個時候我們需要分散式鎖來保證執行緒安全問題,所以這裡學習總結了幾種分散式鎖的實現思路。

分散式的CAP理論告訴我們任何一個分散式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分割槽容錯性(Partition tolerance),最多隻能同時滿足兩項。 一般情況下,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證最終一致性

,只要這個最終時間是在使用者可以接受的範圍內即可。在很多時候,為了保證資料的最終一致性,需要很多的技術方案來支援,比如分散式事務、分散式鎖等。這裡我們主要介紹物件分散式鎖,分散式鎖的的具體實現方案主要如下三種:

  • 基於資料庫的實現
  • 基於快取(redis)的實現
  • 基於zookeeper的實現

1.2 分散式鎖的要求

一個可靠的、高可用的分散式鎖需要滿足以下幾點

  • 互斥性:任意時刻只能有一個客戶端擁有鎖,不能被多個客戶端獲取
  • 安全性:鎖只能被持有該鎖的客戶端刪除,不能被其它客戶端刪除
  • 死鎖避免:獲取鎖的客戶端因為某些原因而宕機,而未能釋放鎖,其它客戶端也就無法獲取該鎖,需要有機制來避免該類問題的發生
  • 高可用:當部分節點宕機,客戶端仍能獲取鎖或者釋放鎖

二. 基於資料庫的實現

2.1 基於資料庫實現的樂觀鎖

樂觀鎖的通常是基於資料版本號來實現的。比如,有個商品表t_goods,有一個欄位left_count用來記錄商品的庫存個數。在併發的情況下,為了保證不出現超賣現象,即left_count不為負數。樂觀鎖的實現方式為給商品表增加一個版本號欄位version,預設為0,每修改一次資料,將版本號加1。

無版本號併發超賣示例:

-- 執行緒1查詢,當前left_count為1,則有記錄
select * from t_goods where id = 10001 and left_count > 0
-- 執行緒2查詢,當前left_count為1,也有記錄
select * from t_goods  where id = 10001 and left_count > 0
-- 執行緒1下單成功庫存減一,修改left_count為0,
update t_goods set left_count = left_count - 1 where id = 10001
-- 執行緒2下單成功庫存減一,修改left_count為-1,產生髒資料
update t_goods set left_count = left_count - 1 where id = 10001

有版本號的樂觀鎖示例:

-- 執行緒1查詢,當前left_count為1,則有記錄,當前版本號為999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 執行緒2查詢,當前left_count為1,也有記錄,當前版本號為999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 執行緒1,更新完成後當前的version為1000,update狀態為1,更新成功
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
-- 執行緒2,更新由於當前的version為1000,udpate狀態為0,更新失敗,再針對相關業務做異常處理
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;

可以發現,這種和CAS的樂觀鎖機制是類似的,所不同的是CAS的硬體來保證原子性,而這裡是通過資料庫來保證單條SQL語句的原子性。順帶一提CAS的ABA問題一般也是通過版本號來解決。

2.2 基於資料庫實現的排他鎖

基於資料庫的排他鎖需要通過資料庫的唯一性約束UNIQUE KEY來保證資料的唯一性,從而為鎖的獨佔性提供基礎。

表結構如下:

CREATE TABLE `distribute_lock` (
   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
   `unique_mutex` varchar(64) NOT NULL COMMENT '需要鎖住的資源或者方法',
   -- `state` tinyint NOT NULL DEFAULT 1 COMMENT '1:未分配;2:已分配
   PRIMARY KEY (`id`),
   UNIQUE KEY `unique_mutex`
);

其中,unique_mutex就是我們需要加鎖的物件,需要用UNIQUE KEY來保證此物件唯一。

加鎖時增加一條記錄:

insert into distribute_lock(unique_mutex) values('mutex_demo'); 

如果當前SQL執行成功代表加鎖成功,如果丟擲唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,當前鎖已經被其他競爭者獲取。

解鎖鎖時刪除該記錄:

delete from distribute_lock(unique_mutex) values('muetx_demo');

除了增刪記錄,也可以通過更新state欄位來標識是否獲取到鎖。

-- 獲取鎖
update distribute_lock set state = 2 where `unique_mutex` = 'muetx_demo' and state=1;

更新之前需要SELECT確認鎖在資料庫中存在,沒有則建立之。如果建立或更新失敗,則說明這個資源已經被別的執行緒佔用了。

2.3 小結

資料庫排他鎖可能出現的問題及解決思路:

  1. 沒有失效時間, 一旦解鎖失敗,會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得鎖。
    • 可通過定時任務清除超時資料來解決
  2. 是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。
    • 可通過增加欄位記錄當前主機資訊和當執行緒資訊,
  3. 這個鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線上程並不會進入阻塞佇列,需要不停自旋直到獲得鎖,相對耗資源。

總的來說,基於資料庫的分散式鎖,能夠滿足一些簡單的需求,好處是能夠少引入依賴,實現較為簡單,缺點是效能較低,且難以滿足複雜場景下的高併發需求。

三. 基於redis的實現

3.1 基本實現思路

一個簡單的分散式鎖機制是使用setnxexpiredel 三個命令的組合來實現的。setnx命令的含義為:當且僅當key不存在時,value設定成功,返回1;否則返回0。另外兩個命令,見名知意,就不多做解釋了。

# 加鎖,設定鎖的唯一標識key,返回1說明加鎖成功,返回0加鎖失敗
setnx key value
# 設定鎖超時時間為30s,防止死鎖
expire key 30
# 解鎖, 刪除鎖
del key

這種思路存在的問題:

  1. setnxexpire的非原子性:如果加鎖之後,伺服器宕機,導致expiredel均執行不了,會導致死鎖。
  2. del導致誤刪:A執行緒超時之後未執行完, 鎖過期釋放;B執行緒獲得鎖,此時A執行緒執行完,執行del將B執行緒的鎖刪除。
  3. 鎖過期後引起的併發:A執行緒超時之後未執行完, 鎖過期釋放;B執行緒獲得鎖,此時A、B執行緒併發執行會導致執行緒安全問題。

對應的解決思路:

  1. 將加鎖和設定鎖過期時間做成一個原子性操作
    • Redis 2.6.12版本之後,set命令增加了NX可選引數,可替代setnx命令;增加了EX可選引數,可以設定key的同時指定過期時間
    • 或者將兩個操作封裝在lua指令碼中,傳送給Redis執行,從而實現操作的原子性。
  2. 將key的value設定為執行緒相關資訊,del釋放鎖之前先判斷一下鎖是不是自己的。(釋放和判斷不是原子性的,需要封裝在lua指令碼中)
  3. 啟動一個守護執行緒,在後臺自動給自己的鎖''續期“,執行完成,顯式關掉守護程序

3.2 redis分散式鎖的缺點

在大型的應用中,一般redis服務都是叢集形式部署的,由於Slave同步Master是非同步的,所以會出現客戶端A在Master上加鎖,此時Master宕機,Slave沒有完成鎖的同步,Slave變為Master,客戶端B此時可以完成加鎖操作。

為了解決這一問題,官方給出了redlock演算法,即使這樣在一些較複雜的場景下也不能100%保證沒有問題。較複雜,留待後續研究。

四. 基於zookeeper的實現

4.1 基本實現思路

zookeeper 是一個開源的分散式協調服務框架,主要用來解決分散式叢集中的一致性問題和資料管理問題。zookeeper本質上是一個分散式檔案系統,由一群樹狀節點組成,每個節點可以存放少量資料,且具有唯一性。

zookeeper有四種類型的節點:

  • 持久節點(PERSISTENT)
    • 預設節點型別,斷開連線仍然存在
  • 持久順序節點(PERSISTENT_SEQUENTIAL)
    • 在持久節點的基礎上,增加了順序性。指定建立同名節點,會根據建立順序在指定的節點名稱後面帶上順序編號,以保證節點具有唯一性和順序性
  • 臨時節點(EPHEMERAL)
    • 斷開連線後,節點會被刪除
  • 臨時順序節點(EPHEMERAL_SEQUENTIAL)
    • 在臨時節點的基礎上,增加了順序性。

基於zookeeper實現的分散式鎖主要利用了zookeeper臨時順序節點的特性和事件監聽機制。主要思路如下:

  1. 建立節點實現加鎖,通過節點的唯一性,來實現鎖的互斥
    • 如果使用臨時節點,節點建立成功表示獲取到鎖
    • 如果使用臨時順序節點,客戶端建立的節點為順序最小節點,表示獲取到鎖
  2. 刪除節點實現解鎖
  3. 通過臨時節點的斷開連線自動刪除的特性來避免持有鎖的伺服器宕機而導致的死鎖
  4. 通過節點的順序性和事件監聽機制,大節點監聽小節點,形成節點監聽鏈,來實現等待佇列(公平鎖)

其他思路:

  • 不使用監聽機制,未獲取到鎖的執行緒自旋重試或者失敗退出(根據業務決定),可實現非阻塞的樂觀鎖。
  • 不使用臨時順序節點,而使用臨時節點,所有客戶端都去監聽該臨時節點,可實現非公平鎖。但是會產生"羊群效應",單個事件,引發多個伺服器響應,佔用伺服器資源和網路頻寬,需要根據業務場景選用。

4.2 zookeeper分散式鎖的缺點

zookeeper分散式鎖有著較好的可靠性,但是也有如下缺點:

  • zookeeper分散式鎖是效能可能沒有redis分散式鎖高,因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬臨時節點來實現鎖功能。
  • 使用zookeeper也有可能帶來併發問題,只是並不常見而已。比如,由於網路抖動,客戶端與zk叢集的session連線斷了,那麼zk以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分散式鎖了。就可能產生併發問題。這個問題不常見是因為zk有重試機制,一旦zk叢集檢測不到客戶端的心跳,就會重試,curator客戶端支援多種重試策略。多次重試之後還不行的話才會刪除臨時節點。

五 總結

上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、效能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道。

  1. 從實現的複雜性角度(從高到低)zookeeper >= redis> 資料庫
    • 資料庫實現的分散式鎖易於理解和實現,且不會給專案引入其他依賴。zookeeperredis需要考慮的情況更多,實現相對較為複雜,但是都有現成的分散式鎖框架curatorredision,用起來程式碼反而可能會更簡潔。
  2. 從效能角度(從高到低)redis > zookeeper > 資料庫
    • redis資料存在記憶體,速度很快;zookeeper雖然資料也存在記憶體中,但是本身維護節點的一致性。需要耗費一些效能;資料庫則只有索引在記憶體中,資料存於磁碟,效能較差。
  3. 從可靠性角度(從高到低)zookeeper > redis > 資料庫
    • zookeeper天生設計定位就是分散式協調,強一致性,可靠性較高;redis分散式鎖需要較多額外手段去保證可靠性;資料庫則較難滿足複雜場景的需求。