一篇文章帶你解讀redis分佈式鎖的發展史和正確實現方式

來源:點我達技術java

http://tech.dianwoda.com/程序員

前言

近兩年來微服務變得愈來愈熱門,愈來愈多的應用部署在分佈式環境中,在分佈式環境中,數據一致性是一直以來須要關注而且去解決的問題,分佈式鎖也就成爲了一種普遍使用的技術,經常使用的分佈式實現方式爲Redis,Zookeeper,其中基於Redis的分佈式鎖的使用更加普遍。

可是在工做和網絡上看到過各個版本的Redis分佈式鎖實現,每種實現都有一些不嚴謹的地方,甚至有多是錯誤的實現,包括在代碼中,若是不能正確的使用分佈式鎖,可能形成嚴重的生產環境故障,本文主要對目前遇到的各類分佈式鎖以及其缺陷作了一個整理,並對如何選擇合適的Redis分佈式鎖給出建議。

各個版本的Redis分佈式鎖

V1.0

tryLock(){  
    SETNX Key 1
    EXPIRE Key Seconds
}
release(){  
  DELETE Key
}

複製代碼
這個版本應該是最簡單的版本,也是出現頻率很高的一個版本,首先給鎖加一個過時時間操做是爲了不應用在服務重啓或者異常致使鎖沒法釋放後,不會出現鎖一直沒法被釋放的狀況。
這個方案的一個問題在於每次提交一個Redis請求,若是執行完第一條命令後應用異常或者重啓,鎖將沒法過時,一種改善方案就是使用Lua腳本(包含SETNX和EXPIRE兩條命令),可是若是Redis僅執行了一條命令後crash或者發生主從切換,依然會出現鎖沒有過時時間,最終致使沒法釋放。
另一個問題在於,不少同窗在釋放分佈式鎖的過程當中,不管鎖是否獲取成功,都在finally中釋放鎖,這樣是一個鎖的錯誤使用,這個問題將在後續的V3.0版本中解決。
針對鎖沒法釋放問題的一個解決方案基於GETSET命令來實現

V1.1 基於GETSET

tryLock(){  
    NewExpireTime=CurrentTimestamp+ExpireSeconds
    if(SETNX Key NewExpireTime Seconds){
         oldExpireTime = GET(Key)
          if( oldExpireTime < CurrentTimestamp){
              NewExpireTime=CurrentTimestamp+ExpireSeconds
              CurrentExpireTime=GETSET(Key,NewExpireTime)
              if(CurrentExpireTime == oldExpireTime){
                return 1;
              }else{
                return 0;
              }
          }
    }
}
release(){  
        DELETE key
    }

複製代碼
思路:
(1)SETNX(Key,ExpireTime)獲取鎖
(2)若是獲取鎖失敗,經過GET(Key)返回的時間戳檢查鎖是否已通過期
(3)GETSET(Key,ExpireTime)修改Value爲NewExpireTime
(4)檢查GETSET返回的舊值,若是等於GET返回的值,則認爲獲取鎖成功
注意:這個版本去掉了EXPIRE命令,改成經過Value時間戳值來判斷過時
問題:
(1)在鎖競爭較高的狀況下,會出現Value不斷被覆蓋,可是沒有一個Client獲取到鎖
(2)在獲取鎖的過程當中不斷的修改原有鎖的數據,設想一種場景C1,C2競爭鎖,C1獲取到了鎖,C2鎖執行了GETSET操做修改了C1鎖的過時時間,若是C1沒有正確釋放鎖,鎖的過時時間被延長,其它Client須要等待更久的時間

V2.0 基於SETNX

tryLock(){  
    SETNX Key 1 Seconds
}
release(){  
  DELETE Key
}

複製代碼
Redis 2.6.12版本後SETNX增長過時時間參數,這樣就解決了兩條命令沒法保證原子性的問題。可是設想下面一個場景:
(1)C1成功獲取到了鎖,以後C1由於GC進入等待或者未知緣由致使任務執行過長,最後在鎖失效前C1沒有主動釋放鎖
(2)C2在C1的鎖超時後獲取到鎖,而且開始執行,這個時候C1和C2都同時在執行,會因重複執行形成數據不一致等未知狀況
(3)C1若是先執行完畢,則會釋放C2的鎖,此時可能致使另一個C3進程獲取到了鎖
大體的流程圖
存在問題: (1)因爲C1的停頓致使C1 和C2同都得到了鎖而且同時在執行,在業務實現間接要求必須保證冪等性
(2)C1釋放了不屬於C1的鎖

V3.0

tryLock(){  
    SETNX Key UnixTimestamp Seconds
}
release(){  
    EVAL(
      //LuaScript
      if redis.call("get",KEYS[1]) == ARGV[1] then
          return redis.call("del",KEYS[1])
      else
          return 0
      end
    )
}

複製代碼
這個方案經過指定Value爲時間戳,並在釋放鎖的時候檢查鎖的Value是否爲獲取鎖的Value,避免了V2.0版本中提到的C1釋放了C2持有的鎖的問題;另外在釋放鎖的時候由於涉及到多個Redis操做,而且考慮到Check And Set 模型的併發問題,因此使用Lua腳原本避免併發問題。
存在問題:
若是在併發極高的場景下,好比搶紅包場景,可能存在UnixTimestamp重複問題,另外因爲不能保證分佈式環境下的物理時鐘一致性,也可能存在UnixTimestamp重複問題,只不過極少狀況下會遇到。

V3.1

tryLock(){  
    SET Key UniqId Seconds
}
release(){  
    EVAL(
      //LuaScript
      if redis.call("get",KEYS[1]) == ARGV[1] then
          return redis.call("del",KEYS[1])
      else
          return 0
      end
    )
}

複製代碼
Redis 2.6.12後SET一樣提供了一個NX參數,等同於SETNX命令,官方文檔上提醒後面的版本有可能去掉SETNX, SETEX, PSETEX,並用SET命令代替,另一個優化是使用一個自增的惟一UniqId代替時間戳來規避V3.0提到的時鐘問題。
這個方案是目前最優的分佈式鎖方案,可是若是在Redis集羣環境下依然存在問題:
因爲Redis集羣數據同步爲異步,假設在Master節點獲取到鎖後未完成數據同步狀況下Master節點crash,此時在新的Master節點依然能夠獲取鎖,因此多個Client同時獲取到了鎖

分佈式Redis鎖:Redlock

V3.1的版本僅在單實例的場景下是安全的,針對如何實現分佈式Redis的鎖,國外的分佈式專家有過激烈的討論, antirez提出了分佈式鎖算法Redlock,在distlock話題下能夠看到對Redlock的詳細說明,下面是Redlock算法的一箇中文說明(引用)
假設有N個獨立的Redis節點
一、獲取當前時間(毫秒數)。
二、按順序依次向N個Redis節點執行獲取鎖的操做。這個獲取操做跟前面基於單Redis節點的獲取鎖的過程相同,包含隨機字符串my_random_value,也包含過時時間(好比PX 30000,即鎖的有效時間)。
爲了保證在某個Redis節點不可用的時候算法可以繼續運行,這個獲取鎖的操做還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗之後,應該當即嘗試下一個Redis節點。
這裏的失敗,應該包含任何類型的失敗,好比該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這裏只提到了Redis節點不可用的狀況,但也應該包含其它的失敗狀況)。
三、計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間。若是客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,而且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認爲最終獲取鎖成功;不然,認爲最終獲取鎖失敗。
四、若是最終獲取鎖成功了,那麼這個鎖的有效時間應該從新計算,它等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。
五、若是最終獲取鎖失敗了(可能因爲獲取到鎖的Redis節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該當即向全部Redis節點發起釋放鎖的操做(即前面介紹的Redis Lua腳本)。
六、釋放鎖:對全部的Redis節點發起釋放鎖操做
然而Martin Kleppmann針對這個算法提出了質疑,提出應該基於fencing token機制(每次對資源進行操做都須要進行token驗證)
(1)Redlock在系統模型上尤爲是在分佈式時鐘一致性問題上提出了假設,實際場景下存在時鐘不一致和時鐘跳躍問題,而Redlock偏偏是基於timing的分佈式鎖
(2)另外Redlock因爲是基於自動過時機制,依然沒有解決長時間的gc pause等問題帶來的鎖自動失效,從而帶來的安全性問題。
接着antirez又回覆了Martin Kleppmann的質疑,給出了過時機制的合理性,以及實際場景中若是出現停頓問題致使多個Client同時訪問資源的狀況下如何處理。
針對Redlock的問題,基於Redis的分佈式鎖到底安全嗎給出了詳細的中文說明,並對Redlock算法存在的問題提出了分析。

總結

不管是基於SETNX版本的Redis單實例分佈式鎖,仍是Redlock分佈式鎖,都是爲了保證如下特性
(1)安全性:在同一時間不容許多個Client同時持有鎖
(2)活性 死鎖:鎖最終應該可以被釋放,即便Client端crash或者出現網絡分區(一般基於超時機制) 容錯性:只要超過半數Redis節點可用,鎖都能被正確獲取和釋放
因此在開發或者使用分佈式鎖的過程當中要保證安全性和活性,避免出現不可預測的結果。
另外每一個版本的分佈式鎖都存在一些問題,在鎖的使用上要針對鎖的實用場景選擇合適的鎖,一般狀況下鎖的使用場景包括:
(1)Efficiency(效率):只須要一個Client來完成操做,不須要重複執行,這是一個對寬鬆的分佈式鎖,只須要保證鎖的活性便可;
(2)Correctness(正確性):多個Client保證嚴格的互斥性,不容許出現同時持有鎖或者對同時操做同一資源,這種場景下須要在鎖的選擇和使用上更加嚴格,同時在業務代碼上儘可能作到冪等
在Redis分佈式鎖的實現上還有不少問題等待解決,咱們須要認識到這些問題並清楚如何正確實現一個Redis 分佈式鎖,而後在工做中合理的選擇和正確的使用分佈式鎖。

Redisson分佈式鎖實現原理的總結了一張圖

最後

歡迎你們關注個人公種浩【程序員追風】,整理了1000道2019年多家公司java面試題400多頁pdf文檔,文章都會在裏面更新,整理的資料也會放在裏面。喜歡文章記得關注我點個贊喲,感謝支持!
相關文章
相關標籤/搜索