Redis分散式鎖(二):鎖超時後導致多個執行緒獲得鎖的解決方案

逍遙jc發表於2019-01-21

使用現狀

Redis分佈鎖的基礎內容,我們已經在基於AOP和Redis實現的簡易版分散式鎖這篇文章中講過了,也在文章中示範了正常的加鎖和解鎖方法。

分散式鎖在之前的專案中一直執行良好,沒有辜負我們的期望。

發現問題

但在最近查線上日誌的時候偶然發現,有一個業務場景下,分散式鎖偶爾會失效,導致有多個執行緒同時執行了相同的程式碼。

我們經過初步排查,定位到是因為在這段程式碼中間呼叫了第三方的介面導致。

因為業務程式碼耗時過長,超過了鎖的超時時間,造成鎖自動失效,然後另外一個執行緒意外的持有了鎖。於是就出現了多個執行緒共同持有鎖的現象。

解決方案

問題既然已經出現了,那麼接下來我們就應該考慮解決方案了。

我們也曾經想過,是否可以通過合理地設定LockTime(鎖超時時間)來解決這個問題?

但LockTime的設定原本就很不容易。LockTime設定過小,鎖自動超時的概率就會增加,鎖異常失效的概率也就會增加,而LockTime設定過大,萬一服務出現異常無法正常釋放鎖,那麼出現這種異常鎖的時間也就越長。我們只能通過經驗去配置,一個可以接受的值,基本上是這個服務歷史上的平均耗時再增加一定的buff。

既然這條路走不通了,那麼還有其他路可以走麼?

當然還是有的,我們可以先給鎖設定一個LockTime,然後啟動一個守護執行緒,讓守護執行緒在一段時間後,重新去設定這個鎖的LockTime。

看起來很簡單是不是?

但在實際操作中,我們要注意以下幾點:
1、和釋放鎖的情況一致,我們需要先判斷鎖的物件是否沒有變。否則會造成無論誰持有鎖,守護執行緒都會去重新設定鎖的LockTime。不應該續的不能瞎續。
2、守護執行緒要在合理的時間再去重新設定鎖的LockTime,否則會造成資源的浪費。不能動不動就去續。
3、如果持有鎖的執行緒已經處理完業務了,那麼守護執行緒也應該被銷燬。不能主人都掛了,守護者還在那裡繼續浪費資源。

程式碼實現

我們首先先生成一個內部類去實現Runnable,作為守護執行緒的引數。

public class SurvivalClamProcessor implements Runnable {

    private static final int REDIS_EXPIRE_SUCCESS = 1;

    SurvivalClamProcessor(String field, String key, String value, int lockTime) {
        this.field = field;
        this.key = key;
        this.value = value;
        this.lockTime = lockTime;
        this.signal = Boolean.TRUE;
    }

    private String field;

    private String key;

    private String value;

    private int lockTime;

    //執行緒關閉的標記
    private volatile Boolean signal;

    void stop() {
        this.signal = Boolean.FALSE;
    }

    @Override
    public void run() {
        int waitTime = lockTime * 1000 * 2 / 3;
        while (signal) {
            try {
                Thread.sleep(waitTime);
                if (cacheUtils.expandLockTime(field, key, value, lockTime) == REDIS_EXPIRE_SUCCESS) {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 成功,本次等待{}ms,將重置鎖超時時間重置為{}s,其中field為{},key為{}", waitTime, lockTime, field, key);
                    }
                } else {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 失敗,將導致SurvivalClamConsumer中斷");
                    }
                    this.stop();
                }
            } catch (InterruptedException e) {
                if (logger.isInfoEnabled()) {
                    logger.info("SurvivalClamProcessor 處理執行緒被強制中斷");
                }
            } catch (Exception e) {
                logger.error("SurvivalClamProcessor run error", e);
            }
        }
        if (logger.isInfoEnabled()) {
            logger.info("SurvivalClamProcessor 處理執行緒已停止");
        }
    }
}
複製程式碼

其中expandLockTime是通過Lua指令碼實現的。延長鎖超時的指令碼語句和釋放鎖的Lua指令碼類似。

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1],ARGV[2]) else return '0' end";

複製程式碼

在以上程式碼中,我們將waitTime設定為Math.max(1, lockTime * 2 / 3),即守護執行緒許需要等待waitTime後才可以去重新設定鎖的超時時間,避免了資源的浪費。

同時在expandLockTime時候也去判斷了當前持有鎖的物件是否一致,避免了胡亂重置鎖超時時間的情況。

然後我們在獲得鎖的程式碼之後,新增如下程式碼:

SurvivalClamProcessor survivalClamProcessor 
	= new SurvivalClamProcessor(lockField, lockKey, randomValue, lockTime);
Thread survivalThread = new Thread(survivalClamProcessor);
survivalThread.setDaemon(Boolean.TRUE);
survivalThread.start();
Object returnObject = joinPoint.proceed(args);
survivalClamProcessor.stop();
survivalThread.interrupt();
return returnObject;
複製程式碼

這段程式碼會先初始化守護執行緒的內部引數,然後通過start函式啟動執行緒,最後在業務執行完之後,設定守護執行緒的關閉標記,最後通過interrupt()去中斷sleep狀態,保證執行緒及時銷燬。

後續

本文講解了如何通過啟動一個守護執行緒去重置鎖超時時間,也同時介紹了在實現過程的注意點。隨帶著也科普了一下執行緒銷燬的正確方式。

那麼關於分散式鎖還有下文麼?我也不知道,權當是有吧,可能下一期會講講如何通過其他方式(除Redis之外的)去實現分散式鎖,也可能是講一下Redis分散式鎖的其他問題和解決方案。

好了,我們下一期再見,歡迎大家一起留言討論。同時也歡迎點贊,歡迎送小星星~

Redis分散式鎖(二):鎖超時後導致多個執行緒獲得鎖的解決方案

相關文章