redis系列:基於redis的分布式鎖

來源:互聯網
上載者:User
一、介紹

這篇博文講介紹如何一步步構建一個基於Redis的分布式鎖。會從最原始的版本開始,然後根據問題進行調整,最後完成一個較為合理的分布式鎖。

本篇文章會將分布式鎖的實現分為兩部分,一個是單機環境,另一個是叢集環境下的Redis鎖實現。在介紹分布式鎖的實現之前,先來瞭解下分布式鎖的一些資訊。

二、分布式鎖2.1 什麼是分布式鎖?

分布式鎖是控制分布式系統或不同系統之間共同訪問共用資源的一種鎖實現,如果不同的系統或同一個系統的不同主機之間共用了某個資源時,往往需要互斥來防止彼此幹擾來保證一致性。

2.2 分布式鎖需要具備哪些條件
  1. 互斥性:在任意一個時刻,只有一個用戶端持有鎖。
  2. 無死結:即便持有鎖的用戶端崩潰或者其他意外事件,鎖仍然可以被擷取。
  3. 容錯:只要大部分Redis節點都活著,用戶端就可以擷取和釋放鎖
2.4 分布式鎖的實現有哪些?
  1. 資料庫
  2. Memcached(add命令)
  3. Redis(setnx命令)
  4. Zookeeper(臨時節點)
  5. 等等
三、單機Redis的分布式鎖3.1 準備工作
  • 定義常量類
public class LockConstants {    public static final String OK = "OK";    /** NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist. **/    public static final String NOT_EXIST = "NX";    public static final String EXIST = "XX";    /** expx EX|PX, expire time units: EX = seconds; PX = milliseconds **/    public static final String SECONDS = "EX";    public static final String MILLISECONDS = "PX";    private LockConstants() {}}
  • 定義鎖的抽象類別

抽象類別RedisLock實現java.util.concurrent包下的Lock介面,然後對一些方法提供預設實現,子類只需實現lock方法和unlock方法即可。代碼如下

public abstract class RedisLock implements Lock {    protected Jedis jedis;    protected String lockKey;    public RedisLock(Jedis jedis,String lockKey) {        this(jedis, lockKey);    }    public void sleepBySencond(int sencond){        try {            Thread.sleep(sencond*1000);        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @Override    public void lockInterruptibly(){}    @Override    public Condition newCondition() {        return null;    }    @Override    public boolean tryLock() {        return false;    }    @Override    public boolean tryLock(long time, TimeUnit unit){        return false;    }}
3.2 最基礎的版本1

先來一個最基礎的版本,代碼如下

public class LockCase1 extends RedisLock {    public LockCase1(Jedis jedis, String name) {        super(jedis, name);    }    @Override    public void lock() {        while(true){            String result = jedis.set(lockKey, "value", NOT_EXIST);            if(OK.equals(result)){                System.out.println(Thread.currentThread().getId()+"加鎖成功!");                break;            }        }    }    @Override    public void unlock() {        jedis.del(lockKey);    }}

LockCase1類提供了lock和unlock方法。
其中lock方法也就是在reids用戶端執行如下命令

SET lockKey value NX

而unlock方法就是調用DEL命令將鍵刪除。
好了,方法介紹完了。現在來想想這其中會有什麼問題?
假設有兩個用戶端A和B,A擷取到分布式的鎖。A執行了一會,突然A所在的伺服器斷電了(或者其他什麼的),也就是用戶端A掛了。這時出現一個問題,這個鎖一直存在,且不會被釋放,其他用戶端永遠擷取不到鎖。如下

可以通過設定到期時間來解決這個問題

3.3 版本2-設定鎖的到期時間
public void lock() {    while(true){        String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30);        if(OK.equals(result)){            System.out.println(Thread.currentThread().getId()+"加鎖成功!");            break;        }    }}

類似的Redis命令如下

SET lockKey value NX EX 30

註:要保證設定到期時間和設定鎖具有原子性

這時又出現一個問題,問題出現的步驟如下

  1. 用戶端A擷取鎖成功,到期時間30秒。
  2. 用戶端A在某個操作上阻塞了50秒。
  3. 30秒時間到了,鎖自動釋放了。
  4. 用戶端B擷取到了對應同一個資源的鎖。
  5. 用戶端A從阻塞中恢複過來,釋放掉了用戶端B持有的鎖。

如下

這時會有兩個問題

  1. 到期時間如何保證大於業務執行時間?
  2. 如何保證鎖不會被誤刪除?

先來解決如何保證鎖不會被誤刪除這個問題。
這個問題可以通過設定value為當前用戶端產生的一個隨機字串,且保證在足夠長的一段時間內在所有用戶端的所有擷取鎖的請求中都是唯一的。

版本2的完整代碼:Github地址

3.4 版本3-設定鎖的value

抽象類別RedisLock增加lockValue欄位,lockValue欄位的預設值為UUID隨機值假設當前線程ID。

public abstract class RedisLock implements Lock {    //...    protected String lockValue;    public RedisLock(Jedis jedis,String lockKey) {        this(jedis, lockKey, UUID.randomUUID().toString()+Thread.currentThread().getId());    }    public RedisLock(Jedis jedis, String lockKey, String lockValue) {        this.jedis = jedis;        this.lockKey = lockKey;        this.lockValue = lockValue;    }    //...}

加鎖代碼

public void lock() {    while(true){        String result = jedis.set(lockKey, lockValue, NOT_EXIST,SECONDS,30);        if(OK.equals(result)){            System.out.println(Thread.currentThread().getId()+"加鎖成功!");            break;        }    }}

解鎖代碼

public void unlock() {    String lockValue = jedis.get(lockKey);    if (lockValue.equals(lockValue)){        jedis.del(lockKey);    }}

這時看看加鎖代碼,好像沒有什麼問題啊。
再來看看解鎖的代碼,這裡的解鎖操作包含三步操作:擷取值、判斷和刪除鎖。這時你有沒有想到在多線程環境下的i++操作?

3.4.1 i++問題

i++操作也可分為三個步驟:讀i的值,進行i+1,設定i的值。
如果兩個線程同時對i進行i++操作,會出現如下情況

  1. i設定值為0
  2. 線程A讀到i的值為0
  3. 線程B也讀到i的值為0
  4. 線程A執行了+1操作,將結果值1寫入到記憶體
  5. 線程B執行了+1操作,將結果值1寫入到記憶體
  6. 此時i進行了兩次i++操作,但是結果卻為1

在多線程環境下有什麼方式可以避免這類情況發生?
解決方式有很多種,例如用AtomicInteger、CAS、synchronized等等。
這些解決方式的目的都是要確保i++ 操作的原子性。那麼回過頭來看看解鎖,同理我們也是要確保解鎖的原子性。我們可以利用Redis的lua指令碼來實現解鎖操作的原子性。

版本3的完整代碼:Github地址

3.5 版本4-具有原子性的釋放鎖

lua指令碼內容如下

if redis.call("get",KEYS[1]) == ARGV[1] then    return redis.call("del",KEYS[1])else    return 0end

這段Lua指令碼在執行的時候要把的lockValue作為ARGV[1]的值傳進去,把lockKey作為KEYS[1]的值傳進去。現在來看看解鎖的java代碼

public void unlock() {    // 使用lua指令碼進行原子刪除操作    String checkAndDelScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +                                "return redis.call('del', KEYS[1]) " +                                "else " +                                "return 0 " +                                "end";    jedis.eval(checkAndDelScript, 1, lockKey, lockValue);}

好了,解鎖操作也確保了原子性了,那麼是不是單機Redis環境的分布式鎖到此就完成了?
別忘了版本2-設定鎖的到期時間還有一個,到期時間如何保證大於業務執行時間問題沒有解決。

版本4的完整代碼:Github地址

3.6 版本5-確保到期時間大於業務執行時間

抽象類別RedisLock增加一個boolean類型的屬性isOpenExpirationRenewal,用來標識是否開啟定時重新整理到期時間。
在增加一個scheduleExpirationRenewal方法用於開啟重新整理到期時間的線程。

public abstract class RedisLock implements Lock {    //...    protected volatile boolean isOpenExpirationRenewal = true;    /**     * 開啟定時重新整理     */    protected void scheduleExpirationRenewal(){        Thread renewalThread = new Thread(new ExpirationRenewal());        renewalThread.start();    }    /**     * 重新整理key的到期時間     */    private class ExpirationRenewal implements Runnable{        @Override        public void run() {            while (isOpenExpirationRenewal){                System.out.println("執行延遲失效時間中...");                String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +                        "return redis.call('expire',KEYS[1],ARGV[2]) " +                        "else " +                        "return 0 end";                jedis.eval(checkAndExpireScript, 1, lockKey, lockValue, "30");                //休眠10秒                sleepBySencond(10);            }        }    }}

加鎖代碼在擷取鎖成功後將isOpenExpirationRenewal置為true,並且調用scheduleExpirationRenewal方法,開啟重新整理到期時間的線程。

public void lock() {    while (true) {        String result = jedis.set(lockKey, lockValue, NOT_EXIST, SECONDS, 30);        if (OK.equals(result)) {            System.out.println("線程id:"+Thread.currentThread().getId() + "加鎖成功!時間:"+LocalTime.now());            //開啟定時重新整理到期時間            isOpenExpirationRenewal = true;            scheduleExpirationRenewal();            break;        }        System.out.println("線程id:"+Thread.currentThread().getId() + "擷取鎖失敗,休眠10秒!時間:"+LocalTime.now());        //休眠10秒        sleepBySencond(10);    }}

解鎖代碼增加一行代碼,將isOpenExpirationRenewal屬性置為false,停止重新整理到期時間的線程輪詢。

public void unlock() {    //...    isOpenExpirationRenewal = false;}

版本5的完整代碼:Github地址

3.7 測試

測試代碼如下

public void testLockCase5() {    //定義線程池    ThreadPoolExecutor pool = new ThreadPoolExecutor(0, 10,                                                    1, TimeUnit.SECONDS,                                                    new SynchronousQueue<>());    //添加10個線程擷取鎖    for (int i = 0; i < 10; i++) {        pool.submit(() -> {            try {                Jedis jedis = new Jedis("localhost");                LockCase5 lock = new LockCase5(jedis, lockName);                lock.lock();                //類比業務執行15秒                lock.sleepBySencond(15);                lock.unlock();            } catch (Exception e){                e.printStackTrace();            }        });    }    //當線程池中的線程數為0時,退出    while (pool.getPoolSize() != 0) {}}

測試結果

或許到這裡基於單機Redis環境的分布式就介紹完了。但是使用java的同學有沒有發現一個鎖的重要特性

那就是鎖的重入,那麼分布式鎖的重入該如何?呢?這裡就留一個坑了

四、叢集Redis的分布式鎖

在Redis的分布式環境中,Redis 的作者提供了RedLock 的演算法來實現一個分布式鎖。

4.1 加鎖

RedLock演算法加鎖步驟如下

  1. 擷取當前Unix時間,以毫秒為單位。
  2. 依次嘗試從N個執行個體,使用相同的key和隨機值擷取鎖。在步驟2,當向Redis設定鎖時,用戶端應該設定一個網路連接和響應逾時時間,這個逾時時間應該小於鎖的失效時間。例如你的鎖自動失效時間為10秒,則逾時時間應該在5-50毫秒之間。這樣可以避免伺服器端Redis已經掛掉的情況下,用戶端還在死死地等待響應結果。如果伺服器端沒有在規定時間內響應,用戶端應該儘快嘗試另外一個Redis執行個體。
  3. 用戶端使用目前時間減去開始擷取鎖時間(步驟1記錄的時間)就得到擷取鎖使用的時間。若且唯若從大多數(這裡是3個節點)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖才算擷取成功。
  4. 如果取到了鎖,key的真正有效時間等於有效時間減去擷取鎖所使用的時間(步驟3計算的結果)。
  5. 如果因為某些原因,擷取鎖失敗(沒有在至少N/2+1個Redis執行個體取到鎖或者取鎖時間已經超過了有效時間),用戶端應該在所有的Redis執行個體上進行解鎖(即便某些Redis執行個體根本就沒有加鎖成功)。
4.2 解鎖

向所有的Redis執行個體發送釋放鎖命令即可,不用關心之前有沒有從Redis執行個體成功擷取到鎖.

關於RedLock演算法,還有一個小插曲,就是Martin Kleppmann 和 RedLock 作者 antirez的對RedLock演算法的互懟。 官網原話如下

Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here.

更多關於RedLock演算法這裡就不在說明,有興趣的可以到官網閱讀相關文章。

五、總結

這篇文章講述了一個基於Redis的分布式鎖的編寫過程及解決問題的思路,但是本篇文章實現的分布式鎖並不適合用於生產環境。java環境有 Redisson 可用於生產環境,但是分布式鎖還是Zookeeper會比較好一些(可以看Martin Kleppmann 和 RedLock的分析)。

Martin Kleppmann對RedLock的分析:http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

RedLock 作者 antirez的回應:http://antirez.com/news/101

整個項目的地址存放在Github上,有需要的可以看看:Github地址

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.