年輕人, 看看 Redisson 是如何實現分散式鎖

Java程式設計師聚集地發表於2020-11-27

前言

平常我們在自己實現分散式鎖時考慮要點以及效能方面, 可能實現的不是很全面, 業界內關於分散式鎖做的比較好的就是 Redisson

不過是否引入可以結合自己專案的需求, 如果只是引入分散式鎖的功能,個人覺得沒有必要,自己實現即可;如果大量依賴 Redisson 中分散式功能,那麼可以引用

讀 Redisson 原始碼之前需要掌握分散式鎖的由來以及自定義各個實現的優缺點 

ReentrantLock 重入鎖

在說 Redisson 之前我們先來說一下 JDK 可重入鎖: ReentrantLock

ReentrantLock 保證了 JVM 共享資源同一時刻只允許單個執行緒進行操作

實現思路

ReentrantLock 內部公平鎖與非公平鎖繼承了 AQS[AbstractQueuedSynchronizer]

1、AQS 內部通過 volatil 修飾的 int 型別變數 state 控制併發情況下執行緒安全問題及鎖重入

2、將未競爭到鎖的執行緒放入 AQS 的佇列中通過 LockSupport#park、unPark 掛起喚醒

Redisson

可以直接檢視 Github Redisson官網 介紹, 沒有了解過的小夥伴, 可以看一下 Redisson 的 WIKI 目錄, 仔細瞅瞅 Redis 是如何被 Redisson 武裝到牙齒的, 就是目錄太多放不下

這裡先過一下和文章有關的一部分內容

通過專案簡介可以看出來, 寫這個專案介紹的人水平非常哇塞哈, 從第一段我們們就知道了兩個問題

Redisson 是什麼

Redisson 是架設在 Redis 基礎上的一個 Java 駐記憶體資料網格框架, 充分利用 Redis 鍵值資料庫提供的一系列優勢, 基於 Java 實用工具包中常用介面, 為使用者提供了 一系列具有分散式特性的常用工具類

Redisson 的優勢

使得原本作為協調單機多執行緒併發程式的工具包 獲得了協調分散式多機多執行緒併發系統的能力, 大大降低了設計和研發大規模分散式系統的難度

同時結合各富特色的分散式服務, 更進一步 簡化了分散式環境中程式相互之間的協作

瞭解到這裡就差不多了, 就不向下擴充套件了, 想要了解詳細用途的, 翻一下上面的目錄

Redisson 重入鎖

由於 Redisson 太過於複雜, 設計的 API 呼叫大多用 Netty 相關, 所以這裡只對 如何加鎖、如何實現重入鎖進行分析以及如何鎖續時進行分析

建立鎖

我這裡是將 Redisson 的原始碼下載到本地了

下面這個簡單的程式, 就是使用 Redisson 建立了一個非公平的可重入鎖

lock() 方法加鎖成功 預設過期時間 30 秒, 並且支援 "看門狗" 續時功能

public static void main(String[] args) {
    Config config = new Config();
    config.useSingleServer()
            .setPassword("123456")
            .setAddress("redis://127.0.0.1:6379");
    RedissonClient redisson = Redisson.create(config);

    RLock lock = redisson.getLock("myLock");

    try {
        lock.lock();
        // 業務邏輯
    } finally {
        lock.unlock();
    }
}
複製程式碼

我們先來看一下 RLock 介面的宣告

public interface RLock extends Lock, RLockAsync {}
複製程式碼

RLock 繼承了 JDK 原始碼 JUC 包下的 Lock 介面, 同時也繼承了 RLockAsync

RLockAsync 從字面意思看是 支援非同步的鎖, 證明獲取鎖時可以非同步獲取

看了 Redisson 的原始碼會知道, 註釋比黃金貴 ?️

由於獲取鎖的 API 較多, 我們這裡以 lock() 做原始碼講解, 看介面定義相當簡單

/**
 * lock 並沒有指定鎖過期時間, 預設 30 秒
 * 如果獲取到鎖, 會對鎖進行續時
 */
void lock();
複製程式碼

獲取鎖例項

根據上面的小 Demo, 看下第一步獲取鎖是如何做的

RLock lock = redisson.getLock("myLock");

// name 就是鎖名稱
public RLock getLock(String name) {
  	// 預設建立的同步執行器, (存在非同步執行器, 因為鎖的獲取和釋放是有強一致性要求, 預設同步)
    return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
複製程式碼

Redisson 中所有 Redis 命令都是通過 ...Executor 執行的

獲取到預設的同步執行器後, 就要初始化 RedissonLock

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    super(commandExecutor, name);
    this.commandExecutor = commandExecutor;
  	// 唯一ID
    this.id = commandExecutor.getConnectionManager().getId();
  	// 等待獲取鎖時間
    this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
  	// ID + 鎖名稱
    this.entryName = id + ":" + name;
  	// 釋出訂閱, 後面關於加、解鎖流程會用到
    this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();
}
複製程式碼

嘗試獲取鎖

我們來看一下 RLock#lock() 底層是如何獲取鎖的

@Override
public void lock() {
    try {
        lock(-1, null, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException();
    }
}
複製程式碼

leaseTime: 加鎖到期時間, -1 使用預設值 30 秒

unit: 時間單位, 毫秒、秒、分鐘、小時...

interruptibly: 是否可被中斷標示

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    // 獲取當前執行緒ID
    long threadId = Thread.currentThread().getId();
    // ? 嘗試獲取鎖, 下面重點分析
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // 成功獲取鎖, 過期時間為空
    if (ttl == null) {
        return;
    }

    // 訂閱分散式鎖, 解鎖時進行通知
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    if (interruptibly) {
        commandExecutor.syncSubscriptionInterrupted(future);
    } else {
        commandExecutor.syncSubscription(future);
    }

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

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

這一段程式碼是用來執行加鎖, 繼續看下方法實現

Long ttl = tryAcquire(-1, leaseTime, unit, threadId);

private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
複製程式碼

lock() 以及 tryLock(...) 方法最終都會呼叫此方法, 分為兩個流程分支

1、tryLock(...) API 非同步加鎖返回

2、lock() & tryLock() API 非同步加鎖並進行鎖續時

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    // 執行 tryLock(...) 才會進入
    if (leaseTime != -1) {
        // 進行非同步獲取鎖
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 嘗試非同步獲取鎖, 獲取鎖成功返回空, 否則返回鎖剩餘過期時間
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    // ttlRemainingFuture 執行完成後觸發此操作
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }
      	// ttlRemaining == null 代表獲取了鎖
        // 獲取到鎖後執行續時操作
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}
複製程式碼

繼續看一下 tryLockInnerAsync(...) 詳細的加鎖流程, 內部採用的 Lua 指令碼形式, 保證了原子性操作

到這一步大家就很明瞭了, 將 Lua 指令碼被 Redisoon 包裝最後通過 Netty 進行傳輸

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "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; " +
                    "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]);",
            Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
複製程式碼

evalWriteAsync(...) 中是對 Eval 命令的封裝以及 Netty 的應用就不繼續跟進了

加鎖 Lua

執行 Redis 加鎖的 Lua 指令碼, 截個圖讓大家看一下引數以及具體含義

KEYS[1]: myLock

ARGV[1]: 36000... 這個是過期時間, 自己測試的, 單位毫秒

ARGV[2]: UUID + 執行緒 ID

# KEYS[1] 代表上面的 myLock
# 判斷 KEYS[1] 是否存在, 存在返回 1, 不存在返回 0
if (redis.call('exists', KEYS[1]) == 0) then
  # 當 KEYS[1] == 0 時代表當前沒有鎖
  # 使用 hincrby 命令發現 KEYS[1] 不存在並新建一個 hash
  # ARGV[2] 就作為 hash 的第一個key, val 為 1
  # 相當於執行了 hincrby myLock 91089b45... 1
	redis.call('hincrby', KEYS[1], ARGV[2], 1);
  # 設定 KEYS[1] 過期時間, 單位毫秒
	redis.call('pexpire', KEYS[1], ARGV[1]);
	return nil;
end;
# 查詢 KEYS[1] 中 key ARGV[2] 是否存在, 存在回返回 1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
  # 同上, ARGV[2] 為 key 的 val +1
	redis.call('hincrby', KEYS[1], ARGV[2], 1);
  # 同上
	redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
# 返回 KEYS[1] 過期時間, 單位毫秒
return redis.call('pttl', KEYS[1]);
複製程式碼

整個 Lua 指令碼加鎖的流程畫圖如下:

現在回過頭看一下獲取到鎖之後, 是如何為鎖進行延期操作的

鎖續時

之前有和朋友聊過這個話題, 思路和 Redisson 中體現的基本一致

先說一下 Redisson 的具體實現思路吧, 中文翻譯叫做 "看門狗"

1、 獲取到鎖之後執行 "看門狗" 流程

2、 使用 Netty 的 Timeout 實現定時延時

3、 比如鎖過期 30 秒, 每過 1/3 時間也就是 10 秒會檢查鎖是否存在, 存在則更新鎖的超時時間

可能會有小夥伴會提出這麼一個疑問, 如果檢查返回存在, 設定鎖過期時剛好鎖被釋放了怎麼辦?

有這樣的疑問, 代表確實用心去考慮所有可能發生的情況了, 但是不必擔心哈

Redisson 中使用的 Lua 指令碼做的檢查及設定過期時間操作, 本身是原子性的不會出現上面情況

如果不想要引用 Netty 的包, 使用延時佇列等包工具也是可以完成 "看門狗"

這裡也貼一哈相關程式碼, 能夠讓小夥伴更直觀的瞭解如何鎖續時的

我可真是個暖男, 上程式碼 RedissonLock#tryAcquireAsync(...)

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    // ...
    // 嘗試非同步獲取鎖, 獲取鎖成功返回空, 否則返回鎖剩餘過期時間
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    // ttlRemainingFuture 執行完成後觸發此操作
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }
        // 獲取到鎖後執行續時操作
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}
複製程式碼

可以看到續時方法將 threadId 當作識別符號進行續時

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();
    }
}
複製程式碼

知道核心理念就好了, 沒必要研究每一行程式碼哈

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

            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, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
}
複製程式碼

解鎖操作

解鎖時的操作相對加鎖還是比較簡單的

@Override
public void unlock() {
    try {
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}
複製程式碼

解鎖成功後會將之前的"看門狗" Timeout 續時取消, 並返回成功

@Override
public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<Void>();
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
      	// 取消自動續時功能
        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;
}
複製程式碼

又是一個精髓點, 解鎖的 Lua 指令碼定義

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                    "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return nil;",
            Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
複製程式碼

還是來張圖理解哈, Lua 指令碼會詳細分析

解鎖 Lua

老規矩, 圖片加引數說明

KEYS[1]: myLock

KEYS[2]: redisson_lock_channel:{myLock}

ARGV[1]: 0

ARGV[2]: 360000... (過期時間)

ARGV[3]: 7f0c54e2...(Hash 中的鎖 Key)

# 判斷 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;
複製程式碼

Redlock 演算法

不可否認, Redisson 設計的分散式鎖真的很 NB, 但是還是沒有解決 主從節點下非同步同步資料導致鎖丟失問題

所以 Redis 作者 Antirez 推出 紅鎖演算法, 這個演算法的精髓就是: 沒有從節點, 如果部署多臺 Redis, 各例項之間相互獨立, 不存在主從複製或者其他叢集協調機制

如何使用

建立多個 Redisson Node, 由這些無關聯的 Node 組成一個完整的分散式鎖

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 原則之間的取捨

CAP 原則又稱 CAP 定理, 指的是在一個分散式系統中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分割槽容錯性), 三者不可得兼

一致性(C) : 在分散式系統中的所有資料備份, 在同一時刻是否同樣的值(等同於所有節點訪問同一份最新的資料副本)

可用性(A): 在叢集中一部分節點故障後, 叢集整體是否還能響應客戶端的讀寫請求(對資料更新具備高可用性)

分割槽容忍性(P): 以實際效果而言, 分割槽相當於對通訊的時限要求. 系統如果不能在時限內達成資料一致性, 就意味著發生了分割槽的情況, 必須就當前操作在 C 和 A 之間做出選擇

分散式鎖選型

如果要滿足上述分散式鎖之間的強一致性, 可以採用 Zookeeper 的分散式鎖, 因為它底層的 ZAB協議(原子廣播協議), 天然滿足 CP

但是這也意味著效能的下降, 所以不站在具體資料下看 Redis 和 Zookeeper, 代表著效能和一致性的取捨

如果專案沒有強依賴 ZK, 使用 Redis 就好了, 因為現在 Redis 用途很廣, 大部分專案中都引用了 Redis

沒必要對此再引入一個新的元件, 如果業務場景對於 Redis 非同步方式的同步資料造成鎖丟失無法忍受, 在業務層處理就好了

寫在最後的話

最近都在寫多執行緒原始碼相關的, 後面會輸出 JUC 下原始碼解析

1、CountDownLatch

2、ThreadLocal

3、Atomic 相關

包括最近兩篇分散式鎖的文章, 篇幅都比較長, 希望大家能耐心觀看

希望大家能夠反饋指正文章中錯誤不正確的地方?,小夥伴的喜歡就是對我最大的支援, 最後關注微信公眾號【Java程式設計師聚集地】可以獲取一份架構資料。


 

相關文章