分佈式鎖

分佈式鎖

本文整理自黑馬程序員相關資料

問題的引入

在平時單服務的情況下,我們使用互斥鎖可以保證同一時刻只有一個線程執行自己的業務。原理是,在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),重置超時時間。

Tags: