Redission 分佈式鎖原理分析
一、前言
我們先來說說分佈式鎖,爲啥要有分佈式鎖呢? 像 JDK 提供的 synchronized、Lock 等實現鎖不香嗎?這是因爲在單進程情況下,多個線程訪問同一資源,可以使用 synchronized 和 Lock 實現;在多進程情況下,也就是分佈式情況,對同一資源的併發請求,需要使用分佈式鎖實現。而 Redisson 組件可以實現 Redis 的分佈式鎖,同樣 Redisson 也是 Redis 官方推薦分佈式鎖實現方案,封裝好了讓用戶實現分佈式鎖更加的方便與簡潔。
二、分佈式鎖的特性
-
互斥性
任意時刻,只能有一個客戶端獲取鎖,不能同時有兩個客戶端獲取到鎖。
-
同一性
鎖只能被持有該鎖的客戶端刪除,不能由其它客戶端刪除。
-
可重入性
持有某個鎖的客戶端可繼續對該鎖加鎖,實現鎖的續租。
-
容錯性
鎖失效後(超過生命週期)自動釋放鎖(key 失效),其他客戶端可以繼續獲得該鎖,防止死鎖。
三、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 的生存時間。
注:
如果服務宕機了,Watch Dog 機制線程也就沒有了,此時就不會延長 key 的過期時間,到了 30s 之後就會自動過期了,其他線程就可以獲取到鎖。
如果調用帶過期時間的 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)
同理,鎖釋放斷點走一波:
鎖釋放機制小結一下:
-
刪除鎖(這裏注意可重入鎖)
-
廣播釋放鎖的消息,通知阻塞等待的進程(向通道名爲 redisson_lock__channel:{myLock} publish 一條 UNLOCK_MESSAGE 信息)
-
取消 Watch Dog 機制,即將 RedissonLock.EXPIRATION_RENEWAL_MAP 裏面的線程 id 刪除,並且 cancel 掉 Netty 的那個定時任務線程。
四、主從 Redis 架構中分佈式鎖存在的問題
-
線程 A 從主 redis 中請求一個分佈式鎖,獲取鎖成功;
-
從 redis 準備從主 redis 同步鎖相關信息時,主 redis 突然發生宕機,鎖丟失了;
-
觸發從 redis 升級爲新的主 redis;
-
線程 B 從繼任主 redis 的從 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