【Redis筆記】一起學習Redis | 如何利用Redis實現一個分佈式鎖?

一起學習Redis | 如何利用Redis實現一個分佈式鎖?


  • 前提知識
    • 什麼是分佈式鎖?
    • 爲什麼需要分佈式鎖?
    • 分佈式鎖的5要素和三種實現方式
  • 實現分佈式鎖
    • 思考思考
    • 基礎方案
    • 改進方案
    • 保證setnx和expire的複合操作的原子性
  • 依然可能存在的不足

前提知識


什麼是分佈式鎖?

分佈式鎖簡而言之就是集羣環境下,全局意義上的鎖,會對集羣上的所有節點服務都造成關聯和影響

爲什麼需要分佈式鎖?

我們都知道,當有場景需要解決代碼的併發安全問題的時候,我們都會給相應的代碼端或方法加鎖,無論你是使用Synchronized內置鎖還是顯示鎖Reentrantlock等其他方式實現;但是這種加鎖的方式僅僅侷限於單機的情況下,在服務集羣的環境下,可能前面的加鎖方式就顯得有心無力了

比如說我有一個服務A,它由3個集羣節點組成,在服務A的某個方法上需要順序串行執行,所以在這個方式加了鎖;當上遊服務來了幾千個併發請求同時要求執行服務A的這個方法,此時微服務的負載均衡會將這幾千個請求分散到這個服務A3個集羣節點中,雖然我們加了鎖,但同一時刻,因爲集羣環境的關係,至少有3個線程在執行這個加了鎖的方法,從而造成一定的線程安全點。

因爲我們加的鎖僅僅是一個單機鎖,而非去全局下的一個鎖,那麼要實現一個全局意義上的鎖,我們應該怎麼實現呢? 首先這在JDK本身的代碼中,肯定是沒有現成的實現的。

分佈式鎖的5要素和三種實現方式

分佈式鎖的五點要素:

  • 保證在分佈式集羣環境中,同一個方法在同一時間只能被其中的一個節點的一個線程執行
  • 爲避免死鎖,這把鎖要是一把可重入鎖
  • 這個鎖最好是一把阻塞鎖,如果有需要,要讓其他沒獲得鎖的線程阻塞住
  • 有高可用的獲取鎖和釋放鎖功能,即要有一定的可靠性
  • 獲取鎖和釋放鎖的性能要好

以上五點非常的重要,每當我們要設計一個分佈式鎖方案的時候,我們就要認真的考慮我們的方案是否滿足這5點,當然如果你的需求不需要滿足這麼多要求,也可以不滿足

分佈式鎖一般有三種實現方式:

  1. 數據庫鎖
  2. 基於Redis的分佈式鎖
  3. 基於ZooKeeper的分佈式鎖

在我查到的中文資料中,大部分常見的分佈式鎖方案都是通過這三種途徑去實現的,比如利用數據庫的主鍵唯一索引特性,有數據庫記錄代表有鎖,釋放鎖則刪除該記錄;又或者利用數據庫的悲觀鎖機制等待;但是通常情況下數據庫的方式運用的比較少,畢竟首先就存在性能問題,所以這個可以在小規模的場景玩一下

從理解的難易程度角度(從低到高): 數據庫 > 緩存 > Zookeeper
從實現的複雜性角度(從低到高): Zookeeper >= 緩存 > 數據庫
從性能角度(從高到低): 緩存 > Zookeeper >= 數據庫
從可靠性角度(從高到低): Zookeeper > 緩存 > 數據庫

本博在生產環境中呢,暫且只使用過Redis的方案,所以也主要介紹的是如果通過Redis去實現一個分佈式鎖,所以呢,其他方式就不多說啦


實現一個分佈式鎖


思考思考

我先聲明一下,這裏要設計的分佈式鎖是符合前面說道的五要素的,即儘量的保證在功能上接近原生的鎖(Synchronized),所以我們必須保證同一時刻只有一個節點的一個線程獲的鎖,是可重入鎖,可以實現阻塞,有一定可靠性,獲得鎖和釋放鎖的性能高

首先,Redis可以做集羣多節點,肯定是可以保證高可用的,同時Redis本身就是做緩存的,也可以保證鎖的性能,所以這兩點是Redis天然可以保障的;其他的三要素則是要通過代碼層面的邏輯去實現的,那我們怎麼去實現呢?

  • 同一時間只有一個節點的線程獲得鎖
  • 可阻塞
  • 可重入
一、 首先搞定同一時間只有一個節點獲得鎖

這一步必然不可少的就是Redis的setnx命令, 該命令的好處是有返回值,我們可以知道該命令到底有沒有插入數據成功;即當key不存在的時候,set入Value, 存在則set失敗;所以setnx的特點就是有返回值,成功插入返回1,已存在則返回0;業務意義就是返回1則代表獲取到鎖,Redis還沒有其他線程獲取到這鎖,返回0 則代表已經有其他線程獲取到鎖了

我們的鎖在Redis中存儲結構通常是key-value結構 ,key是固定的,value可以是一個全局唯一值 ,可以隨機數生成,在分佈式集羣環境建議使用全局唯一Id生成器生成,比如Snowflake算法

爲了防止因爲某個線程長時間不釋放鎖到導致死鎖,我們還要給鎖一個超時時間,可以通過expire(key , timeout)命令去實現

  • 第一步: setnx(key , 全局唯一值) , 超時時間比如我們定義5s ,即5000ms; 返回1則成功獲取鎖,返回0則不成功要重試
  • 第二步: expire(key , 5000) , 給鎖一個超時時間,即使線程超時不釋放鎖,Redis也會根據key是否過期自動釋放
  • 第三步: 因爲已經獲取了鎖,所以就可以執行真正的業務
  • 第四步: del(key) , 業務執行成功,釋放鎖

行,同一時間只有一個節點獲得鎖的問題解決了,那我們就來解決一下可重入性的問題

二、 然後我們要解決鎖的可重入性

這個可以有多種實現方式,但是我個人覺得通過ThreadLocal的方式最簡單。 當我的線程在某個加鎖方法中獲取到了鎖,我就給該線程的ThreadLocal設置一個flag ,比如true。但我在這個加鎖方法裏又調用了另一個加鎖方法,我可以在這個線程的ThreadLocal中獲得flag看看是否已經獲得過鎖,如果獲的過,那我就可以什麼都不做,繼續執行。如果沒有獲得過,我就要再次重新獲得鎖

三、 然後我們要解決鎖的可阻塞性

什麼叫可阻塞性呢?意思就是當某個節點的某個線程獲得了鎖,其餘的所有線程在我釋放鎖前必須阻塞起來。 其實這個就看你怎麼設計了,有的場景不一定需要阻塞,比如說Spring Scheduled + 分佈式鎖的任務調度場景,因爲我只是想隔多久執行一個定時任務,但我不需要每個節點都啓動一起,所以就拿分佈式鎖來保證在某個時間點上只需要一個節點來執行定時任務即可,獲得鎖的就執行定時任務,沒有獲得鎖的就直接結束任務即可

當然有的場景,分佈式鎖還需要完全的模擬出鎖的特性,即阻塞其他線程;此時我們只需要讓沒有獲得鎖的其他線程while循環獲取鎖,如果覺得頻繁,可以給一個隨機的休眠時間再重試(但是不要太長,毫秒級即可);如果你覺得不能無限重試,就可以額定一個重試次數限制


基礎的方案

行,上面我們解決了鎖的可重入性可阻塞性同時間只有一個線程獲得鎖的特性,再加上Redis天然的可以通過集羣的方式支持高可用性以及本身緩存就具有非常高的性能, 這樣我們就毫無壓力的把5大要素就實現了;

嗯嗯,非常完美~ 看起來毫無違和感,的確是發現不出有什麼毛病

  • 首先判斷是否已經獲得過鎖,如果沒有則生成Value,執行setnx命令嘗試獲取鎖
  • 如果成功獲得鎖,則給鎖一個過期時間,同時開始執行正在業務,結束後釋放鎖,del(key)
  • 如果沒有成功獲得鎖,則while循環重試,可以給一個阻塞時間,時隔多久重試一次,也可以限制重試次數
缺陷:

但是,有時候,只要存在多方服務通過網絡進行通訊,就避免不了出現一些網絡故障的意外或程序意外

  • 比如當某個線程的setnx命令與redis服務端交互成功後,既成功獲取鎖後;準備執行expire命令時,網絡中斷了,或者程序崩潰了,從而導致某個線程獲取了鎖,但沒有給該鎖設置一個過期時間。當網絡恢復時,或程序重新上線時,就會發現其他線程一直被阻塞,一直無法獲的鎖,從而導致出現死鎖問題

這個要怎麼解決呢?所以我們來看一下改進後的方案


通過補償機制去改進方案

當出現了setnx和expire之間的程序或網絡中斷之後,我們要怎麼通過一個補償機制去抵消可能出現的死鎖呢?

問題:

首先根本問題是Redis中可能存在沒有過期時間的鎖,那麼我們要解決的就是怎麼讓這個鎖有過期時間,既然expire的方式不能完全保證過期時間的設置,那我們想想能不能在其他方式上實現這個過期時間呢?

當然是有的,我們之前談到,在Redis中存儲結構通常是key-value結構 ,key是固定的,value是一個全局唯一值 ;但在這裏我們要將value改進一下,不再使用一個沒有業務含義的唯一值做爲Value。所以我們這裏建議的Value 是當前時間戳 + 超時時間, 如下

key value 備註
your_key 時間戳 + 超時時間 數值相加
my_lock_example 142435559000 時間戳142435554000ms,超時時間是5000ms
那麼我們解決方案就是:

將鎖的Value由全局隨機唯一值,改爲由當前時間戳 + 超時時間的格式 , 這個Value的業務實際意義就是鎖的超時時的時間戳,既記錄着該鎖超時時的時間點

既然我們無法保證Redis去讓它過期,那我們就讓線程嘗試獲取鎖失敗的時候,再去取鎖的Value,既鎖的超時實際時間,用當前時間的時間戳和Value的時間戳去比較,看當前時間是否大於鎖的超時時刻的時間,如果大於則代表該鎖實際已經超時了,可以被其他線程重新獲取,如果小於,則進入重試獲取鎖的階段

這樣人爲的增加一次鎖實際是否超時的判斷,就可以彌補Redis設置過期時間失效的情況。當然相應的缺點就是多了一步網絡請求操作,但也是值得的

另外通過這種方式去實現,你還要去解決一個場景,就是多個線程都獲取鎖失敗了,都進入了get(value)與當前時間判斷的代碼段,如果他們都發現鎖已超時,都想去替換鎖的時候,你就要去想如何避免多個線程都獲取到鎖的問題了,當然最簡單的方式就是給這個塊代碼段加鎖,問題是單機鎖無法實現集羣的環境的鎖,若又引入一個分佈式鎖,那更是讓情況複雜化;所以這裏通過會用getset命令去實現一個全局意義上的CAS操作,具體可以看下面的流程圖

完整改進型方案的流程圖:

當然畫的不好請見諒,畢竟原本就是畫給自己看看…

其實也不復雜,相比上一次的方案流程圖,也就是多了線程獲取鎖失敗後要增加的部分操作罷了

  • 如果獲取鎖失敗,則獲取鎖的Value,既鎖的實際超時時間;用當前時間與鎖超時時間比較
  • 如果當前時間大於鎖的超時時間或鎖的Value爲null(代表該key被其他線程刪除了),則代表該鎖可以被替換了
  • 但是爲了解決併發情況下,多個線程都獲取到鎖的情況,所以我們要再做一個判斷。既用新鎖替換舊鎖的同時獲得舊鎖的Value, 拿這個Value與上一步獲得的Value進行比較,看是否一致,如果一致則代表,則是沒有其他線程在跟自己競爭,則可以正式的獲得鎖;如果不一致,則代表我替換舊鎖的時候,也有其他線程也替換了舊鎖。其實本質上就是一個CAS操作

這種方式的實現,使用Spring Data Redis就可以很好的實現了,用其他的客戶端方式當然也行

這裏提供一個Java代碼的實現, 基於Spring Data Redis實現

@Component
public class RemoteLock {

    private static final String LOCK_KEY_PREFIX = "LOCK-";

    private static ThreadLocal<AtomicInteger> threadLocal = new ThreadLocal<>();

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static String createLockValue() {
        return String.valueOf(System.currentTimeMillis()) + "@" + RandomStringUtils.randomNumeric(6);
    }

    /**
     * 獲取鎖
     *
     * @param key
     * @param timeOutMillis
     * @param tryCount
     * @param tryIntervalMillis
     * @return boolean
     */
    public boolean tryLock(String key, long timeOutMillis, int tryCount, long tryIntervalMillis) {
        String lockKey = LOCK_KEY_PREFIX + key;
        //如果已經獲取過鎖,則不需再獲取
        if (threadLocal.get() != null) {
            //計算鎖的深度
            threadLocal.get().incrementAndGet();
            return true;
        }
        for (int i = 0; i < tryCount; i++) {
            if (redisTemplate.opsForValue().setIfAbsent(lockKey, createLockValue())) {
                redisTemplate.expire(lockKey, timeOutMillis, TimeUnit.MILLISECONDS);
                return true;
            }
            if (tryCount > 1) {
                try {
                    Thread.sleep(tryIntervalMillis);
                } catch (InterruptedException e) {
                }
            }
        }
        // 防止死鎖處理
        String lockValue = (String) redisTemplate.opsForValue().get(lockKey);
        if (lockValue != null) {
            long lockTime = Long.parseLong(lockValue.substring(0, lockValue.indexOf("@")));
            if (System.currentTimeMillis() - lockTime > timeOutMillis) {
                //如果併發設置的時候,判斷哪個是鎖的真正獲得者
                String oldValue = (String) redisTemplate.opsForValue().getAndSet(lockKey, createLockValue());
                if (lockValue.equals(oldValue)) {
                    redisTemplate.expire(lockKey, timeOutMillis, TimeUnit.MILLISECONDS);
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 釋放鎖
     *
     * @param key
     */
    public void releaseLock(String key) {
        redisTemplate.delete(LOCK_KEY_PREFIX + key);
        threadLocal.remove();
    }

}

保證setnx和expire的複合操作的原子性

因爲我們我使用的Redis客戶端是Spring Data Redis | RedisTemplate ,同時因爲Spring的RedisTemplate的API中不支持Redis的原生命令set多參數版本(Jedis貌似有API支持的);所以我才需要使用上面的方案;如果你使用了支持set多參數命令行的API,可以保證了setnx和expire兩個動作的原子性,你就不需要上面的設計方案了,而是可以採用一種更加簡單的設計
set key value [EX seconds] [PX milliseconds] [NX|XX]

說白了,就是可以省去CAS判斷鎖是否實際過期的情況,因爲保證了setnx和expire兩個動作的原子性,只要生成鎖,那麼這把鎖就肯定就有過期。因爲就可以省下很多的操作,這是一個種比較簡單舒服的方式,不需要考慮這麼多,同時鎖的value也不需要是時間戳+過期時間了,可以是任意不重複的隨機數即可

@Component
public class RemoteLock {

    private final static String KEY = "Redis:Lock:";

    @Autowired
    JedisPool jedisPool;

    /**
     * 加鎖
     *
     * @param key
     * @param ttl
     * @return
     */
    public boolean tryLock(String key, Long ttl) {
        Jedis jedis = jedisPool.getResource();
        String val = System.currentTimeMillis() + "";
        String result = jedis.set(KEY + key, val, "NX", "EX", ttl);
        return "OK".equals(result);
    }


    /**
     * 釋放鎖
     *
     * @param key
     * @return
     */
    public boolean releaseLock(String key) {
        Jedis jedis = jedisPool.getResource();
        return jedis.del(KEY + key) > 0;

    }
}

當然,有時候我們的api不支持一些原子組合命令操作的時候,也可以通過Redis的事務或者Lua腳本來封裝多個原子操作,因爲一個Lua腳本在Redis中就是一個完整的整體,也是具有原子性的。


相關問題


依然可能存在的不足

上面我們說了兩種實現方案,其實也可以說是一種實現方案,也是常見通用的一些實現方案。雖然這兩個方案非常的簡單,看上去也挺好用,感覺完美無疵。但是事實上還是有一些缺點的。

  • 那就是Redis集羣環境下可能會導致出現故障
    例如我們想實現Redis的高可用,就會讓Redis做集羣,主從複製,保證Redis分佈式鎖的可靠性;但實際上這樣也會出現一些風險,雖然概率很低。那就是Redis的持久化是異步的,且Redis的主從複製也是異步的,當Redis的主節點崩潰了,Redis恢復數據或數據複製的機制是不能完全保證數據完整性,可能會存在部分少數數據的丟失。如果我們把已經某個線程獲取到鎖的數據丟失了,當Redis再次就緒,就會讓多個線程同時獲得鎖的情況存在,所以會專門算法的策略去實現Redis集羣環境的分佈式鎖,比如Redis作者自己實現的RedLock算法,當然我們也可以直接使用一些開源的方案Redission, 當然如果對於這把鎖要求沒這麼高的情況下,我們在犧牲高可用性的情況下,也可以採用單機Redis去實現分佈式鎖

  • 如果獲得鎖的線程執行時間大於鎖的有效期時間呢?
    這就分兩種情況了,是讓該線程繼續執行下去,鎖失效了就失效了,允許其他線程重寫獲得鎖,這也是常見的策略,但是這樣就無法真正做到同一時間只有一個線程擁有執行任務的權利,不過也不是所有場景都需要這麼嚴格的線程管控;是通過一種監聽機制,在發現該鍵要過期的時候,自動續約,延長時間,可以加入續約次數的管控,續超過多少次,就不會再續了。同時要判斷是線程還在執行過程,所以要續約,還是程序蹦了,沒有釋放鎖的情況。如果是程序崩了的情況,就不應該再爲其續約了。


可重入性的其他方案

別的可重入性方案

  • 不一定要通過ThreadLocal去實現,也可以在鎖的value上做手腳,加上一些當前ip + 端口 + 線程號的方式標識這把鎖正被誰獲取了,下個線程嘗試獲取鎖,可以判斷自己之間是否已經獲取過鎖就可以了;當然還有其他很多的實現方案

最好不使用可重入性

  • 當然,我們這裏所說的可重入性,都是在比較簡答的層面上去訴說的。再嚴緊一些,我們還需要考慮重入的鎖的過期問題,這會讓代碼變的非常複雜。
  • 所以最好的解決方式就是不要再業務中嘗試分佈式鎖的重入性!!

鎖衝突的三種解決策略

雖然我們說分佈式鎖有五大要素,但是實際運用場景中,我們的分佈式鎖並非是要把所有的要素都準備齊,既不一定需要達到Synchronized這麼嚴苛的鎖效果。所以有時候的分佈式鎖鎖衝突的情況,有時候也不一定需要阻塞能力。總之,當分佈式鎖衝突時,我們可以考慮以下三種策略

  • 直接拋出異常,通知用戶,放棄任務
  • while循環,sleep一段時間,重試獲取鎖
  • 將請求轉移到隊消息隊列,過一會再嘗試

有的場景比如說分佈式任務,只需要一個結點執行任務即可,那麼沒有獲取到鎖的結點直接拋異常,打印日誌即可。有的時候,你可能需要阻塞其他線程,讓線程不斷的重試,直到獲取成功,可以嘗試第二種while + sleep的方式,但是有可能會長時阻塞線程。所以還可以考慮把沒有獲取到鎖的請求,轉移到消息隊列,相當於延遲了一段時間,再執行,這樣相比sleep,性能會更好。


Redis分佈式鎖的使用場景分析



參考資料


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章