分散式鎖

2022-06-15 21:00:47

分散式鎖

本文整理自黑馬程式設計師相關資料

問題的引入

在平時單服務的情況下,我們使用互斥鎖可以保證同一時刻只有一個執行緒執行自己的業務。原理是,在JVM內部維護了一個鎖監視器,鎖監視器保證了同一時刻只有一個執行緒獲取到鎖。但是如果開啟了多個服務,就會有多個JVM,從而有多個不同的鎖監視器,每個鎖監視器監視自己JVM內部的執行緒,因此一個JVM內部的執行緒獲取到鎖,並不影響其他JVM內部的執行緒獲取鎖。從而導致並行安全問題。因此,我們需要獨立於JVM之外的鎖監視器對所有的執行緒統一管理。

概念

滿足分散式系統或叢集模式下多程序可見並且互斥的鎖。

常見分散式鎖的實現比較

MySQL Redis Zookeeper
互斥 利用Mysql本身的互斥鎖機制 利用setnx這樣的互斥命令 利用節點的唯一性和有序性實現互斥
高可用
高效能 一般 一般
安全性 斷開連線,自動釋放鎖 利用鎖超時時間,到期釋放 臨時節點,斷開連線自動釋放

基於Redis的分散式鎖

最基本的分散式鎖

獲取鎖:

利用Redis的SETNX保證互斥的特性,同時設定鎖過期時間,避免服務宕機不能執行釋放鎖的操作而導致死鎖。

釋放鎖:

刪除對應的鍵即可

流程圖如下所示:

保證釋放鎖的執行緒是持有鎖的執行緒本身

前面提到的最基本的分散式鎖存在著一些問題。如果獲取鎖的執行緒1阻塞,在該執行緒阻塞期間,鎖超時釋放了,這時執行緒2就可以獲取到鎖,接著執行自己的業務。執行緒1在完成自己的業務後釋放鎖。這時執行緒3也獲得了鎖執行自己的業務,這樣就造成了執行緒2和執行緒3都獲取到了鎖,從而造成了執行緒安全問題。如下圖所示

為了解決未持有鎖的執行緒釋放鎖這個問題,在鎖中存入執行緒標識,在釋放鎖之前先判斷鎖標識是否是本身執行緒。如果標識是自己,則釋放鎖。其流程圖如下所示

保證釋放鎖的原子性

由於前面加入了判斷,判斷與釋放是兩步。有可能在判斷時持有鎖的執行緒1阻塞,直到超時釋放鎖,執行緒2拿到了鎖,執行緒1被喚醒並執行釋放鎖,導致執行緒3也拿到了鎖。造成了兩個執行緒同時持有鎖的執行緒安全問題。如下所示

為了解決這個問題,使用Lua指令碼,在一個指令碼中編寫多條Redis命令,確保多條命令執行時的原子性。

釋放鎖的業務流程如下所示

-- 這裡的 KEYS[1] 就是鎖的key,這裡的ARGV[1] 就是當前執行緒標示
-- 獲取鎖中的標示,判斷是否與當前執行緒標示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,則刪除鎖
  return redis.call('DEL', KEYS[1])
end
-- 不一致,則直接返回
return 0

到目前為止,一個基於Redis的基本的分散式鎖就完成了。但還是存在著以下問題

  • 不可重入:同一線城無法多次獲取統一把鎖
  • 不可重試:獲取鎖只嘗試一次就返回,沒有重試機制
  • 超時釋放問題:鎖超時釋放雖然可以避免死鎖,但是如果業務執行耗時較長,也會導致鎖釋放,存在安全隱患
  • 主從一致性問題:如果Redis提供了主從叢集,主從同步存在延遲,當主節點宕機時,從節點沒有同步主節點中的鎖資料。其他執行緒就會拿到鎖

Redisson分散式鎖簡單介紹

Redisson可重入鎖原理

獲取鎖的Lua指令碼

local key = KEYS[1]; -- 鎖的key
local threadId = ARGV[1]; -- 執行緒唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間
-- 判斷是否存在
if(redis.call('exists', key) == 0) then
    -- 不存在, 獲取鎖
    redis.call('hset', key, threadId, '1'); 
    -- 設定有效期
    redis.call('expire', key, releaseTime); 
    return 1; -- 返回結果
end;
-- 鎖已經存在,判斷threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
    -- 不存在, 獲取鎖,重入次數+1
    redis.call('hincrby', key, threadId, '1'); 
    -- 設定有效期
    redis.call('expire', key, releaseTime);   
    return 1; -- 返回結果
end;
return 0; -- 程式碼走到這裡,說明獲取鎖的不是自己,獲取鎖失敗

釋放鎖的Lua指令碼

local key = KEYS[1]; -- 鎖的key
local threadId = ARGV[1]; -- 執行緒唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間
-- 判斷當前鎖是否還是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
    return nil; -- 如果已經不是自己,則直接返回
end;
-- 是自己的鎖,則重入次數-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判斷是否重入次數是否已經為0 
if (count > 0) then
    -- 大於0說明不能釋放鎖,重置有效期然後返回
    redis.call('EXPIRE', key, releaseTime);
    return nil;
else  -- 等於0說明可以釋放鎖,直接刪除
    redis.call('DEL', key);
    return nil;
end;

Redisson分散式鎖原理

  • 可重入:利用hash結構記錄執行緒id和重入次數
  • 可重試:利用號誌和PubSub功能實現等待、喚醒,獲取鎖失敗的重試機制
  • 超時續約:利用watchDog,每隔一段時間(releaseTime/3),重置超時時間。