溫故知新-分佈式系統-分佈式鎖的實現原理和存在的問題


摘要

本分旨在快速理解分佈鎖的實現原理,以及不同實現方式存在的問題,閱讀此文需要對mysql、zk、redis有一定的瞭解。

在Java中synchronized關鍵字和ReentrantLock可重入鎖在我們的代碼中是經常見的,一般我們用其在多線程環境中控制對資源的併發訪問,但是隨着分佈式的快速發展,本地的加鎖往往不能滿足我們的需要,在我們的分佈式環境中上面加鎖的方法就會失去作用。於是人們爲了在分佈式環境中也能實現本地鎖的效果,也是紛紛各出其招,今天讓我們來聊一聊一般分佈式鎖實現的套路。

分佈式鎖的特點

  • 互斥性:和我們本地鎖一樣互斥性是最基本,但是分佈式鎖需要保證在不同節點的不同線程的互斥。
  • 可重入性:同一個節點上的同一個線程如果獲取了鎖之後那麼也可以再次獲取這個鎖。
  • 鎖超時:和本地鎖一樣支持鎖超時,防止死鎖。
  • 高效,高可用:加鎖和解鎖需要高效,同時也需要保證高可用防止分佈式鎖失效,可以增加降級。
  • 支持阻塞和非阻塞:和ReentrantLock一樣支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。這個一般來說實現的比較少。

分佈式鎖的實現方式

  • MySql
  • zk
  • Redis

MySql

Mysql分佈式鎖的實現原理很簡單,也很容實現,創建一個表,當我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。這種方式實現問題也非常明顯。

  • 這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
  • 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
  • 這把鎖只能是非阻塞的,因爲數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
  • 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因爲數據中數據已經存在了。

zookeeper

  • 方式一:zk 分佈式鎖,其實可以做的比較簡單,就是某個節點嘗試創建臨時 znode,此時創建成功了就獲取了這個鎖;這個時候別的客戶端來創建鎖會失敗,只能註冊個監聽器監聽這個鎖。釋放鎖就是刪除這個 znode,一旦釋放掉就會通知客戶端,然後有一個等待着的客戶端就可以再次重新加鎖。

  • 方式二:創建臨時順序節點,如果有一把鎖,被多個人給競爭,此時多個人會排隊,第一個拿到鎖的人會執行,然後釋放鎖;後面的每個人都會去監聽排在自己前面的那個人創建的 node 上,一旦某個人釋放了鎖,排在自己後面的人就會被 zookeeper 給通知,一旦被通知了之後,就 ok 了,自己就獲取到了鎖,就可以執行代碼了,如圖所示

    • image-20200614204639719

存在問題

對比:在高併發場景下,方式一需要通知很多個監聽,此時會引起羊羣效應;所以一般推薦第二種方式;但是第二種方式也並非完美無缺,如上圖所示,如果發生腦裂等網路異常情況,導致clinet1生成的臨時節點被刪除、此時client2獲得了鎖,但此時clinet1並未執行完畢,此時就會引發問題。

redis

redis 最普通的分佈式鎖

第一個最普通的實現方式,就是在 redis 裏使用 setnx 命令創建一個 key,這樣就算加鎖。

SET resource_name random_value NX PX 30000

執行這個命令就 ok。

  • NX:表示只有 key 不存在的時候纔會設置成功。(如果此時 redis 中存在這個 key,那麼設置失敗,返回 nil
  • PX 30000:意思是 30s 後鎖自動釋放。別人創建的時候如果發現已經有了就不能加鎖了。

釋放鎖就是刪除 key ,但是一般可以用 lua 腳本刪除,判斷 value 一樣才刪除:

-- 刪除鎖的時候,找到 key 對應的 value,跟自己傳過去的 value 做比較,如果是一樣的才刪除。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

爲啥要用 random_value 隨機值呢?因爲如果某個客戶端獲取到了鎖,但是阻塞了很長時間才執行完,比如說超過了 30s,此時可能已經自動釋放鎖了,此時可能別的客戶端已經獲取到了這個鎖,要是你這個時候直接刪除 key 的話會有問題,所以得用隨機值加上面的 lua 腳本來釋放鎖。這個隨機數一般會存在ThreadLocal裏面;

private ThreadLocal lockValue = new ThreadLocal<>();

存在問題

但是這樣是肯定不行的。因爲如果是普通的 redis 單實例,那就是單點故障。或者是 redis 普通主從,那 redis 主從異步複製,如果主節點掛了(key 就沒有了),key 還沒同步到從節點,此時從節點切換爲主節點,別人就可以 set key,從而拿到鎖。

RedLock 算法

這個場景是假設有一個 redis cluster,有 5 個 redis master 實例。然後執行如下步驟獲取一把鎖:

  1. 獲取當前時間戳,單位是毫秒;
  2. 跟上面類似,輪流嘗試在每個 master 節點上創建鎖,過期時間較短,一般就幾十毫秒;
  3. 嘗試在大多數節點上建立一個鎖,比如 5 個節點就要求是 3 個節點 n / 2 + 1
  4. 客戶端計算建立好鎖的時間,如果建立鎖的時間小於超時時間,就算建立成功了;
  5. 要是鎖建立失敗了,那麼就依次之前建立過的鎖刪除;
  6. 只要別人建立了一把分佈式鎖,你就得不斷輪詢去嘗試獲取鎖

Redis 官方給出了以上兩種基於 Redis 實現分佈式鎖的方法,詳細說明可以查看:https://redis.io/topics/distlock 。

實際使用

在spring中,我們一般情況會中將鎖封裝爲註解,DistributedLock,通過APO的@Around的方法做增強,我們可以基於RedisTemplate實現自己鎖的邏輯,也可以使用RedissonClient(對分佈式相關支持比較好的redis客戶端);

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DistributedLock {
    /**
     * 鎖的資源前綴,可以寫入方法名稱
     */
    String prefix() default "";
    /**
     * 鎖的資源,redis的key,使用"#"開頭,可以取參數值
     */
    String value() default "default";
    /**
     * 鎖的有效時間  單位ms (默認6秒)
     */
    int expireTime() default 6000;
    /**
     * 請求鎖的超時時間 ms (默認1秒)
     */
    int timeOut() default 1000;
}

總結

分佈式鎖的實現有很多種,網上也非常齊全,具體代碼實現找一下就好了,不管是mysql、zk、redis多多少少都是存在問題的;

  • redis 分佈式鎖,其實需要自己不斷去嘗試獲取鎖,CPU的資源消耗較多。
  • zk 分佈式鎖,獲取不到鎖,註冊個監聽器即可,不需要不斷主動嘗試獲取鎖,性能開銷較小。

我們出於redis的高性能考慮,採用了redis實現了分佈式!

參考

ZooKeeper 的羊羣效應

再有人問你分佈式鎖,這篇文章扔給他

zookeeper 的容錯與腦裂問題


你的鼓勵也是我創作的動力

打賞地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章