分散式鎖及其實現

御狐神發表於2021-12-01

對於Java中的鎖大家肯定都很熟悉,在Java中synchronized關鍵字和ReentrantLock可重入鎖在我們的程式碼中是經常見的,一般我們用其在多執行緒環境中控制對資源的併發訪問,但是隨著分散式的快速發展,本地的加鎖往往不能滿足我們的需要,在我們的分散式環境中上面加鎖的方法就會失去作用。為了在分散式環境中也能實現本地鎖的效果,人們提出了分散式鎖的概念。

分散式鎖

分散式鎖場景

一般需要使用分散式鎖的場景如下:

  • 效率:使用分散式鎖可以避免不同節點重複相同的工作,比如避免重複執行定時任務等;
  • 正確性:使用分散式鎖同樣可以避免破壞資料正確性,如果兩個節點在同一條資料上面操作,可能會出現併發問題。

分散式鎖特點

一個完善的分散式鎖需要滿足以下特點:

  • 互斥性:互斥是所得基本特性,分散式鎖需要按需求保證執行緒或節點級別的互斥。;
  • 可重入性:同一個節點或同一個執行緒獲取鎖,可以再次重入獲取這個鎖;
  • 鎖超時:支援鎖超時釋放,防止某個節點不可用後,持有的鎖無法釋放;
  • 高效性:加鎖和解鎖的效率高,可以支援高併發;
  • 高可用:需要有高可用機制預防鎖服務不可用的情況,如增加降級;
  • 阻塞性:支援阻塞獲取鎖和非阻塞獲取鎖兩種方式;
  • 公平性:支援公平鎖和非公平鎖兩種型別的鎖,公平鎖可以保證安裝請求鎖的順序獲取鎖,而非公平鎖不可以。

分散式鎖的實現

分散式鎖常見的實現有三種實現,下文我們會一一介紹這三種鎖的實現方式:

  • 基於資料庫的分散式鎖;
  • 基於Redis的分散式鎖;
  • 基於Zookeeper的分散式鎖。

基於資料庫的分散式鎖

基於資料庫的分散式鎖可以有不同的實現方式,本文會介紹作者在實際生產中使用的一種資料庫非阻塞分散式鎖的實現方案。

方案概覽

我們上面列舉出了分散式鎖需要滿足的特點,使用資料庫實現分散式鎖也需要滿足這些特點,下面我們來一一介紹實現方法:

  • 互斥性:通過資料庫update的原子性達到兩次獲取鎖之間的互斥性;
  • 可重入性:在資料庫中保留一個欄位儲存當前鎖的持有者;
  • 鎖超時:在資料庫中儲存鎖的獲取時間點和超時時長;
  • 高效性:資料庫本身可以支援比較高的併發;
  • 高可用:可以增加主從資料庫邏輯,提升資料庫的可用性;
  • 阻塞性:可以通過看門狗輪詢的方式實現執行緒的阻塞;
  • 公平性:可以新增鎖佇列,不過不建議,實現起來比較複雜。

表結構設計

資料庫的表名為lock,各個欄位的定義如下所示:

欄位名名稱 欄位型別 說明
lock_key varchar 鎖的唯一識別符號號
lock_time timestample 加鎖的時間
lock_duration integer 鎖的超時時長,單位可以業務自定義,通常為秒
lock_owner varchar 鎖的持有者,可以是節點或執行緒的唯一標識,不同可重入粒度的鎖有不同的含義
locked boolean 當前鎖是否被佔有

獲取鎖的SQL語句

獲取鎖的SQL語句分不同的情況,如果鎖不存在,那麼首先需要建立鎖,並且建立鎖的執行緒可以獲取鎖:

insert into lock(lock_key,lock_time,lock_duration,lock_owner,locked) values ('xxx',now(),1000,'ownerxxx',true)

如果鎖已經存在,那麼就嘗試更新鎖的資訊,如果更新成功則表示獲取鎖成功,更新失敗則表示獲取鎖失敗。

update lock set 
    locked = true, 
    lock_owner = 'ownerxxxx', 
    lock_time = now(), 
    lock_duration = 1000
where
    lock_key='xxx' and(
    lock_owner = 'ownerxxxx' or
    locked = false or
    date_add(lock_time, interval lock_duration second) > now())

釋放鎖的SQL語句

當使用者使用完鎖需要釋放的時候,可以直接更新locked標識位為false。

update lock set 
    locked = false, 
where
    lock_key='xxx' and
    lock_owner = 'ownerxxxx' and
    locked = true

看門狗

通過上面的步驟,我們可以實現獲取鎖和釋放鎖,那麼看門狗又是做什麼的呢?

大家想象一下,如果使用者獲取鎖到釋放鎖之間的時間大於鎖的超時時間,是不是會有問題?是不是可能會出現多個節點同時獲取鎖的情況?這個時候就需要看門狗了,看門狗可以通過定時任務不斷重新整理鎖的獲取事件,從而在使用者獲取鎖到釋放鎖期間保持一直持有鎖。

基於Redis的分散式鎖

Redis的Java客戶端Redisson實現了分散式鎖,我們可以通過類似ReentrantLock的加鎖-釋放鎖的邏輯來實現分散式鎖。

RLock disLock = redissonClient.getLock("DISLOCK");
disLock.lock();
try {
    // 業務邏輯
} finally {
    // 無論如何, 最後都要解鎖
    disLock.unlock();
}

Redisson分散式鎖的底層原理

如下圖為Redisson客戶端加鎖和釋放鎖的邏輯:

Redisson客戶端加鎖和釋放鎖

加鎖機制

從上圖中可以看出來,Redisson客戶端需要獲取鎖的時候,要傳送一段Lua指令碼到Redis叢集執行,為什麼要用Lua指令碼呢?因為一段複雜的業務邏輯,可以通過封裝在Lua指令碼中傳送給Redis,保證這段複雜業務邏輯執行的原子性。

Lua原始碼分析:如下為Redisson加鎖的lua原始碼,接下來我們會對原始碼進行分析。

原始碼入參:Lua指令碼有三個輸入引數:KEYS[1]、ARGV[1]和ARGV[2],含義如下:

  • KEYS[1]代表的是加鎖的Key,例如RLock lock = redisson.getLock("myLock")中的“myLock”;
  • ARGV[1]代表的就是鎖Key的預設生存時間,預設30秒;
  • ARGV[2]代表的是加鎖的客戶端的ID,類似於下面這樣的:8743c9c0-0795-4907-87fd-6c719a6b4586:1。

Lua指令碼及加鎖步驟如下程式碼塊所示,可以看出其大致原理為:

  • 鎖不存在的時候,建立鎖並設定過期時間;
  • 鎖存在的時候,如果是重入場景則重新整理鎖的過期事件;
  • 否則返回加鎖失敗和鎖的過期時間。
-- 判斷鎖是不是存在
if (redis.call('exists', KEYS[1]) == 0) then 
    -- 新增鎖,並且設定客戶端和初始鎖重入次數
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    -- 設定鎖的超時事件 
    redis.call('pexpire', KEYS[1], ARGV[1]);  
    -- 返回加鎖成功
    return nil;  
end;  
-- 判斷當前鎖的持有者是不是請求鎖的請求者
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then  
    -- 當前鎖被請求者持有,重入鎖,增加鎖重入次數
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  
    -- 重新整理鎖的過期時間
    redis.call('pexpire', KEYS[1], ARGV[1]);  
    -- 返回加鎖成功
    return nil;  
end;  
-- 返回當前鎖的過期時間
return redis.call('pttl', KEYS[1]);

看門狗邏輯

客戶端1加鎖的鎖Key預設生存時間才30秒,如果超過了30秒,客戶端1還想一直持有這把鎖,怎麼辦呢?只要客戶端1加鎖成功,就會啟動一個watchdog看門狗,這個後臺執行緒,會每隔10秒檢查一下,如果客戶端1還持有鎖Key,就會不斷的延長鎖Key的生存時間。

釋放鎖機制

如果執行lock.unlock(),就可以釋放分散式鎖,此時的業務邏輯也是非常簡單的。就是每次都對myLock資料結構中的那個加鎖次數減1。

如果發現加鎖次數是0了,說明這個客戶端已經不再持有鎖了,此時就會用:“del myLock”命令,從Redis裡刪除這個Key。

而另外的客戶端2就可以嘗試完成加鎖了。這就是所謂的分散式鎖的開源Redisson框架的實現機制。

一般我們在生產系統中,可以用Redisson框架提供的這個類庫來基於Redis進行分散式鎖的加鎖與釋放鎖。

Redisson分散式鎖的缺陷

Redis分散式鎖會有個缺陷,就是在Redis哨兵模式下:

  1. 客戶端1對某個master節點寫入了redisson鎖,此時會非同步複製給對應的slave節點。但是這個過程中一旦發生master節點當機,主備切換,slave節點從變為了master節點。
  2. 客戶端2來嘗試加鎖的時候,在新的master節點上也能加鎖,此時就會導致多個客戶端對同一個分散式鎖完成了加鎖。
  3. 系統在業務語義上一定會出現問題,導致各種髒資料的產生。

這個缺陷導致在哨兵模式或者主從模式下,如果master例項當機的時候,可能導致多個客戶端同時完成加鎖。

基於Zookeeper的分散式鎖

Zookeeper實現的分散式鎖適用於引入Zookeeper的服務,如下所示,有兩個服務註冊到Zookeeper,並且都需要獲取Zookeeper上的分散式鎖,流程式什麼樣的呢?

Zookeeper的分散式鎖-1

步驟1

假設客戶端A搶先一步,對ZK發起了加分散式鎖的請求,這個加鎖請求是用到了ZK中的一個特殊的概念,叫做“臨時順序節點”。簡單來說,就是直接在"my_lock"這個鎖節點下,建立一個順序節點,這個順序節點有ZK內部自行維護的一個節點序號。

  • 比如第一個客戶端來獲取一個順序節點,ZK內部會生成名稱xxx-000001。
  • 然後第二個客戶端來獲取一個順序節點,ZK內部會生成名稱xxx-000002。

最後一個數字都是依次遞增的,從1開始逐次遞增。ZK會維護這個順序。所以客戶端A先發起請求,就會生成出來一個順序節點,如下所示:

Zookeeper的分散式鎖-2

客戶端A發起了加鎖請求,會先加鎖的node下生成一個臨時順序節點。因為客戶端A是第一個發起請求,所以節點名稱的最後一個數字是"1"。客戶端A建立完好順序節後,會查詢鎖下面所有的節點,按照末尾數字升序排序,判斷當前節點的是不是第一個節點,如果是第一個節點則加鎖成功。

Zookeeper的分散式鎖-3

步驟2

客戶端A都加完鎖了,客戶端B過來想要加鎖了,此時也會在鎖節點下建立一個臨時順序節點,節點名稱的最後一個數字是"2"。

Zookeeper的分散式鎖-4

客戶端B會判斷加鎖邏輯,查詢鎖節點下的所有子節點,按序號順序排列,此時第一個是客戶端A建立的那個順序節點,序號為"01"的那個。所以加鎖失敗。加鎖失敗了以後,客戶端B就會通過ZK的API對他的順序節點的上一個順序節點加一個監聽器。ZK天然就可以實現對某個節點的監聽。

Zookeeper的分散式鎖-5

步驟3

客戶端A加鎖之後,可能處理了一些程式碼邏輯,然後就會釋放鎖。Zookeeper釋放鎖其實就是把客戶端A建立的順序節點zk_random_000001刪除。

Zookeeper的分散式鎖-6

刪除客戶端A的節點之後,Zookeeper會負責通知監聽這個節點的監聽器,也就是客戶端B之前新增監聽器。客戶端B的監聽器知道了上一個順序節點被刪除,也就是排在他之前的某個客戶端釋放了鎖。此時,就會客戶端B會重新嘗試去獲取鎖,也就是獲取鎖節點下的子節點集合,判斷自身是不是第一個節點,從而獲取鎖。

Zookeeper的分散式鎖-7

三種鎖的優缺點

基於資料庫的分散式鎖

  • 資料庫併發效能較差;
  • 阻塞式鎖實現比較複雜;
  • 公平鎖實現比較複雜。

基於Redis的分散式鎖

  • 主從切換的情況下可能出現多客戶端獲取鎖的情況;
  • Lua指令碼在單機上具有原子性,主從同步時不具有原子性。

基於Zookeeper的分散式鎖

  • 需要引入Zookeeper叢集,比較重量級;
  • 分散式鎖的可重入粒度只能是節點級別;

參考文件

分散式鎖

三種分散式鎖對比

分散式鎖的三種實現的對比

我是御狐神,歡迎大家關注我的微信公眾號:wzm2zsd

qrcode_for_gh_83670e17bbd7_344-2021-09-04-10-55-16

本文最先發布至微信公眾號,版權所有,禁止轉載!

相關文章