【Redisson】分散式鎖的原始碼解析

kamier發表於2023-04-07

一、前言

分散式鎖在實際工作中的應用還是比較多的,其實現方式也有很多種,常見的有基於資料庫鎖、基於zookeeper、基於redis的,今天我們來講下基於redis實現的分散式鎖。

redisson是一個redis客戶端框架,提供了分散式鎖的功能特性,這裡我們透過解析redisson的原始碼來分析它是如何基於redis來實現分散式鎖的?

二、原始碼解析

2.1 樣例程式碼

下面是一個分散式鎖的簡單樣例程式碼

// 初始化配置,建立Redisson客戶端
Config config = new Config();
config.setCodec(new JsonJacksonCodec())
        .useSingleServer()
        .setAddress("redis://192.168.10.131:6379");
RedissonClient client = Redisson.create(config);

// 獲取分散式鎖
RLock lock = client.getLock("myLock");
lock.lock();
System.out.println(Thread.currentThread().getId() + ": 獲取到分散式鎖");
try {
    Thread.sleep(60 * 1000);
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 解鎖
    lock.unlock();
}

上面的樣例程式碼比較簡單,透過redisson客戶端獲取一個分散式鎖,該分散式鎖的key為myLock,睡眠60秒之後釋放鎖。這裡比較重要的是lock()方法,該方法是獲取鎖的具體步驟,所以接下來詳細解析一下該方法

2.2 整體流程

獲取鎖的流程圖如下

具體流程為:

  • 第一次嘗試獲取鎖,如果獲取到鎖,直接返回。如果未獲取到鎖,返回鎖的剩餘過期時間ttl
  • 當未獲取到鎖時,訂閱頻道redisson_lock__channel:{myLock}(訂閱該頻道的作用是,當該分散式鎖被其他擁有者所釋放時,會往該訂閱頻道傳送一個解鎖訊息UNLOCK_MESSAGE,這時當前等待該分散式鎖的執行緒會中斷等待,並再次嘗試獲取鎖
  • 開啟死迴圈,嘗試獲取鎖,如果未獲取到鎖,拿到鎖的剩餘過期時間,並等待該鎖的剩餘過期時間(中間過程中如果訂閱頻道有解鎖訊息UNLOCK_MESSAGE,會提前中斷等待,繼續迴圈),直到獲取鎖,退出迴圈
  • 獲取到鎖之後,取消訂閱頻道

原始碼如下

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        // 1、第一次嘗試獲取鎖,ttl為null,表示獲取到鎖,直接return
        Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
        if (ttl == null) {
            return;
        }

        // 2、訂閱頻道redisson_lock__channel:{myLock}
        CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
        pubSub.timeout(future);
        RedissonLockEntry entry;
        if (interruptibly) {
            entry = commandExecutor.getInterrupted(future);
        } else {
            entry = commandExecutor.get(future);
        }

        try {
            // 3、開啟迴圈
            while (true) {
                // 再次嘗試獲取鎖,ttl為null,表示獲取到鎖,退出迴圈
                ttl = tryAcquire(-1, leaseTime, unit, threadId);
                if (ttl == null) {
                    break;
                }

                // 如果ttl大於等於0
                if (ttl >= 0) {
                    try {
                        // 等待ttl時間 或者 接收到解鎖訊息
                        entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        ...
                    }
                } else {    // 如果ttl小於0,說明該鎖未設定過期時間,等待接收解鎖訊息
                    if (interruptibly) {
                        entry.getLatch().acquire();
                    } else {
                        entry.getLatch().acquireUninterruptibly();
                    }
                }
            }
        } finally {
            // 退出訂閱頻道redisson_lock__channel:{myLock}
            unsubscribe(entry, threadId);
        }
    }

2.3 鎖的獲取

那麼如何表示當前執行緒獲取到鎖

redisson中的分散式鎖實質上是個hash結構的資料,假設鎖的名稱為myLock,那麼當某個執行緒獲取到鎖之後,會在這個hash結構裡設定一個hashkey,其為 【連線管理器id】 : 【執行緒id】,如下圖

redisson透過執行lua指令碼來獲取鎖,lua指令碼如下

// 如果鎖不存在,則成功獲取到鎖,設定鎖的過期時間,並返回nil
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
// 如果鎖已存在,判斷是否是當前執行緒已經獲取到,如果是,對應的值加1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
// 否則表示未獲取到鎖,返回鎖的過期時間
return redis.call('pttl', KEYS[1]);

該lua指令碼的主要作用是

  1. 如果鎖不存在,則成功獲取到鎖,設定鎖的過期時間(lockWatchdogTimeout預設是30秒),並返回nil
  2. 如果鎖已存在,判斷是否是當前執行緒已經獲取到,如果是,對應的值加1
  3. 否則表示未獲取到鎖,返回鎖的過期時間

這裡的第一步為什麼要設定鎖的過期時間?其實是為了當鎖的擁有者掛了之後,避免鎖一直存在,導致其他應用永遠無法獲取到鎖

2.4 鎖續期

那麼既然鎖設定了過期時間,那很自然地想到,如果在鎖過期的這段時間內,擁有鎖的執行緒還未執行完業務邏輯,這時鎖自動過期,導致其他應用也獲取到了鎖,從而產生邏輯錯誤。所以引入了鎖續期

當獲取到鎖時,redisson會啟動一個看門狗,該看門狗每隔 lockWatchdogTimeout / 3秒續期一次鎖(假設lockWatchdogTimeout預設為30秒,則每隔10秒續期鎖),原始碼如下

private void renewExpiration() {
    ...
    
    // 1、建立一個10秒後執行的延遲任務
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ...
            
            // 2、執行續期鎖的lua指令碼
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                ...
                
                // 3、res為true,代表鎖續期成功,重新呼叫該方法,繼續建立延遲任務
                // false表示鎖續期失敗
                if (res) {
                    renewExpiration();
                } else {
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
}

續期鎖的lua指令碼如下:

// 如果鎖存在這個hashkey,重新設定鎖的過期時間
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

到這裡,redisson實現分散式鎖的原始碼解析就結束了。

三、總結

redisson的原始碼中大量使用了非同步程式設計,這導致閱讀原始碼的難度係數較高,這裡我也只是大概整理了一下,有問題的同學可以互相討論一下或自行查閱原始碼。

相關文章