重做永遠比改造簡單 最近在做一個項目,將一個其他公司的實現系統(下文稱作舊系統),完整的整合到自己公司的系統(下文稱作新系統)中,這其中需要將對方實現的功能完整在自己系統也實現一遍。 舊系統還有一批存量商戶,為了不影響存量商戶的體驗,新系統提供的對外介面,還必須得跟以前一致。最後系統完整切換之後,功 ...
重做永遠比改造簡單
最近在做一個項目,將一個其他公司的實現系統(下文稱作舊系統),完整的整合到自己公司的系統(下文稱作新系統)中,這其中需要將對方實現的功能完整在自己系統也實現一遍。
舊系統還有一批存量商戶,為了不影響存量商戶的體驗,新系統提供的對外介面,還必須得跟以前一致。最後系統完整切換之後,功能只運行在新系統中,這就要求舊系統的數據還需要完整的遷移到新系統中。
當然這些在做這個項目之前就有預期,想過這個過程很難,但是沒想到有那麼難。原本感覺排期大半年,時間還是挺寬裕,現在感覺就是大坑,還不得不在坑裡一點點去填。
哎,說多都是淚,不吐槽了,等到下次做完再給大家復盤下真正心得體會。
回到正文,上篇文章Redis 分散式鎖,咱們基於 Redis 實現一個分散式鎖。這個分散式鎖基本功能沒什麼問題,但是缺少可重入的特性,所以這篇文章小黑哥就帶大家來實現一下可重入的分散式鎖。
本篇文章將會涉及以下內容:
- 可重入
- 基於 ThreadLocal 實現方案
- 基於 Redis Hash 實現方案
先贊後看,養成習慣。微信搜索「程式通事」,關註就完事了~
可重入
說到可重入鎖,首先我們來看看一段來自 wiki 上可重入的解釋:
若一個程式或子程式可以“在任意時刻被中斷然後操作系統調度執行另外一段代碼,這段代碼又調用了該子程式不會出錯”,則稱其為可重入(reentrant或re-entrant)的。即當該子程式正在運行時,執行線程可以再次進入並執行它,仍然獲得符合設計時預期的結果。與多線程併發執行的線程安全不同,可重入強調對單個線程執行時重新進入同一個子程式仍然是安全的。
當一個線程執行一段代碼成功獲取鎖之後,繼續執行時,又遇到加鎖的代碼,可重入性就就保證線程能繼續執行,而不可重入就是需要等待鎖釋放之後,再次獲取鎖成功,才能繼續往下執行。
用一段 Java 代碼解釋可重入:
public synchronized void a() {
b();
}
public synchronized void b() {
// pass
}
假設 X 線程在 a 方法獲取鎖之後,繼續執行 b 方法,如果此時不可重入,線程就必須等待鎖釋放,再次爭搶鎖。
鎖明明是被 X 線程擁有,卻還需要等待自己釋放鎖,然後再去搶鎖,這看起來就很奇怪,我釋放我自己~
可重入性就可以解決這個尷尬的問題,當線程擁有鎖之後,往後再遇到加鎖方法,直接將加鎖次數加 1,然後再執行方法邏輯。退出加鎖方法之後,加鎖次數再減 1,當加鎖次數為 0 時,鎖才被真正的釋放。
可以看到可重入鎖最大特性就是計數,計算加鎖的次數。所以當可重入鎖需要在分散式環境實現時,我們也就需要統計加鎖次數。
分散式可重入鎖實現方式有兩種:
- 基於 ThreadLocal 實現方案
- 基於 Redis Hash 實現方案
首先我們看下基於 ThreadLocal 實現方案。
基於 ThreadLocal 實現方案
實現方式
Java 中 ThreadLocal
可以使每個線程擁有自己的實例副本,我們可以利用這個特性對線程重入次數進行技術。
下麵我們定義一個ThreadLocal
的全局變數 LOCKS
,記憶體存儲 Map
實例變數。
private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);
每個線程都可以通過 ThreadLocal
獲取自己的 Map
實例,Map
中 key
存儲鎖的名稱,而 value
存儲鎖的重入次數。
加鎖的代碼如下:
/**
* 可重入鎖
*
* @param lockName 鎖名字,代表需要爭臨界資源
* @param request 唯一標識,可以使用 uuid,根據該值判斷是否可以重入
* @param leaseTime 鎖釋放時間
* @param unit 鎖釋放時間單位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
Map<String, Integer> counts = LOCKS.get();
if (counts.containsKey(lockName)) {
counts.put(lockName, counts.get(lockName) + 1);
return true;
} else {
if (redisLock.tryLock(lockName, request, leaseTime, unit)) {
counts.put(lockName, 1);
return true;
}
}
return false;
}
ps:
redisLock#tryLock
為上一篇文章實現的分佈鎖。由於公號外鏈無法直接跳轉,關註『程式通事』,回覆分散式鎖獲取源代碼。
加鎖方法首先判斷當前線程是否已經已經擁有該鎖,若已經擁有,直接對鎖的重入次數加 1。
若還沒擁有該鎖,則嘗試去 Redis 加鎖,加鎖成功之後,再對重入次數加 1 。
釋放鎖的代碼如下:
/**
* 解鎖需要判斷不同線程池
*
* @param lockName
* @param request
*/
public void unlock(String lockName, String request) {
Map<String, Integer> counts = LOCKS.get();
if (counts.getOrDefault(lockName, 0) <= 1) {
counts.remove(lockName);
Boolean result = redisLock.unlock(lockName, request);
if (!result) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
+ request);
}
} else {
counts.put(lockName, counts.get(lockName) - 1);
}
}
釋放鎖的時首先判斷重入次數,若大於 1,則代表該鎖是被該線程擁有,所以直接將鎖重入次數減 1 即可。
若當前可重入次數小於等於 1,首先移除 Map
中鎖對應的 key,然後再到 Redis 釋放鎖。
這裡需要註意的是,當鎖未被該線程擁有,直接解鎖,可重入次數也是小於等於 1 ,這次可能無法直接解鎖成功。
ThreadLocal
使用過程要記得及時清理內部存儲實例變數,防止發生記憶體泄漏,上下文數據串用等問題。下次咱來聊聊最近使用
ThreadLocal
寫的 Bug。
相關問題
使用 ThreadLocal
這種本地記錄重入次數,雖然真的簡單高效,但是也存在一些問題。
過期時間問題
上述加鎖的代碼可以看到,重入加鎖時,僅僅對本地計數加 1 而已。這樣可能就會導致一種情況,由於業務執行過長,Redis 已經過期釋放鎖。
而再次重入加鎖時,由於本地還存在數據,認為鎖還在被持有,這就不符合實際情況。
如果要在本地增加過期時間,還需要考慮本地與 Redis 過期時間一致性的,代碼就會變得很複雜。
不同線程/進程可重入問題
狹義上可重入性應該只是對於同一線程的可重入,但是實際業務可能需要不同的應用線程之間可以重入同把鎖。
而 ThreadLocal
的方案僅僅只能滿足同一線程重入,無法解決不同線程/進程之間重入問題。
不同線程/進程重入問題就需要使用下述方案 Redis Hash 方案解決。
基於 Redis Hash 可重入鎖
實現方式
ThreadLocal
的方案中我們使用了 Map
記載鎖的可重入次數,而 Redis 也同樣提供了 Hash (哈希表)這種可以存儲鍵值對數據結構。所以我們可以使用 Redis Hash 存儲的鎖的重入次數,然後利用 lua
腳本判斷邏輯。
加鎖的 lua 腳本如下:
---- 1 代表 true
---- 0 代表 false
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
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 1;
end ;
return 0;
如果 KEYS:[lock],ARGV[1000,uuid]
不熟悉 lua 語言同學也不要怕,上述邏輯還是比較簡單的。
加鎖代碼首先使用 Redis exists
命令判斷當前 lock 這個鎖是否存在。
如果鎖不存在的話,直接使用 hincrby
創建一個鍵為 lock
hash 表,並且為 Hash 表中鍵為 uuid
初始化為 0,然後再次加 1,最後再設置過期時間。
如果當前鎖存在,則使用 hexists
判斷當前 lock
對應的 hash 表中是否存在 uuid
這個鍵,如果存在,再次使用 hincrby
加 1,最後再次設置過期時間。
最後如果上述兩個邏輯都不符合,直接返回。
加鎖代碼如下:
// 初始化代碼
String lockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:lock.lua").openStream(), Charsets.UTF_8);
lockScript = new DefaultRedisScript<>(lockLuaScript, Boolean.class);
/**
* 可重入鎖
*
* @param lockName 鎖名字,代表需要爭臨界資源
* @param request 唯一標識,可以使用 uuid,根據該值判斷是否可以重入
* @param leaseTime 鎖釋放時間
* @param unit 鎖釋放時間單位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
long internalLockLeaseTime = unit.toMillis(leaseTime);
return stringRedisTemplate.execute(lockScript, Lists.newArrayList(lockName), String.valueOf(internalLockLeaseTime), request);
}
Spring-Boot 2.2.7.RELEASE
只要搞懂 Lua 腳本加鎖邏輯,Java 代碼實現還是挺簡單的,直接使用 SpringBoot 提供的 StringRedisTemplate
即可。
解鎖的 Lua 腳本如下:
-- 判斷 hash set 可重入 key 的值是否等於 0
-- 如果為 0 代表 該可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil;
end ;
-- 計算當前可重入次數
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小於等於 0 代表可以解鎖
if (counter > 0) then
return 0;
else
redis.call('del', KEYS[1]);
return 1;
end ;
return nil;
首先使用 hexists
判斷 Redis Hash 表是否存給定的域。
如果 lock 對應 Hash 表不存在,或者 Hash 表不存在 uuid 這個 key,直接返回 nil
。
若存在的情況下,代表當前鎖被其持有,首先使用 hincrby
使可重入次數減 1 ,然後判斷計算之後可重入次數,若小於等於 0,則使用 del
刪除這把鎖。
解鎖的 Java 代碼如下:
// 初始化代碼:
String unlockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:unlock.lua").openStream(), Charsets.UTF_8);
unlockScript = new DefaultRedisScript<>(unlockLuaScript, Long.class);
/**
* 解鎖
* 若可重入 key 次數大於 1,將可重入 key 次數減 1 <br>
* 解鎖 lua 腳本返回含義:<br>
* 1:代表解鎖成功 <br>
* 0:代表鎖未釋放,可重入次數減 1 <br>
* nil:代表其他線程嘗試解鎖 <br>
* <p>
* 如果使用 DefaultRedisScript<Boolean>,由於 Spring-data-redis eval 類型轉化,<br>
* 當 Redis 返回 Nil bulk, 預設將會轉化為 false,將會影響解鎖語義,所以下述使用:<br>
* DefaultRedisScript<Long>
* <p>
* 具體轉化代碼請查看:<br>
* JedisScriptReturnConverter<br>
*
* @param lockName 鎖名稱
* @param request 唯一標識,可以使用 uuid
* @throws IllegalMonitorStateException 解鎖之前,請先加鎖。若為加鎖,解鎖將會拋出該錯誤
*/
public void unlock(String lockName, String request) {
Long result = stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
// 如果未返回值,代表其他線程嘗試解鎖
if (result == null) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
+ request);
}
}
解鎖代碼執行方式與加鎖類似,只不過解鎖的執行結果返回類型使用 Long
。這裡之所以沒有跟加鎖一樣使用 Boolean
,這是因為解鎖 lua 腳本中,三個返回值含義如下:
- 1 代表解鎖成功,鎖被釋放
- 0 代表可重入次數被減 1
null
代表其他線程嘗試解鎖,解鎖失敗
如果返回值使用 Boolean
,Spring-data-redis 進行類型轉換時將會把 null
轉為 false,這就會影響我們邏輯判斷,所以返回類型只好使用 Long
。
以下代碼來自 JedisScriptReturnConverter
:
相關問題
spring-data-redis 低版本問題
如果 Spring-Boot 使用 Jedis 作為連接客戶端,並且使用Redis Cluster 集群模式,需要使用 2.1.9 以上版本的spring-boot-starter-data-redis,不然執行過程中將會拋出:
org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.
如果當前應用無法升級 spring-data-redis
也沒關係,可以使用如下方式,直接使用原生 Jedis 連接執行 lua 腳本。
以加鎖代碼為例:
public boolean tryLock(String lockName, String reentrantKey, long leaseTime, TimeUnit unit) {
long internalLockLeaseTime = unit.toMillis(leaseTime);
Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
Object innerResult = eval(connection.getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
return convert(innerResult);
});
return result;
}
private Object eval(Object nativeConnection, RedisScript redisScript, final List<String> keys, final List<String> args) {
Object innerResult = null;
// 集群模式和單點模式雖然執行腳本的方法一樣,但是沒有共同的介面,所以只能分開執行
// 集群
if (nativeConnection instanceof JedisCluster) {
innerResult = evalByCluster((JedisCluster) nativeConnection, redisScript, keys, args);
}
// 單點
else if (nativeConnection instanceof Jedis) {
innerResult = evalBySingle((Jedis) nativeConnection, redisScript, keys, args);
}
return innerResult;
}
數據類型轉化問題
如果使用 Jedis 原生連接執行 Lua 腳本,那麼可能又會碰到數據類型的轉換坑。
可以看到 Jedis#eval
返回 Object
,我們需要具體根據 Lua 腳本的返回值的,再進行相關轉化。這其中就涉及到 Lua 數據類型轉化為 Redis 數據類型。
下麵主要我們來講下 Lua 數據轉化 Redis 的規則中幾條比較容易踩坑:
1、Lua number 與 Redis 數據類型轉換
Lua 中 number 類型是一個雙精度的浮點數,但是 Redis 只支持整數類型,所以這個轉化過程將會丟棄小數位。
2、Lua boolean 與 Redis 類型轉換
這個轉化比較容易踩坑,Redis 中是不存在 boolean 類型,所以當Lua 中 true
將會轉為 Redis 整數 1。而 Lua 中 false
並不是轉化整數,而是轉化 null 返回給客戶端。
3、Lua nil 與 Redis 類型轉換
Lua nil 可以當做是一個空值,可以等同於 Java 中的 null。在 Lua 中如果 nil 出現在條件表達式,將會當做 false 處理。
所以 Lua nil 也將會 null 返回給客戶端。
其他轉化規則比較簡單,詳情參考:
http://doc.redisfans.com/script/eval.html
總結
可重入分散式鎖關鍵在於對於鎖重入的計數,這篇文章主要給出兩種解決方案,一種基於 ThreadLocal
實現方案,這種方案實現簡單,運行也比較高效。但是若要處理鎖過期的問題,代碼實現就比較複雜。
另外一種採用 Redis Hash 數據結構實現方案,解決了 ThreadLocal
的缺陷,但是代碼實現難度稍大,需要熟悉 Lua 腳本,以及Redis 一些命令。另外使用 spring-data-redis 等操作 Redis 時不經意間就會遇到各種問題。
幫助
https://www.sofastack.tech/blog/sofa-jraft-rheakv-distributedlock/
最後說兩句(求關註)
看完文章,哥哥姐姐們點個贊吧,周更真的超累,不知覺又寫了兩天,拒絕白嫖,來點正反饋唄~。
最後感謝各位的閱讀,才疏學淺,難免存在紕漏,如果你發現錯誤的地方,可以留言指出。如果看完文章還有其他不懂的地方,歡迎加我,互相學習,一起成長~
最後謝謝大家支持~
最最後,重要的事再說一篇~
快來關註我呀~
快來關註我呀~
快來關註我呀~
歡迎關註我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關註我的博客:studyidea.cn