一文掌握分佈式鎖原理,附詳細方案

本文主要介紹分佈式鎖的概念和分佈式鎖的設計原則,以及常見的分佈式鎖的實現方式。

分佈式鎖的概念和設計原則

什麼是分佈式鎖

要介紹分佈式鎖,首先要提到與分佈式鎖相對應的是線程鎖、進程鎖。

分佈式鎖的設計原則

分佈式鎖的最小設計原則:安全性有效性

Redis 的官網上對使用分佈式鎖提出至少需要滿足如下三個要求:

  1. 互斥(屬於安全性):在任何給定時刻,只有一個客戶端可以持有鎖。

  2. 無死鎖(屬於有效性):即使鎖定資源的客戶端崩潰或被分區,也總是可以獲得鎖;通常通過超時機制實現。

  3. 容錯性(屬於有效性):只要大多數 Redis 節點都啓動,客戶端就可以獲取和釋放鎖。

除此之外,分佈式鎖的設計中還可以 / 需要考慮:

  1. 加鎖解鎖的同源性:A 加的鎖,不能被 B 解鎖

  2. 獲取鎖是非阻塞的:如果獲取不到鎖,不能無限期等待;

  3. 高性能:加鎖解鎖是高性能的

分佈式鎖的實現方案

就體系的角度而言,談談常見的分佈式鎖的實現方案。

基於數據庫實現

基於數據庫如何實現分佈式鎖?有什麼缺陷?

基於數據庫表(鎖表,很少使用)

最簡單的方式可能就是直接創建一張鎖表,然後通過操作該表中的數據來實現了。當我們想要獲得鎖的時候,就可以在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。

爲了更好的演示,我們先創建一張數據庫表,參考如下:

CREATE TABLE database_lock (
 `id` BIGINT NOT NULL AUTO_INCREMENT,
 `resource` int NOT NULL COMMENT '鎖定的資源',
 `description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
 PRIMARY KEY (id),
 UNIQUE KEY uiq_idx_resource (resource)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數據庫分佈式鎖表';

當我們想要獲得鎖時,可以插入一條數據:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

當需要釋放鎖的時,可以刪除這條數據:

DELETE FROM database_lock WHERE resource=1;

基於悲觀鎖

悲觀鎖實現思路?

  1. 在對任意記錄進行修改前,先嚐試爲該記錄加上排他鎖(exclusive locking)。

  2. 如果加鎖失敗,說明該記錄正在被修改,那麼當前查詢可能要等待或者拋出異常。具體響應方式由開發者根據實際需要決定。

  3. 如果成功加鎖,那麼就可以對記錄做修改,事務完成後就會解鎖了。

  4. 其間如果有其他對該記錄做修改或加排他鎖的操作,都會等待我們解鎖或直接拋出異常。

以 MySQL InnoDB 中使用悲觀鎖爲例?

要使用悲觀鎖,我們必須關閉 mysql 數據庫的自動提交屬性,因爲 MySQL 默認使用 autocommit 模式,也就是說,當你執行一個更新操作後,MySQL 會立刻將結果進行提交。set autocommit=0;

//0.開始事務
begin;/begin work;/start transaction; (三者選一就可以)
//1.查詢出商品信息
select status from t_goods where id=for update;
//2.根據商品信息生成訂單
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status爲2
update t_goods set status=2;
//4.提交事務
commit;/commit work;

上面的查詢語句中,我們使用了select…for update的方式,這樣就通過開啓排他鎖的方式實現了悲觀鎖。此時在 t_goods 表中,id 爲 1 的 那條數據就被我們鎖定了,其它的事務必須等本次事務提交之後才能執行。這樣我們可以保證當前的數據不會被其它事務修改。

上面我們提到,使用select…for update會把數據給鎖住,不過我們需要注意一些鎖的級別,MySQL InnoDB 默認行級鎖。行級鎖都是基於索引的,如果一條 SQL 語句用不到索引是不會使用行級鎖的,會使用表級鎖把整張表鎖住,這點需要注意。

基於樂觀鎖

樂觀併發控制(又名 “樂觀鎖”,Optimistic Concurrency Control,縮寫 “OCC”)是一種併發控制的方法。它假設多用戶併發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。在提交數據更新之前,每個事務會先檢查在該事務讀取數據後,有沒有其他事務又修改了該數據。如果其他事務有更新的話,正在提交的事務會進行回滾。

以使用版本號實現樂觀鎖爲例?

使用版本號時,可以在數據初始化時指定一個版本號,每次對數據的更新操作都對版本號執行 + 1 操作。並判斷當前版本號是不是該數據的最新的版本號。

1.查詢出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根據商品信息生成訂單
3.修改商品status爲2
update t_goods 
set status=2,version=version+1
where id=#{id} and version=#{version};

需要注意的是,樂觀鎖機制往往基於系統中數據存儲邏輯,因此也具備一定的侷限性。由於樂觀鎖機制是在我們的系統中實現的,對於來自外部系統的用戶數據更新操作不受我們系統的控制,因此可能會造成髒數據被更新到數據庫中。在系統設計階段,我們應該充分考慮到這些情況,並進行相應的調整(如將樂觀鎖策略在數據庫存儲過程中實現,對外只開放基於此存儲過程的數據更新途徑,而不是將數據庫表直接對外公開)。

對數據庫依賴,開銷問題,行鎖變表鎖問題,無法解決數據庫單點和可重入的問題。

基於 redis 如何實現分佈式鎖

基於 redis 如何實現分佈式鎖?這裏一定要看 Redis 的官網在新窗口打開的分佈式鎖的實現這篇文章。

SetNXPX + Lua

加鎖set NX PX + 重試 + 重試間隔

向 Redis 發起如下命令: SET productId:lock 0xx9p03001 NX PX 30000 其中,"productId" 由自己定義,可以是與本次業務有關的 id,"0xx9p03001" 是一串隨機值,必須保證全局唯一 (原因在後文中會提到),“NX"指的是當且僅當 key(也就是案例中的"productId:lock”) 在 Redis 中不存在時,返回執行成功,否則執行失敗。"PX 30000" 指的是在 30 秒後,key 將被自動刪除。執行命令後返回成功,表明服務成功的獲得了鎖。

@Override
public boolean lock(String key, long expire, int retryTimes, long retryDuration) {
    // use JedisCommands instead of setIfAbsense
    boolean result = setRedis(key, expire);

    // retry if needed
    while ((!result) && retryTimes-- > 0) {
        try {
            log.debug("lock failed, retrying..." + retryTimes);
            Thread.sleep(retryDuration);
        } catch (Exception e) {
            return false;
        }

        // use JedisCommands instead of setIfAbsense
        result = setRedis(key, expire);
    }
    return result;
}

private boolean setRedis(String key, long expire) {
    try {
        RedisCallback<String> redisCallback = connection -> {
            JedisCommands commands = (JedisCommands) connection.getNativeConnection();
            String uuid = SnowIDUtil.uniqueStr();
            lockFlag.set(uuid);
            return commands.set(key, uuid, NX, PX, expire); // 看這裏
        };
        String result = redisTemplate.execute(redisCallback);
        return !StringUtil.isEmpty(result);
    } catch (Exception e) {
        log.error("set redis occurred an exception", e);
    }
    return false;
}

解鎖:採用 lua 腳本

在刪除 key 之前,一定要判斷服務 A 持有的 value 與 Redis 內存儲的 value 是否一致。如果貿然使用服務 A 持有的 key 來刪除鎖,則會誤將服務 B 的鎖釋放掉。

if redis.call("get", KEYS[1])==ARGV[1] then
 return redis.call("del", KEYS[1])
else
 return 0
end

基於 RedLock 實現分佈式鎖

這是 Redis 作者推薦的分佈式集羣情況下的方式,請看這篇文章 Is Redlock safe? 在新窗口打開

假設有兩個服務 A、B 都希望獲得鎖,有一個包含了 5 個 redis master 的 Redis Cluster,執行過程大致如下:

  1. 客戶端獲取當前時間戳,單位: 毫秒

  2. 服務 A 輪尋每個 master 節點,嘗試創建鎖。(這裏鎖的過期時間比較短,一般就幾十毫秒) RedLock 算法會嘗試在大多數節點上分別創建鎖,假如節點總數爲 n,那麼大多數節點指的是 n/2+1。

  3. 客戶端計算成功建立完鎖的時間,如果建鎖時間小於超時時間,就可以判定鎖創建成功。如果鎖創建失敗,則依次 (遍歷 master 節點) 刪除鎖。

  4. 只要有其它服務創建過分佈式鎖,那麼當前服務就必須輪尋嘗試獲取鎖。

基於 Redis 的客戶端

這裏 Redis 的客戶端(Jedis, Redisson, Lettuce 等)都是基於上述兩類形式來實現分佈式鎖的,只是兩類形式的封裝以及一些優化(比如 Redisson 的 watch dog)。

以基於 Redisson 實現分佈式鎖爲例(支持了 單實例、Redis 哨兵、redis cluster、redis master-slave 等各種部署架構):

特色

  1. redisson 所有指令都通過 lua 腳本執行,保證了操作的原子性

  2. redisson 設置了 watchdog 看門狗,“看門狗” 的邏輯保證了沒有死鎖發生

  3. redisson 支持 Redlock 的實現方式。

過程

  1. 線程去獲取鎖,獲取成功: 執行 lua 腳本,保存數據到 redis 數據庫。

  2. 線程去獲取鎖,獲取失敗: 訂閱瞭解鎖消息,然後再嘗試獲取鎖,獲取成功後,執行 lua 腳本,保存數據到 redis 數據庫。

互斥

如果這個時候客戶端 B 來嘗試加鎖,執行了同樣的一段 lua 腳本。第一個 if 判斷會執行 “exists myLock”,發現 myLock 這個鎖 key 已經存在。接着第二個 if 判斷,判斷 myLock 鎖 key 的 hash 數據結構中,是否包含客戶端 B 的 ID,但明顯沒有,那麼客戶端 B 會獲取到 pttl myLock 返回的一個數字,代表 myLock 這個鎖 key 的剩餘生存時間。此時客戶端 B 會進入一個 while 循環,不聽的嘗試加鎖。

watch dog 自動延時機制

客戶端 A 加鎖的鎖 key 默認生存時間只有 30 秒,如果超過了 30 秒,客戶端 A 還想一直持有這把鎖,怎麼辦?其實只要客戶端 A 一旦加鎖成功,就會啓動一個 watch dog 看門狗,它是一個後臺線程,會每隔 10 秒檢查一下,如果客戶端 A 還持有鎖 key,那麼就會不斷的延長鎖 key 的生存時間。

可重入

每次 lock 會調用 incrby,每次 unlock 會減一。

進一步理解

  1. 藉助 Redis 實現分佈式鎖時,有一個共同的缺陷: 當獲取鎖被拒絕後,需要不斷的循環,重新發送獲取鎖 (創建 key) 的請求,直到請求成功。這就造成空轉,浪費寶貴的 CPU 資源。

  2. RedLock 算法本身有爭議,具體看這篇文章 How to do distributed locking 在新窗口打開 以及作者的回覆 Is Redlock safe? 在新窗口打開

基於 zookeeper 如何實現分佈式鎖?

說幾個核心點:

創建一個用於發號的節點 “/test/lock”,然後以它爲父親節點的前綴爲“/test/lock/seq-” 依次發號:

由於序號的遞增性,可以規定排號最小的那個獲得鎖。所以,每個線程在嘗試佔用鎖之前,首先判斷自己是排號是不是當前最小,如果是,則獲取鎖。

每個線程搶佔鎖之前,先搶號創建自己的 ZNode。同樣,釋放鎖的時候,就需要刪除搶號的 Znode。搶號成功後,如果不是排號最小的節點,就處於等待通知的狀態。等誰的通知呢?不需要其他人,只需要等前一個 Znode 的通知就可以了。當前一個 Znode 刪除的時候,就是輪到了自己佔有鎖的時候。第一個通知第二個、第二個通知第三個,擊鼓傳花似的依次向後。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vupf4Rc-LyeIMxD_uqnY7g