Redisson 分散式鎖實戰與 watch dog 機制解讀

Soochow 發表於 2021-01-26

Redisson 分散式鎖實戰與 watch dog 機制解讀

背景

Redisson官網的介紹,Redisson是一個Java Redis客戶端,與Spring 提供給我們的 RedisTemplate 工具沒有本質的區別,可以把它看做是一個功能更強大的客戶端(雖然官網上聲稱Redisson不只是一個Java Redis客戶端)

我想我們用到 Redisson 最多的場景一定是分散式鎖,一個基礎的分散式鎖具有三個特性:

  1. 互斥:在分散式高併發的條件下,需要保證,同一時刻只能有一個執行緒獲得鎖,這是最最基本的一點。
  2. 防止死鎖:在分散式高併發的條件下,比如有個執行緒獲得鎖的同時,還沒有來得及去釋放鎖,就因為系統故障或者其它原因使它無法執行釋放鎖的命令,導致其它執行緒都無法獲得鎖,造成死鎖。
  3. 可重入:我們知道ReentrantLock是可重入鎖,那它的特點就是同一個執行緒可以重複拿到同一個資源的鎖。

實現的方案有很多,這裡,就以我們平時在網上常看到的redis分散式鎖方案為例,來對比看看 Redisson 提供的分散式鎖有什麼高階的地方。

普通的 Redis 分散式鎖的缺陷

我們在網上看到的redis分散式鎖的工具方法,大都滿足互斥、防止死鎖的特性,有些工具方法會滿足可重入特性。

如果只滿足上述3種特性會有哪些隱患呢?redis分散式鎖無法自動續期,比如,一個鎖設定了1分鐘超時釋放,如果拿到這個鎖的執行緒在一分鐘內沒有執行完畢,那麼這個鎖就會被其他執行緒拿到,可能會導致嚴重的線上問題,我已經在秒殺系統故障排查文章中,看到好多因為這個缺陷導致的超賣了。

Redisson 提供的分散式鎖

image

Redisson 鎖的加鎖機制如上圖所示,執行緒去獲取鎖,獲取成功則執行lua指令碼,儲存資料到redis資料庫。

如果獲取失敗: 一直通過while迴圈嘗試獲取鎖(可自定義等待時間,超時後返回失敗),獲取成功後,執行lua指令碼,儲存資料到redis資料庫。

Redisson提供的分散式鎖是支援鎖自動續期的,也就是說,如果執行緒仍舊沒有執行完,那麼redisson會自動給redis中的目標key延長超時時間,這在Redisson中稱之為 Watch Dog 機制。

同時 redisson 還有公平鎖、讀寫鎖的實現。

使用樣例如下,附有方法的詳細機制釋義

private void redissonDoc() throws InterruptedException {
    //1. 普通的可重入鎖
    RLock lock = redissonClient.getLock("generalLock");

    // 拿鎖失敗時會不停的重試
    // 具有Watch Dog 自動延期機制 預設續30s 每隔30/3=10 秒續到30s
    lock.lock();

    // 嘗試拿鎖10s後停止重試,返回false
    // 具有Watch Dog 自動延期機制 預設續30s
    boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);

    // 拿鎖失敗時會不停的重試
    // 沒有Watch Dog ,10s後自動釋放
    lock.lock(10, TimeUnit.SECONDS);

    // 嘗試拿鎖100s後停止重試,返回false
    // 沒有Watch Dog ,10s後自動釋放
    boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);

    //2. 公平鎖 保證 Redisson 客戶端執行緒將以其請求的順序獲得鎖
    RLock fairLock = redissonClient.getFairLock("fairLock");

    //3. 讀寫鎖 沒錯與JDK中ReentrantLock的讀寫鎖效果一樣
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");
    readWriteLock.readLock().lock();
    readWriteLock.writeLock().lock();
}

watch dog 的自動延期機制

如果拿到分散式鎖的節點當機,且這個鎖正好處於鎖住的狀態時,會出現鎖死的狀態,為了避免這種情況的發生,鎖都會設定一個過期時間。這樣也存在一個問題,加入一個執行緒拿到了鎖設定了30s超時,在30s後這個執行緒還沒有執行完畢,鎖超時釋放了,就會導致問題,Redisson給出了自己的答案,就是 watch dog 自動延期機制。

Redisson提供了一個監控鎖的看門狗,它的作用是在Redisson例項被關閉前,不斷的延長鎖的有效期,也就是說,如果一個拿到鎖的執行緒一直沒有完成邏輯,那麼看門狗會幫助執行緒不斷的延長鎖超時時間,鎖不會因為超時而被釋放。

預設情況下,看門狗的續期時間是30s,也可以通過修改Config.lockWatchdogTimeout來另行指定。

另外Redisson 還提供了可以指定leaseTime引數的加鎖方法來指定加鎖的時間。超過這個時間後鎖便自動解開了,不會延長鎖的有效期。

watch dog 核心原始碼解讀

// 直接使用lock無引數方法
public void lock() {
    try {
        lock(-1, null, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException();
    }
}

// 進入該方法 其中leaseTime = -1
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return;
    }

   //...
}

// 進入 tryAcquire(-1, leaseTime, unit, threadId)
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}

// 進入 tryAcquireAsync
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    //當leaseTime = -1 時 啟動 watch dog機制
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    //執行完lua指令碼後的回撥
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        if (ttlRemaining == null) {
            // watch dog 
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}

scheduleExpirationRenewal 方法開啟監控:

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();
    }
}

// 進入 renewExpiration()
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    //如果快取不存在,那不再鎖續期
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            //執行lua 進行續期
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }
                
                if (res) {
                    //延期成功,繼續迴圈操作
                    renewExpiration();
                }
            });
        }
        //每隔internalLockLeaseTime/3=10秒檢查一次
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

//lua指令碼 執行包裝好的lua指令碼進行key續期
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return 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.singletonList(getName()),
            internalLockLeaseTime, getLockName(threadId));
}

關鍵結論

上述原始碼讀過來我們可以記住幾個關鍵情報:

  1. watch dog 在當前節點存活時每10s給分散式鎖的key續期 30s;
  2. watch dog 機制啟動,且程式碼中沒有釋放鎖操作時,watch dog 會不斷的給鎖續期;
  3. 從可2得出,如果程式釋放鎖操作時因為異常沒有被執行,那麼鎖無法被釋放,所以釋放鎖操作一定要放到 finally {} 中;

看到3的時候,可能會有人有疑問,如果釋放鎖操作本身異常了,watch dog 還會不停的續期嗎?下面看一下釋放鎖的原始碼,找找答案。

// 鎖釋放
public void unlock() {
    try {
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}

// 進入 unlockAsync(Thread.currentThread().getId()) 方法 入參是當前執行緒的id
public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<Void>();
    //執行lua指令碼 刪除key
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
        // 無論執行lua指令碼是否成功 執行cancelExpirationRenewal(threadId) 方法來
        cancelExpirationRenewal(threadId);

        if (e != null) {
            result.tryFailure(e);
            return;
        }

        if (opStatus == null) {
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
            result.tryFailure(cause);
            return;
        }

        result.trySuccess(null);
    });

    return result;
}

// 此方法會停止 watch dog 機制
void cancelExpirationRenewal(Long threadId) {
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (task == null) {
        return;
    }
    
    if (threadId != null) {
        task.removeThreadId(threadId);
    }

    if (threadId == null || task.hasNoThreads()) {
        Timeout timeout = task.getTimeout();
        if (timeout != null) {
            timeout.cancel();
        }
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
}

釋放鎖的操作中 有一步操作是從 EXPIRATION_RENEWAL_MAP 中獲取 ExpirationEntry 物件,然後將其remove,結合watch dog中的續期前的判斷:

EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
    return;
}

可以得出結論:

如果釋放鎖操作本身異常了,watch dog 還會不停的續期嗎?不會,因為無論釋放鎖操作是否成功,EXPIRATION_RENEWAL_MAP中的目標 ExpirationEntry 物件已經被移除了,watch dog 通過判斷後就不會繼續給鎖續期了。

參考

Redisson實現分散式鎖(1)---原理

Redisson 官方文件

談談基於Redis分散式鎖(下)- Redisson原始碼解析