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

Yangsc_o發表於2020-07-11


摘要

本分旨在快速理解分佈鎖的實現原理,以及不同實現方式存在的問題,閱讀此文需要對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

RedLock的演算法問題依然存在

  • 如果加鎖過程中發生了GC,那麼還是存在問題;

實際使用

在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 的容錯與腦裂問題


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

打賞地址

相關文章