老生常談分佈式鎖

網絡上很多關於分佈式鎖的文章,有些標題起得誇張得要死, 內容卻很是通常, 固然也有一些文章總結得至關不錯, 讓人受益不淺.html

本文比較務實,並無不少高大上的理論, 單純地想從分佈式鎖的實現的推演過程當中,探討一下分佈式鎖實現,和使用中應該注意哪些問題java

分佈式鎖的實現方式

數據庫主鍵實現

這個主要是利用到了數據庫的主鍵的惟一性, 例如惟一性來實現分佈式鎖的排他性.git

具體案例的話, 據我所知的, 就是quartz的集羣模式中就利用到了innodb來作分佈式鎖,來避免同一個任務被多個節點重複執行.github

例如數據庫主鍵作分佈式鎖的主要問題是不夠靈活,例如可重入等等特性實現起來比較麻煩, 適合比較簡單的場景下使用redis

zookeeper實現

基於zk的分佈式鎖通常是用到了其臨時順序節點的特性, id最小的臨時節點視爲獲取到鎖, 會話結束時臨時節點會被自動刪掉,下一個最小id的臨時節點獲取到鎖算法

zk分佈式鎖存在的問題是,zk的寫性能其實很差,畢竟都是寫在硬盤上的文件中的, 因此不大適合在高併發環境中數據庫

redis實現

這個主要是利用到了redis是每個命令單個命令都是原子性的特性來實現分佈式鎖.緩存

簡單的來講就是,須要加鎖的時候就調用set, 須要釋放鎖的時候就調用del, 固然實際上沒有那麼簡單.網絡

redis實現分佈式鎖的最大優勢就是性能好.併發

小結

其實每一種分佈式鎖的實現都有它的優點, 例如說數據庫的理解簡單, zk的實現可靠性高, redis的實現性能高. 主要仍是要根據具體的業務場景選擇合適的實現方式.

因爲實際應用中, 仍是redis實現的比較多(印象流), 所以本文選擇redis實現來進行分析

基於redis實現的分佈式鎖

首先定義一個最簡單的分佈式鎖的接口,它只有兩個方法:

  1. 加鎖, 指定鎖的名稱, 和鎖的超時時間, 獲取不到直接返回false
  2. 釋放鎖
package com.north.lat.dislocklat;

/**
 * @author lhh
 */
public interface DisLock {
    /**
     * 加鎖
     * @param lockName 鎖的名稱
     * @param lockValue 鎖的redis值
     * @param expire 鎖的超時時間
     * @return 加鎖成功則返回true, 不然返回false
     */
    boolean lock(String lockName,String lockValue,int expire);

    /**
     *  釋放鎖
     * @param lockName
     * @param lockValue
     * @return 釋放成功則返回true
     */
    boolean  unlock(String lockName,String lockValue);
}
1. set NX PX 加鎖, DELETE釋放鎖
redis官方已經爲咱們提供了一個命令
SET key value [EX seconds] [PX milliseconds] [NX|XX]

這個命令能夠在一個key不存在的時候,設置這個KEY的值, 並指定這個key的過時時間, 而且這個命令是原子性的, 因此能夠完美地被咱們用來做爲加鎖的操做

利用這個命令, 咱們能夠先實現第一個版本的分佈式鎖:

package com.north.lat.dislocklat.redisimpl;

import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;

/**
 * @author lhh
 */
public class DisLockV1 implements DisLock {
    public static final String OK = "OK";
    private Jedis jedis = new Jedis ("localhost",6379);
    @Override
    public boolean lock(String lockName, String lockValue, int expire) {
        String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
        return OK.equalsIgnoreCase(ret);
    }

    @Override
    public boolean unlock(String lockName,String lockValue) {
        Long c = jedis.del(lockName);
        return c > 0;
    }
}

測試代碼以下:

package com.north.lat.dislocklat;

import com.north.lat.dislocklat.redisimpl.DisLockV1;

public class DisLockTest {


    public static void main(String[] args) {
        String lockName = "test_lock";
        String lockValue = "test_value";
        DisLock disLock = new DisLockV1();
        boolean success = disLock.lock(lockName, lockValue, 10);
        if(success){
            try {
                doSomeThingImportant();
            }finally {
                disLock.unlock(lockName, lockValue);
            }
        }
    }

    public static void doSomeThingImportant(){

    }
}

這是一個最簡單版本的分佈式鎖

  1. 加鎖成功,確定會釋放鎖
  2. 鎖的超時時間設爲10秒,避免鎖長時間不釋放

這個分佈鎖理論上在簡單的場景下是沒有問題的,然而在doSomeThingImportant()業務比較複雜, 處理時間過長的狀況下, 就會出現問題了. 咱們來模擬一下

時刻 線程1 線程2 線程3 線程4
第1秒 加鎖 加鎖 未開始執行 未開始執行
第2秒 獲取到鎖 沒獲取到鎖 未開始執行 未開始執行
第10秒 執行業務邏輯 已返回 未開始執行 未開始執行
第11秒 執行業務邏輯(鎖已超時失效) - 加鎖 未開始執行
第12秒 釋放鎖, 這時把線程2的鎖也釋放了 - 執行業務邏輯 未開始執行
第13秒 返回 - 執行業務邏輯 加鎖(獲取鎖成功)
第14秒 - - 執行業務邏輯 執行業務邏輯
第n秒 - - ... ...

對照上面的時刻表, 前面的10秒都沒有問題, 若是10秒內線程能處理完業務邏輯的話,也不會有問題.

然而, 第11秒的時候線程1尚未處理完它本身的業務邏輯, 恰好線程2又過來加鎖, 這時候問題就出現了:
線程1尚未釋放鎖的時候, 線程2加鎖成功了.

問題並不止一個, 到了第12秒的時候,線程1終於處理完本身的業務邏輯,而後就屁顛屁顛地去把鎖給釋放了.這一釋放不單把本身的鎖給釋放了, 還把線程3的鎖也給釋放了.

到了第13秒的時候, 線程4過來加鎖,有線程1和線程3的鎖都被釋放了, 所以線程4加鎖成功

整個過程當中, 線程1和線程3同時執行過臨界區代碼, 線程3和線程4也同時執行過臨界區代碼.分佈鎖跟本沒起一點做用

綜上所述, 這個絕對不是一個可用的分佈式鎖代碼. 那麼它的問題是什麼呢, 主要是下面兩點:

1. 超時時間設置不合理, 由於redis key過時致使鎖失效
    2. 釋放鎖的問題, 釋放鎖的時候把其餘線程加的鎖也給釋放了

怎麼解決呢? 咱們來看第二個版本的分佈式鎖實現

2. set Nx px + lua 腳本delete + 定時器
鎖的超時時間

對於分佈式鎖的過時時間的值,實際上是一個比較難肯定的東西. 由於咱們永遠不知道臨界區的業務邏輯到底要執行多長時間, 若是設置過短, 就會出現上面的那種狀況, 若是說設置得長點, 那多長算是長呢?

一個簡單的辦法就是在鎖快要失效的時候,若是代碼沒有執行完,那麼就給這個鎖的過時時間延長一些.

這個算法思想大概以下:

1. 加鎖, 過時時間爲N秒
2. 若是加鎖成功, 則開啓一個定時器
3. 定時器一直在執行, 每過了X(X < N, 通常可配置)秒, 就給這個鎖延長Y (Y > X, 通常可配置)秒
4. 釋放鎖的時候, 把定時器刪掉

在上面算法中, 只要臨界區的代碼沒有執行完, 定時器會一直給分佈式鎖"續命", 直到這個分佈式鎖被應用程序釋放掉.

乍一看,若是業務代碼一直沒有處理完, 那這裏豈不是跟沒有設置超時時間同樣同樣的?

但其實仍是有區別:

1. 沒有設置超時時間, redis的key是不會失效的.
   2. "續命"的這種方式, 只有在應用程序(的臨界代碼)一直在運行的狀況下, redis的key的過時時間會不斷地被延長
   
   區別就在於, 鎖的失效與否仍是在鎖的使用方手上, 而不是在於鎖自己

另外定時器(具體實現中多是一個守護線程)都是在臨界區內生成和銷燬的, 也就是每一個時刻最多隻會有一個定時器存在, 因此也沒必要擔憂性能問題

只是要保證加鎖釋放鎖和定時器的生成銷燬的事務性, 即加鎖成功必需要生成定時器, 釋放鎖必需要銷燬定時器

鎖釋放的問題

鎖釋放的時候,誤把其餘線程加的鎖也釋放了. 這個問題其實很容易解決, 就是釋放鎖的時候, 判斷一下這個鎖是不是本身加的,是的話才釋放鎖. 僞代碼實現以下:

public boolean unlock(String lockName,String lockValue) {
          String val = jedis.get(lockName); // (1)
          if(lockValue.equalsIgnoreCase(val)){
              jedis.del(lockName); // (2)
          }
          return true;
    }

可是上面這段代碼明顯(1)和(2)不是原子性的, 極可能會帶來一些未知的問題.因此真正的實現並非這樣的,而是使用lua腳本,把兩個命令放在一塊兒,原子性地執行, 代碼以下:

public boolean unlock(String lockName,String lockValue) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        // public Object eval(String script, List<String> keys, List<String> args)
        // 第一個參數是腳本, 第二個參數是腳本中涉及到的key的列表, 這裏只涉及到lockName一個key, 第三個參數是涉及到的參數的列表, 這裏只有一個lockValue參數
        // 因此這裏實際執行的腳本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
        Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
        return "1".equalsIgnoreCase(o.toString());
    }
DisLockV2

在實現咱們的第二個版本的redis分佈鎖以前, 咱們先來總結一些,針對初版,有哪些優化

1. 每一個線程加鎖的時候, redis key的值必須不同,並且惟一.釋放鎖的時候要傳上這個惟一值
2. 加鎖的時候,要新建一個定時器, 不斷地延長這key的過時時間,直到鎖釋放
3. 釋放鎖的時候, 要判斷當前鎖的redis value是不是當前線程set進入的, 若是不是則不能釋放
4. 釋放鎖的時候要把定時器銷燬

代碼簡單實現以下:

package com.north.lat.dislocklat.redisimpl;

import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;

import java.util.Collections;

/**
* @author lhh
*/
public class DisLockV2 implements DisLock {
   public static final String OK = "OK";
   private Jedis jedis = new Jedis ("localhost",6379);
   @Override
   public boolean lock(String lockName, String lockValue, int expire) {
       String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
       createTimer(lockName, jedis, expire);
       return OK.equalsIgnoreCase(ret);
   }

   @Override
   public boolean unlock(String lockName,String lockValue) {
       deleteTimer();
       String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
       // public Object eval(String script, List<String> keys, List<String> args)
       // 第一個參數是腳本, 第二個參數是腳本中涉及到的key的列表, 這裏只涉及到lockName一個key, 第三個參數是涉及到的參數的列表, 這裏只有一個lockValue參數
       // 因此這裏實際執行的腳本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
       Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
       return "1".equalsIgnoreCase(o.toString());
   }
   /**
    * 建立定時器, 這裏暫時省略實現
    * @param lockName
    * @param jedis
    * @param expire
    */
   void createTimer(String lockName,Jedis jedis, int expire){
       //每過了X(X < expire, 通常可配置)秒,jedis.expire  就給lockName這個鎖延長Y (Y > X, 通常可配置)秒
   }

   /**
    *銷燬定時器, 這裏暫時省略實現
    */
   void deleteTimer(){
   }
}

測試main方法, 惟一變更的是lockValue:

package com.north.lat.dislocklat;

import com.north.lat.dislocklat.redisimpl.DisLockV1;

import java.util.UUID;

public class DisLockTest {


   public static void main(String[] args) {
       String lockName = "test_lock";
       // 用uuid 保持惟一
       String lockValue = UUID.randomUUID().toString();
       DisLock disLock = new DisLockV1();
       boolean success = disLock.lock(lockName, lockValue, 10);
       if(success){
           try {
               doSomeThingImportant();
           }finally {
               disLock.unlock(lockName, lockValue);
           }
       }
   }

   public static void doSomeThingImportant(){

   }
}

上面這個定時器的思路,實際上是redission 分佈式鎖裏面的實現細想. 固然redission還實現了可重入,異步等等特性,咱們的跟它的是沒法比的這裏只是體現一下思想而已.

那麼這樣實現的分佈式鎖是否還有問題? 答案是確定的. 讓咱們再來推演一下兩種異常狀況.

redis主從切換

你們都知道, 爲了提升可用性, 生產環境中的redis通常都不會是單點.解決單點有不少種方案, 可用是客戶端分片, 哨兵模式,集羣模式等等, 無論是哪一種方式 redis通常都會有一主一從. 正常狀況是master提供服務, slave節點保持數據同步,
當master掛了的話, slave節點變成新的master, 來繼續提供服務.

在redis只做爲緩存服務的時候, 這個模式是比較可靠的. 可是在做爲分佈鎖的狀況下, 有時就不可用了.考慮如下的一種場景:

時刻 線程1 線程2 redis1 redis2 備註
第1秒 獲取鎖 - 做爲master 做爲slave redis1有lock的key, redis2尚未
第2秒 獲取到鎖,執行業務邏輯 獲取鎖 掛了 成爲master 假設因爲網絡延遲,redis1的lock的key尚未同步到redis2
第3秒 執行業務邏輯 獲取到鎖,執行業務邏輯 掛了 做爲master 同時有兩個線程在執行臨界區代碼,分佈式鎖不起做用
第4秒 執行業務邏輯 執行業務邏輯 掛了 做爲master
第n秒 ... .. ... ...

從上面第2秒能夠看到,因爲主從切換的時候, slave節點上面是不必定有master節點的全部的數據的, 這個時候若是有另一個線程來獲取鎖, 就會出現多個線程同時獲取到鎖的狀況

3. REDLOCK

若是redis是單實例的話, 上面的分佈式鎖已是可用的了, 只是又必需要面臨單redis實例掛掉的風險.

爲了解決redis主從切換帶來的問題,reddsion的設計者實現一個新的分佈式鎖, 就是大名鼎鼎的REDLOCK

REDLOCK的設計思想仍是很符合咱們實事求是,具體問題具體分析的方法論的:
1. 主從切換會致使分佈式鎖失效? ok, 那就用單實例的redis 2. 單實例存在單點故障? ok, 那咱們用多個相互獨立的單實例redis
總的來講, REDLOCK的實現思路就是放棄redis的主從結構, 使用N(通常是5)個redis實例來保證可用性

N個redis實例互相獨立,分佈式鎖只有在大多數的實例上成功獲取到鎖, 纔到算獲取到鎖成功. 爲了不多個實例同時掛掉,
通常來講每一個實例都在不一樣的機器上面.

當客戶端嘗試去獲取分佈式鎖的時候, 須要通過如下幾個步驟

1. 計算當前時間戳CUR_T
  2. 客戶端逐一貫N個redis獲取鎖.也就是把同一個KEY和VALUE分佈寫到每一個redis實例中,過時時間爲EX_T. 獲取鎖的時候還須要指一個時間:
  此次set命令的響應超時時間RESP_T. 其中RESP_T < EX_T. RESP_T的存在是爲了不某個redis實例已經掛了的時候,還在苦等它響應返回.
  3. 對於第2步中的任何一個redis實例, 若是RESP_T時間內沒有返回, 或者set命令返回false, 則表明獲取鎖失敗, 不然就是獲取鎖成功. 無論在當前實例獲取鎖成功仍是失敗, 都立馬向下一個實例獲取鎖.
  4. N個redis都請求完後,計算總耗時(用加鎖完成時間戳-CUR_T) ,知足至少有(N/2+1)個實例能獲取到鎖,並且總耗時小於鎖的失效時間纔算獲取鎖成功.
  5. 若是獲取鎖失敗,要算全部的實例unlock釋放鎖.

上面的這個思路, 在這篇譯文中描述得很是清楚, 文中REDLOCK的做者大概的論證了這個算法的正確性,並不是常自信地認爲該分佈鎖算法是無懈可擊的

可是另一位大神Martin Kleppmann在他的文章內舉了很多的例子, 來證實REDLOCK是脆弱的,不可靠的. 其中這裏是一篇簡單的譯文

我試着理解了一下他的其中一個舉證

jvm發生FULL GC

在java應用裏面, 當full gc發生的時候, 整個jvm會發生stop the world的停頓, 當停頓發生時, 分佈鎖的正確性就可能會被打破

來考慮一下下面的一種場景:

時刻 進程1 進程2 進程3
第1秒 加鎖 加鎖 未開始執行
第2秒 獲取到鎖 沒獲取到鎖 未開始執行
第3秒 執行業務邏輯,發生FULL GC 已返回 未開始執行
第4秒 執行業務邏輯,FULL GC, STOP THE WORLD中 已返回 未開始執行
第11秒 FULL GC結束,執行業務邏輯(鎖已超時失效) - 加鎖
第12秒 執行業務邏輯 - 執行業務邏輯
第n秒 .. ... ...

當JVM在stop the world時, 無論是業務邏輯代碼, 仍是上面的"續命"定時器代碼, 都會中止運行.

當FULL GC的停頓時間過長時, redis中分佈式鎖的key有可能已通過期了. 倘若FULL GC結束的瞬間有另一個進程過來獲取鎖的話, 就會發生同時兩個進程獲取到鎖,同時執行臨界區代碼的狀況.

Martin Kleppmann也給出這個狀況的解決方案(詳細見這篇譯文), 並指出redlock處理不了這種狀況, 因此redlock是不可靠的.

有趣的是, redlock的做者在另一篇文章迴應了Martin Kleppmann的質疑. 內容就沒有仔細看了, 質疑的論文和反質疑的論文都是兩三年前的了, 在技術突飛猛進的這個時代, 文中的一些觀點可能早就過期或者是解決掉了.

相關文章
相關標籤/搜索