Redission 分佈式鎖原理分析

一、前言

我們先來說說分佈式鎖,爲啥要有分佈式鎖呢? 像 JDK 提供的 synchronized、Lock 等實現鎖不香嗎?這是因爲在單進程情況下,多個線程訪問同一資源,可以使用 synchronized 和 Lock 實現;在多進程情況下,也就是分佈式情況,對同一資源的併發請求,需要使用分佈式鎖實現。而 Redisson 組件可以實現 Redis 的分佈式鎖,同樣 Redisson 也是 Redis 官方推薦分佈式鎖實現方案,封裝好了讓用戶實現分佈式鎖更加的方便與簡潔。

二、分佈式鎖的特性

三、Redisson 分佈式鎖原理

下面我們從加鎖機制、鎖互斥機制、鎖續期機制、可重入加鎖機制、鎖釋放機制等五個方面對 Redisson 分佈式鎖原理進行分析。

3.0 整體分析

注:redisson 版本 3.24.4-SNAPSHOT

/**
 * 微信公衆號:【老周聊架構】
 */
public class RedissonLockTest {
    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer()
            .setPassword("admin")
            .setAddress("redis://127.0.0.1:6379");

        RedissonClient redisson = Redisson.create(config);
        RLock lock = redisson.getLock("myLock");

        try {
            lock.lock();
            // 業務邏輯
        } finally {
            lock.unlock();
        }
    }
}

初始化 RedissonLock

/**
 * 加鎖方法
 *
 * @param leaseTime 加鎖到期時間(-1:使用默認值 30 秒)
 * @param unit 時間單位
 * @param interruptibly 是否可被中斷標識
 * @throws InterruptedException
 */
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    // 獲取當前線程ID
    long threadId = Thread.currentThread().getId();
    // 嘗試獲取鎖(重點)
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    // 成功獲取鎖, 過期時間爲空。
    if (ttl == null) {
        return;
    }

    // 訂閱分佈式鎖, 解鎖時進行通知。
    CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
    pubSub.timeout(future);
    RedissonLockEntry entry;
    if (interruptibly) {
        entry = commandExecutor.getInterrupted(future);
    } else {
        entry = commandExecutor.get(future);
    }

    try {
        while (true) {
            // 再次嘗試獲取鎖
            ttl = tryAcquire(-1, leaseTime, unit, threadId);
            // lock acquired
            // 成功獲取鎖, 過期時間爲空, 成功返回。
            if (ttl == null) {
                break;
            }

            // waiting for message
            // 鎖過期時間如果大於零, 則進行帶過期時間的阻塞獲取。
            if (ttl >= 0) {
                try {
                    // 獲取不到鎖會在這裏進行阻塞, Semaphore, 解鎖時釋放信號量通知。
                    entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } catch (InterruptedException e) {
                    if (interruptibly) {
                        throw e;
                    }
                    entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                }
            } else {
                // 鎖過期時間小於零, 則死等, 區分可中斷及不可中斷。
                if (interruptibly) {
                    entry.getLatch().acquire();
                } else {
                    entry.getLatch().acquireUninterruptibly();
                }
            }
        }
    } finally {
        // 取消訂閱
        unsubscribe(entry, threadId);
    }
}

當鎖超時時間爲 -1 時,而且獲取鎖成功時,會啓動看門狗定時任務自動續鎖:



每次續鎖都要判斷鎖是否已經被釋放,如果鎖續期成功,自己再次調度自己,持續續鎖操作。

爲了保證原子性,用 lua 實現的原子性加鎖操作,見 3.1 加鎖機制。

3.1 加鎖機制

加鎖機制的核心就是這段,將 Lua 腳本被 Redisoon 包裝最後通過 Netty 進行傳輸。

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    /**
     * // 1
     * KEYS[1] 代表上面的 myLock
     * 判斷 KEYS[1] 是否存在, 存在返回 1, 不存在返回 0。
     * 當 KEYS[1] == 0 時代表當前沒有鎖
     * // 2
     * 查找 KEYS[1] 中 key ARGV[2] 是否存在, 存在回返回 1
     * // 3
     * 使用 hincrby 命令發現 KEYS[1] 不存在並新建一個 hash
     * ARGV[2] 就作爲 hash 的第一個key, val 爲 1
     * 相當於執行了 hincrby myLock 91089b45... 1
     * // 4
     * 設置 KEYS[1] 過期時間, 單位毫秒
     * // 5
     * 返回 KEYS[1] 過期時間, 單位毫秒
     */
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
            "if ((redis.call('exists', KEYS[1]) == 0) " + // 1
                        "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " + // 2
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 3
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 4
                    "return nil; " +
                "end; " +
                "return redis.call('pttl', KEYS[1]);", // 5
            Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

斷點走一波就很清晰了:

KEYS[1]) :加鎖的 key
ARGV[1] :key 的生存時間,默認爲 30 秒
ARGV[2] :加鎖的客戶端 ID (UUID.randomUUID()) + “:” + threadId)


上面這一段加鎖的 lua 腳本的作用是:第一段 if 判斷語句,就是用 exists myLock 命令判斷一下,如果你要加鎖的那個鎖 key 不存在的話(第一次加鎖)或者該 key 的 field 存在(可重入鎖),你就進行加鎖。如何加鎖呢?使用 hincrby 命令設置一個 hash 結構,類似於在 Redis 中使用下面的操作:

整個 Lua 腳本加鎖的流程畫圖如下:

可以看出,最新版本的邏輯比之前的版本更簡單清晰了。

3.2 鎖互斥機制

此時,如果客戶端 2 來嘗試加鎖,會如何呢?首先,第一個 if 判斷會執行 exists myLock,發現 myLock 這個鎖 key 已經存在了。接着第二個 if 判斷,判斷一下,myLock 鎖 key 的 hash 數據結構中,是否包含客戶端 2 的 ID,這裏明顯不是,因爲那裏包含的是客戶端 1 的 ID。所以,客戶端 2 會執行:

return redis.call('pttl', KEYS[1]);

返回的一個數字,這個數字代表了 myLock 這個鎖 key 的剩餘生存時間。

鎖互斥機制主流程其實在 3.0 整體分析 裏有講,具體可以看這個 org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean) 方法。

3.3 鎖續期機制

客戶端 1 加鎖的鎖 key 默認生存時間是 30 秒,如果超過了 30 秒,客戶端 1 還想一直持有這把鎖,怎麼辦呢?

Redisson 提供了一個續期機制, 只要客戶端 1 一旦加鎖成功,就會啓動一個 Watch Dog。

3.4 可重入加鎖機制



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

注:

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

  2. 如果調用帶過期時間的 lock 方法,則不會啓動看門狗任務去自動續期。

3.5 鎖釋放機制

// 判斷 KEYS[1] 中是否存在 ARGV[3]
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
    "return nil;" +
"end; " +
// 將 KEYS[1] 中 ARGV[3] Val - 1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 如果返回大於0 證明是一把重入鎖
"if (counter > 0) then " +
    // 重置過期時間
    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
    "return 0; " +
"else " +
    // 刪除 KEYS[1]
    "redis.call('del', KEYS[1]); " +
    // 通知阻塞等待線程或進程資源可用
    "redis.call('publish', KEYS[2], ARGV[1]); " +
    "return 1; " +
"end; " +
"return nil;"

KEYS[1]: myLock
KEYS[2]: redisson_lock_channel:{myLock}
ARGV[1]: 0
ARGV[2]: 30000 (過期時間)
ARGV[3]: 66a84a47-3960-4f3e-8ed7-ea2c1061e4cf:1 (Hash 中的鎖 field)

同理,鎖釋放斷點走一波:


鎖釋放機制小結一下:

四、主從 Redis 架構中分佈式鎖存在的問題

爲了解決這個問題,redis 引入了紅鎖的概念。

需要準備多臺 redis 實例,這些 redis 實例指的是完全互相獨立的 Redis 節點,這些節點之間既沒有主從,也沒有集羣關係。客戶端申請分佈式鎖的時候,需要向所有的 redis 實例發出申請,只有超過半數的 redis 實例報告獲取鎖成功,才能算真正獲取到鎖。跟大多數保證一致性的算法類似,就是多數原理。

public static void main(String[] args) {
    String lockKey = "myLock";
    Config config = new Config();
    config.useSingleServer().setPassword("123456").setAddress("redis://127.0.0.1:6379");
    Config config2 = new Config();
    config.useSingleServer().setPassword("123456").setAddress("redis://127.0.0.1:6380");
    Config config3 = new Config();
    config.useSingleServer().setPassword("123456").setAddress("redis://127.0.0.1:6381");

    RLock lock = Redisson.create(config).getLock(lockKey);
    RLock lock2 = Redisson.create(config2).getLock(lockKey);
    RLock lock3 = Redisson.create(config3).getLock(lockKey);

    RedissonRedLock redLock = new RedissonRedLock(lock, lock2, lock3);

    try {
        redLock.lock();
    } finally {
        redLock.unlock();
    }
}

當然, 對於 Redlock 算法不是沒有質疑聲,兩位大神前幾年吵的沸沸騰騰,大家感興趣的可以去 Redis 官網查看 Martin Kleppmann 與 Redis 作者 Antirez 的辯論。

額,想收一收了,再講下去感覺要繞不開分佈式經典問題 CAP 了。

五、分佈式鎖選型

魚和熊掌不可兼得,如果你想強一致性的話可以選擇 ZK 的分佈式鎖,但 ZK 的話性能就會有一定的下降,如果項目沒有用到 ZK 的話,那就選擇 Redis 的分佈式鎖吧,比較你爲了那極小的概率而丟去性能以及引入一個組件很不划算,如果無法忍受 Redis 的紅鎖缺陷,那自己在業務中自己保證吧。

下面是常見的幾種分佈式鎖選型對比:


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AXJRDBNCioDaRk-bBNV74A