Redis分散式鎖這樣用,有坑?

程式設計師Forlan發表於2023-04-16

背景

在微服務專案中,大家都會去使用到分散式鎖,一般也是使用Redis去實現,使用RedisTemplate、Redisson、RedisLockRegistry都行,公司的專案中,使用的是Redisson,一般你會怎麼用?看看下面的程式碼,是不是就是你的寫法

String lockKey = "forlan_lock_" + serviceId;
RLock lock = redissonClient.getLock(lockKey);

// 方式1
try {
	lock.lock(5, TimeUnit.SECONDS);
	// 執行業務
	...
} catch (Exception e) {
	e.printStackTrace();
} finally {
	// 釋放鎖
	lock.unlock();
}

// 方式2
try {
	if (lock.tryLock(5, 5, TimeUnit.SECONDS)) {
		// 獲得鎖執行業務
		...
	}
} catch (Exception e) {
	e.printStackTrace();
} finally {
	// 釋放鎖
	lock.unlock();
}

分析

像上面的寫法,符合我們的常規思維,一般,為了避免程式掛了的情況,沒有釋放鎖,都會設定一個過期時間
但這個過期時間,一般設定多長?

設定過短,會導致我們的業務還沒有執行完,鎖就釋放了,其它執行緒拿到鎖,重複執行業務
設定過長,如果程式掛了,需要等待比較長的時間,鎖才釋放,佔用資源

這時候,你會說,一般我們可以根據業務執行情況,設定個過期時間即可,對於部分執行久的業務,Redisson內部是有個看門狗機制,會幫我們去續期,簡單來說,就是有個定時器,會去看我們的業務執行完沒,沒有就幫我們進行延時,看似沒有問題吧,那我們來簡單看下原始碼,無論我們使用哪種方式,最終都會進到這個方法,就是看門狗機制的核心程式碼

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1L) {
        // 前面我們指定了過期時間,會進到這裡,直接加鎖
        return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 沒有指定過期時間的話,預設採用LockWatchdogTimeout,預設是30s
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        // ttlRemainingFuture執行完,新增一個監聽器,類似netty的時間輪
        ttlRemainingFuture.addListener(new FutureListener<Long>() {
            public void operationComplete(Future<Long> future) throws Exception {
                if (future.isSuccess()) {
                    Long ttlRemaining = (Long)future.getNow();
                    if (ttlRemaining == null) {
                        RedissonLock.this.scheduleExpirationRenewal(threadId);
                    }
                }
            }
        });
        return ttlRemainingFuture;
    }

scheduleExpirationRenewal方法

private void scheduleExpirationRenewal(final long threadId) {
 if (!expirationRenewalMap.containsKey(this.getEntryName())) {
     Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
         public void run(Timeout timeout) throws Exception {
         	 // renewExpirationAsync就是執行續期的方法
             RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
             // 什麼時候觸發執行?
             future.addListener(new FutureListener<Boolean>() {
                 public void operationComplete(Future<Boolean> future) throws Exception {
                     RedissonLock.expirationRenewalMap.remove(RedissonLock.this.getEntryName());
                     if (!future.isSuccess()) {
                         RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", future.cause());
                     } else {
                         if ((Boolean)future.getNow()) {
                             RedissonLock.this.scheduleExpirationRenewal(threadId);
                         }

                     }
                 }
             });
         }
     }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // 當跑了LockWatchdogTimeout的1/3時間就會去執行續期
     if (expirationRenewalMap.putIfAbsent(this.getEntryName(), new RedissonLock.ExpirationEntry(threadId, task)) != null) {
         task.cancel();
     }
 }

所以,結論是啥?

// 方式1
lock.lock(5, TimeUnit.SECONDS);
// 方式2
lock.tryLock(5, 5, TimeUnit.SECONDS)

我們這兩種寫法都會導致看門狗機制失效,如果業務執行超過5s,就會出問題

解決

正確的寫法應該是,不指定過期時間

// 方式1
lock.lock();
// 方式2
lock.tryLock(5, -1, TimeUnit.SECONDS)

你可以會覺得不妥,不指定的話,就預設按照30s續期時間,然後每10s去看看有沒有執行完,沒有就續期,
我們也可以指定續期時間,比如指定為15s

config.setLockWatchdogTimeout(15000L);

總結

  • 在使用Redisson實現分散式鎖,不應該設定過期時間
  • 看門狗預設續期時間是30s,可以透過setLockWatchdogTimeout指定
  • 看門狗會每internalLockLeaseTime / 3L去續期
  • 看門狗底層實際就是類似Netty的時間輪

相關文章