基於redis實現可靠的分布式鎖


什么是鎖

今天要談的是如何在分布式環境下實現一個全局鎖,在開始之前先說說非分布式下的鎖:

  • 單機 – 單進程程序使用互斥鎖mutex,解決多個線程之間的同步問題
  • 單機 – 多進程程序使用信號量sem,解決多個進程之間的同步問題

這里同步的意思很簡單:某個運行者,用某個工具,保障某段代碼,獨占的運行,直到釋放。

分布式鎖解決的是 多台機器 – 多個進程 之間的同步問題,因為不同的機器之間mutex/sem無法使用。不過要注意:即便如此,一個進程內多個線程之間仍舊建議使用mutex同步,盡量減少對分布式鎖服務造成不必要的負擔。

redis分布式鎖

首先呢,基於redis的分布式鎖並不是一個坊間方案,而是redis官網提供的解決思路並且有若干語言的實現版本直接使用。

今天要做的,首先是閱讀官方的文檔(中文點我英文點我),有些地方講的不怎么清晰,所以我接下來會分析PHP版本的代碼,應該可以解答你的主要疑惑。

分析代碼

首先打開代碼:https://github.com/ronnylt/redlock-php/blob/master/src/RedLock.php,這是PHP的官方推薦實現版本,它基於composer安裝(不懂composer可以點我)。

構造函數 

    function __construct(array $servers, $retryDelay = 200, $retryCount = 3)
    {
        $this->servers = $servers;
        $this->retryDelay = $retryDelay;
        $this->retryCount = $retryCount;
        $this->quorum  = min(count($servers), (count($servers) / 2 + 1));
    }

  

 

  • 需要傳入的是redis的若干master節點地址,並且這些master是純內存模式且無slave的。
  • retryDelay是設置每一輪lock失敗或者異常,多久之后重新嘗試下一輪lock。
  • retryCount是指最多幾輪lock失敗后徹底放棄。
  • quorum體現了分布式里一個有名的”鴿巢原理”,也就是如果大於半數的節點操作成功則認為整個集群是操作成功的;在這里的意思是,如果超過1/2的(>=N/2+1)redis master調用鎖成功,則認為獲得了整個redis集群的鎖,假設A用戶獲得了集群的鎖,那么接下來的B用戶只能獲得<=1/2的redis master的鎖,相當於無法獲得集群的鎖。

初始化redis連接

 

private function initInstances()
    {
        if (empty($this->instances)) {
            foreach ($this->servers as $server) {
                list($host, $port, $timeout) = $server;
                $redis = new \Redis();
                $redis->connect($host, $port, $timeout);
                $this->instances[] = $redis;
            }
        }
    }

  

  • 遍歷每個redis master,建立到它們的連接並保存起來;
  • 因為需要用到”鴿巢原理”,也就是redis數量足夠產生”大多數”這個目的:因此redis master數量最好>=3台,因為2台的話大多數是2台(2/2+1),這樣任何1台故障就無法產生”大多數”,那么整個分布式鎖就不可用了。

請求1個redis上鎖

 

  • 請求某一台redis,如果key=resource不存在就設置value=token(算法生成,全局唯一),並且redis會在ttl時間后自動刪除這個key

請求1個redis放鎖

 

    private function unlockInstance($instance, $resource, $token)
    {
        $script = '
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            else
                return 0
            end
        ';
        return $instance->eval($script, [$resource, $token], 1);
    }

  

  • 請求某一台redis,給它發送一段lua腳本,如果resource的value不等於lock時設置的token則說明鎖已被它人占用無需釋放,否則說明是自己上的鎖可以DEL刪除。
  • lua腳本在redis里原子執行,在這里即保障GET和DEL的原子性。

請求集群鎖

  • 首先整個lock過程最多會重試retry次,因此外層有do while。
  • 為了獲取”大多數”的鎖,因此遍歷每個redis master去lock,統計成功的次數。
  • 因為遍歷redis master進行逐個上鎖需要花費一定的時間,因此在第1個redis上鎖前記錄時間T1,結束最后一個redis上鎖動作的時間點T2,此時第1個redis的TTL已經消逝了T2-T1這么長的時間。
  • 為了保障在鎖內計算期間鎖不會失效,我們剩余可以占用鎖的時間實際上是TTL – (T2 – T1),因為越靠前上鎖的redis其剩余時間越少,最少的就是第1個redis了。
  • drift值用於補償不同機器時鍾的精度差異,怎么理解呢:
    • 在我們的程序看來時間過去了(T2-T1),剩余的鎖時間認為是TTL-(T2-T1),在接下來的剩余時間內進行計算應該不會超過鎖的有效期。
    • 但是第1台redis機器的機器時鍾也許跑的比較快(比如時鍾多前進了1毫秒),那么數據會提前1毫秒淘汰,然而我們認為TTL-(T2-T1)秒內鎖有效,而redis相當於TTL-(T2-T1)-1秒內鎖有效,這可能導致我們在鎖外計算。(drift+1)
    • 另外,我們計算(T2-T1)之后到返回給lock的調用者之間還有一段代碼在運行,這段代碼的花費也將占用一些時間,所以drift應該也考慮這個。(drift+1)
    • 最后,ttl * 0.01的意思是ttl越長,那么時鍾可能差異越大,所以這里做了一個動態計算的補償,比如ttl=100ms,那么就補償1ms的時鍾誤差,盡量避免遇到鎖已過期而我們仍舊在計算的情況發生。
  • 如果鎖redis成功的次數>1/2,並且整個遍歷redis+鎖定的過程的耗時 沒有超過鎖的有效期,那么lock成功,將剩余的鎖時間(TTL減去上鎖花費的時間)+ 鎖的標識token 返回給用戶。
  • 如果上鎖中途失敗(返回key已存在)或者異常(不知道操作結果),那么都認為上鎖失敗;如果上鎖失敗的數量超過1/2,那么本次上鎖失敗,需要遍歷所有redis進行回滾(回滾失敗也沒有辦法,其他人只能等待我們的key過期,並不會有什么錯誤)。

釋放集群鎖

    public function unlock(array $lock)
    {
        $this->initInstances();
        $resource = $lock['resource'];
        $token    = $lock['token'];
        foreach ($this->instances as $instance) {
            $this->unlockInstance($instance, $resource, $token);
        }
    }
  • 遍歷所有redis,利用lua腳本原子的安全的釋放自己建立的鎖。

故障處理

這里所有redis都是master,不開啟持久化,也不需要slave。

如果某台redis宕機,那么不要立即重啟它,因為宕機后redis沒有任何數據,如果你此時重啟它,那么其他進程就可以可以鎖住一個本應還沒有過期的key,這可能導致2個調用者同時在鎖內進行計算,舉個例子吧:

3個redis,兩個用戶A和B,有這么1個典型流程來說明上述情況:

  • A發起lock,鎖住了2個redis(r1+r2),超過3/2+1(大多數),開始執行鎖內操作。
    • r0() r1(A) r2(A)
  • r1宕機,立即重啟,數據全部丟失;A仍舊在進行鎖內計算,並不知情。
    • r0() r1() r2(A)
  • B發起lock,鎖住了2個redis(r0+r1),超過3/2+1(大多數),開始執行鎖內操作。
    • r0(B) r1(B) r2(A)

悲劇的事情發生了,因為r1宕機立即重啟導致B可以成功鎖住”大多數”redis,導致A和B並發操作。

紅色字體就是解決這個問題的:不要立即重啟,保持r1無法聯通,這樣的話B只能鎖住r0,沒有達到”大多數”從而上鎖失敗。那么何時重啟r1呢?根據業務最大的TTL判斷,當過了TTL秒后redis中所有key都會過期,遵守規則的A用戶的計算也應早已結束,此時B獲得鎖也可以保證獨占。

當然,無論宕機幾台原理都是一樣的,不要立即重啟,等待最大TTL過期后再啟動redis,你可以自己分析上述例子,假設r0和r1一起宕機看看又會發生什么。

分布式鎖用途

我也沒有經驗,不過猜想一個場景:

庫存服務通常需要高並發的update一行記錄以更新商品的剩余數量,而我們知道mysql的update是行鎖的,如果並發過高造成mysql的工作線程都在等待行鎖,將會影響mysql處理其他請求。

如果可以把行鎖用redis鎖取代,那么到達mysql層的並發將永遠都是1,問題將得到解決,不過要注意上述redis鎖的實現有一個問題就是高並發場景下,可能導致誰都無法獲取”大多數”的鎖,不過好在redis一般足夠穩定並且上述實現在lock失敗重試時有一個隨機的間隔值,從而讓某個Lock調用者有機會獲得”大多數”。

 

原文點我


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM