基於redis實現的分散式鎖

基於redis實現的分散式鎖實現

什麼是鎖

這裡需要引入一個話題,什麼是鎖?其實說到鎖在我們的現實生活中非常的常見,比如密碼鎖,指紋鎖,他是為了保證家中物資的安全性的一道保障。而在我們的電腦領域中,其實也有鎖的概念,他的目的與上相似,都是為了保證數據的最終一致性,當然在單個執行緒鎖是沒有太大作用,但是若出現多個執行緒之間對某個資源進行競爭的時候,那麼鎖的存在就很有意義。那麼剛才所提到的是針對與單體服務的鎖(這個有時間可以講講基於java中鎖的概念),然而隨網路用戶量的升級,單體服務難以支撐起龐大的訪問量,為此我們的服務之間存在了數據以及模組的拆分,之前關於單體服務的鎖對我們就不太適用了,因此就會引入到我們今天所需要講的分散式鎖。

 

為什麼要用分散式鎖

就如之前所說,我們需要保證數據的一致性,防止分散式系統中多個執行緒之間相互進行干擾,我們需要一種分散式協調技術來對這些進程進行調度。而這個分散式協調技術的核心就是來實現這個分散式鎖,以用戶購買商品下單為例子。

  • 未使用鎖

    我們在沒有使用任何鎖的時候,當用戶進行下單的時候我們需要對redis 中的庫存進行查詢,如果是可以售賣,那麼數量就需要進行更新,同時使用mq進行落庫。但是,這個地方需要注意的是查詢和更新是兩部操作(其實可以 使用lua進行原子操作,但是今天主要講的是分散式鎖) 這樣可能會存在bug,如果兩個執行緒同時進行操作的時候,如果庫存只有1,兩個執行緒在沒有執行第三步的間隙同時進入了第二階段,都認為可以購買,並進入了第四階段。 那麼就會存在我們所謂的超賣問題(沒錯的話,估計會被請去喝茶了)

      

  • 使用分散式鎖

    這裡略過了多個單體伺服器多執行緒操作的過程,其實也和上面的類似,都是相同時間,多個執行緒對同一數據進行操作。其實有個思路,就是能夠保證全局唯一執行緒去獲取到鎖並對數據進行操作,那麼就可以保證全局的安全性問題,能夠實現分散式鎖的框架很多,如 redis,以及zookeeper甚至是資料庫, 我們這裡使用redis作為我們的分散式鎖,關於redis為什麼能實現分散式鎖主要是用到了他的單執行緒模式,採用隊列模式將並發訪問轉換成串列模式

 

在圖中其實可以看到,如果多個服務進行購買商品的時候,在最後會進入到分散式鎖的階段,得到了鎖的伺服器的那個執行緒才能執行對扣減操作。其實對應鎖的和java中的多執行緒非常類似,只是從java中的synchronized的鎖更改未了redis 的單執行緒操作,然後操作對象由原來的單體伺服器中的多個執行緒更改為了多個伺服器的多個執行緒進行執行操作。

 

分散式鎖原理

既然redis那麼好用,那是用的內部哪個命令呢,setNx,沒錯。就是一個命令就可以做到我們想要的。他其實內部包含有兩部分操作

1、判斷數據是否存在,

2、如果存在那麼不插入數據,如果不存在那麼就插入對應的數據。

 

具體的執行操作如下。

img

 

 

圖中的Setex 命令為指定的 key 設置值及其過期時間。如果 key 已經存在, SETEX 命令將會替換舊的值。

同時這裡需要注意

* 【千萬記住】解鎖流程不能遺漏,否則導致任務執行一次就永不過期

* 將加鎖程式碼和任務邏輯放在try,catch程式碼塊,將解鎖流程放在finally

 

 

分散式鎖可能出現的問題

雖然這個命令能夠完成我們在高並發的數據一致性,但是還是可能會存在一些問題

  • 服務宕機導致redis鎖永不失效

  • 執行緒誤刪除redis鎖

  • 執行執行緒操作時redis鎖過期

  • 集群模式下哨兵重選導致的redis丟失

     

所以,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:

  1. 互斥性。在任意時刻,只有一個客戶端能持有鎖。

  2. 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。

  3. 具有容錯性。只要大部分的Redis節點正常運行,客戶端就可以加鎖和解鎖。

  4. 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

我們通過程式碼的方式來對分散式鎖出現問題進行節點

1、服務宕機導致redis鎖永不失效

  • 錯誤程式碼範例


    public static void thisIsWrongLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

      Long result = jedis.setnx(lockKey, requestId);
      if (result == 1) {
          // 若在這裡程式突然崩潰,則無法設置過期時間,將發生死鎖
          jedis.expire(lockKey, expireTime);
      }

    }

    由於設置中 加鎖以及設置過期時間為兩段操作,在未執行過期時間時伺服器宕機,那麼這個鎖就永遠沒有辦法失效了

  • 正確操作

    其實正確操作就是將剛才的兩步操作,更改為一步操作,那麼操作的方式是什麼呢,這就要涉及到另外個語言LUA腳本語言,他執行是原子性質操作。

      //上鎖腳本
      private static final String LOCK_LUA = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('expire', KEYS[1], ARGV[2]) return 'true' else return 'false' end";
     
      /**
        *
        * @param lockKey 上鎖
        * @param time 上鎖時間
        * @return
        */
        public boolean lock(String lockKey, int time) {
          RedisScript lockRedisScript = RedisScript.of(LOCK_LUA, String.class);
          List<String> keys = Collections.singletonList(lockKey);
          /**
          * 這裡上鎖的value是當前執行緒
          **/
          String flag = redisTemplate.execute(lockRedisScript, argsSerializer, resultSerializer, keys, Thread.currentThread().getName(), String.valueOf(time));
          return Boolean.valueOf(flag);
      }


 

2、執行緒誤刪除redis鎖

 

  • 錯誤示範



      public   void wrongReleaseLock1( String lockKey) {
          redisTemplate.delete(lockKey);
      }

    沒錯 看了這個就是直接強制刪除對應的redis key值,管你是誰,直接強刪,這樣也會出現很多問題。

 

  • 正確示範

    我們可以在進行操作的時候來對key值數據進行判斷,判斷數據是否是我們之前存放的結果值,一般來結果值也需要一個特定的數據,那想像下,在同一時間執行如何他設置一個唯一性id的value值呢?其實方式也很多,redis的incr或者雪花演算法生成的唯一性id,然後使用lua腳本進行執行操作就可以了

         //解鎖腳本
      private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then redis.call('del', KEYS[1]) end return 'true' ";
     
       
      public void unlock(String lockKey, String val) {
          RedisScript unLockRedisScript = RedisScript.of(UNLOCK_LUA, String.class);
          List<String> keys = Collections.singletonList(LOCK_PREFIX + lockKey);
          redisTemplate.execute(unLockRedisScript, argsSerializer, resultSerializer, keys, val);
      }

 

3、 redis鎖提前過期

在生產過程中其實可能會出現這樣業務程式碼執行緩慢的情況,而我們在添加的redis的過期太短,導致程式還沒有執行完,redis就直接過期從而其他執行緒會提前拿到對應的鎖。針對與解決思路其實設置時間長一點也行,但這裡其實可以考慮對鎖進行自動續期,需要引入redission客戶端進行操作。它內部提供了對應的看門狗,作用是在redisson實例被關閉之前,不斷的對鎖進行延長時間。

 

redisson的底層原理

img

 

 

4、集群模式下哨兵重選導致的redis丟失

如果redis 部署的是集群版本可能會腦裂或者是主master宕機問題。那麼就很有可能會出現鎖的丟失:

  1. 客戶端1在Redis的master節點上拿到了鎖

  2. Master宕機了,存儲鎖的key還沒有來得及同步到Slave上

  3. master故障,發生故障轉移,slave節點升級為master節點

  4. 客戶端2從新的Master獲取到了對應同一個資源的鎖

於是,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破了。針對這個問題。Redis作者antirez提出了RedLock演算法來解決這個問題

redLock思路

大致思路如下

1、獲取當前時間毫秒值 CT

2、按照順序向N個節點中執行獲取鎖的操作,為了保證在某個在某個Redis節點不可用的時候演算法能夠繼續運行,這個獲取鎖的操作還需要一個超時時間。它應該遠小於鎖的過期時間(expireTime))。客戶端向某個Redis節點獲取鎖失敗後,應立即嘗試下一個Redis節點。這裡失敗包括Redis節點不可用或者該Redis節點上的鎖已經被其他客戶端持有。

3、計算總耗時間,即ET=now()-CT,然後與過期時間進行比對,如果是小於鎖過期則對應上鎖生效,否則認定失敗,同時對所有節點進行鎖的刪除(無論是否得到都得執行該操作)

 

當然,這裡有個小問題:一定是要全部節點獲取到才認為上鎖成功么?

其實當超過半數redis請求到鎖的時候,才算是真正獲取到了鎖。如果沒有獲取到鎖,則把部分已鎖的redis釋放掉。

 

 

今天的分享就到這裡了,下期有興趣可以給大家分享下分散式事務的小知識。

 

 

附錄:

 

紅鎖