Redis 分佈式鎖如何自動續期

Redis 實現分佈式鎖
指定一個 key 作爲鎖標記,存入 Redis 中,指定一個 唯一的用戶標識作爲 value。

當 key 不存在時才能設置值,確保同一時間只有一個客戶端進程獲得鎖,滿足互斥性特性。

設置一個過期時間,防止因系統異常導致沒能刪除這個 key,滿足防死鎖特性。

當處理完業務之後需要清除這個 key 來釋放鎖,清除 key 時需要校驗 value 值,需要滿足只有加鎖的人才能釋放鎖 。

問題

如果這個鎖的過期時間是30秒,但是業務運行超過了30秒,比如40秒,當業務運行到30秒的時候,鎖過期了,其他客戶端拿到了這個鎖,怎麼辦

我們可以設置一個合理的過期時間,讓業務能夠在這個時間內完成業務邏輯,但LockTime的設置原本就很不容易。

LockTime設置過小,鎖自動超時的概率就會增加,鎖異常失效的概率也就會增加;

LockTime設置過大,萬一服務出現異常無法正常釋放鎖,那麼出現這種異常鎖的時間也就越長。

我們只能通過經驗去配置,一個可以接受的值,基本上是這個服務歷史上的平均耗時再增加一定的buff。總體來說,設置一個合理的過期時間並不容易

我們也可以不設置過期時間,讓業務運行結束後解鎖,但是如果客戶端出現了異常結束了或宕機了,那麼這個鎖就無法解鎖,變成死鎖;

自動續期

我們可以先給鎖設置一個LockTime,然後啓動一個守護線程,讓守護線程在一段時間後,重新去設置這個鎖的LockTime。

看起來很簡單,但實現起來並不容易

和釋放鎖的情況一樣,我們需要先判斷持有鎖客戶端是否有變化。否則會造成無論誰持有鎖,守護線程都會去重新設置鎖的LockTime。

守護線程要在合理的時間再去重新設置鎖的LockTime,否則會造成資源的浪費。不能動不動就去續。

如果持有鎖的線程已經處理完業務了,那麼守護線程也應該被銷燬。不能業務運行結束了,守護者還在那裏繼續運行,浪費資源。

看門狗

Redisson的看門狗機制就是這種機制實現自動續期的

Redissson tryLock
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        // 1.嘗試獲取鎖
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return true;
        }

        // 申請鎖的耗時如果大於等於最大等待時間,則申請鎖失敗.
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }

        current = System.currentTimeMillis();

        /**
         * 2.訂閱鎖釋放事件,並通過 await 方法阻塞等待鎖釋放,有效的解決了無效的鎖申請浪費資源的問題:
         * 基於信息量,當鎖被其它資源佔用時,當前線程通過 Redis 的 channel 訂閱鎖的釋放事件,一旦鎖釋放會發消息通知待等待的線程進行競爭.
         *
         * 當 this.await 返回 false,說明等待時間已經超出獲取鎖最大等待時間,取消訂閱並返回獲取鎖失敗.
         * 當 this.await 返回 true,進入循環嘗試獲取鎖.
         */
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        // await 方法內部是用 CountDownLatch 來實現阻塞,獲取 subscribe 異步執行的結果(應用了 Netty 的 Future)
        if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            acquireFailed(threadId);
            return false;
        }

        try {
            // 計算獲取鎖的總耗時,如果大於等於最大等待時間,則獲取鎖失敗.
            time -= System.currentTimeMillis() - current;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;

              }

            /**
             * 3.收到鎖釋放的信號後,在最大等待時間之內,循環一次接着一次的嘗試獲取鎖
             * 獲取鎖成功,則立馬返回 true,
             * 若在最大等待時間之內還沒獲取到鎖,則認爲獲取鎖失敗,返回 false 結束循環
             */
            while (true) {
                long currentTime = System.currentTimeMillis();

                // 再次嘗試獲取鎖
                ttl = tryAcquire(leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    return true;
                }
                // 超過最大等待時間則返回 false 結束循環,獲取鎖失敗
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }

                /**
                 * 6.阻塞等待鎖(通過信號量(共享鎖)阻塞,等待解鎖消息):
                 */
                currentTime = System.currentTimeMillis();
                if (ttl >= 0 && ttl < time) {
                    //如果剩餘時間(ttl)小於wait time ,就在 ttl 時間內,從Entry的信號量獲取一個許可(除非被中斷或者一直沒有可用的許可)。
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    //則就在wait time 時間範圍內等待可以通過信號量
                    getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                // 更新剩餘的等待時間(最大等待時間-已經消耗的阻塞時間)
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }
            }
        } finally {
            // 7.無論是否獲得鎖,都要取消訂閱解鎖消息
            unsubscribe(subscribeFuture, threadId);
        }
        return get(tryLockAsync(waitTime, leaseTime, unit));
    }

嘗試獲取鎖,返回 null 則說明加鎖成功,返回一個數值,則說明已經存在該鎖,ttl 爲鎖的剩餘存活時間。

如果此時客戶端 2 進程獲取鎖失敗,那麼使用客戶端 2 的線程 id(其實本質上就是進程 id)通過 Redis 的 channel 訂閱鎖釋放的事件。如果等待的過程中一直未等到鎖的釋放事件通知,當超過最大等待時間則獲取鎖失敗,返回 false,也就是第 39 行代碼。如果等到了鎖的釋放事件的通知,則開始進入一個不斷重試獲取鎖的循環。

循環中每次都先試着獲取鎖,並得到已存在的鎖的剩餘存活時間。如果在重試中拿到了鎖,則直接返回。如果鎖當前還是被佔用的,那麼等待釋放鎖的消息,具體實現使用了信號量 Semaphore 來阻塞線程,當鎖釋放併發布釋放鎖的消息後,信號量的 release() 方法會被調用,此時被信號量阻塞的等待隊列中的一個線程就可以繼續嘗試獲取鎖了。

當鎖正在被佔用時,等待獲取鎖的進程並不是通過一個 while(true) 死循環去獲取鎖,而是利用了 Redis 的發佈訂閱機制,通過 await 方法阻塞等待鎖的進程,有效的解決了無效的鎖申請浪費資源的問題。

看門狗如何自動續期

Redisson看門狗機制, 只要客戶端加鎖成功,就會啓動一個 Watch Dog。

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}

leaseTime 必須是 -1 纔會開啓 Watch Dog 機制,如果需要開啓 Watch Dog 機制就必須使用默認的加鎖時間爲 30s。
如果你自己自定義時間,超過這個時間,鎖就會自定釋放,並不會自動續期。

續期原理

續期原理其實就是用lua腳本,將鎖的時間重置爲30s

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        renewExpiration();
    }
}

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return 1; " +
            "end; " +
            "return 0;",
        Collections.<Object>singletonList(getName()),
        internalLockLeaseTime, getLockName(threadId));
}

Watch Dog 機制其實就是一個後臺定時任務線程,獲取鎖成功之後,會將持有鎖的線程放入到一個 RedissonLock.EXPIRATION_RENEWAL_MAP裏面,然後每隔 10 秒 (internalLockLeaseTime / 3) 檢查一下,如果客戶端 還持有鎖 key(判斷客戶端是否還持有 key,其實就是遍歷 EXPIRATION_RENEWAL_MAP 裏面線程 id 然後根據線程 id 去 Redis 中查,如果存在就會延長 key 的時間),那麼就會不斷的延長鎖 key 的生存時間。

如果服務宕機了,Watch Dog 機制線程也就沒有了,此時就不會延長 key 的過期時間,到了 30s 之後就會自動過期了,其他線程就可以獲取到鎖。

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