前提
最近在跟進一個比較老的系統的時候,發現了所有排程任務使用了
spring-context
裡面的
@Scheduled
注解和自行基于
Redis
封裝的簡易分布式鎖控制任務不并發執行。為了不引入其他架構的情況下做一些簡單優化,筆者花點時間去研讀了一下
Redis
的
SET
指令的相關文檔。
場景還原
使用
@Scheduled
注解實作定時任務,使用
spring-data-redis
提供的API實作簡易的
Redis
分布式鎖的僞代碼如下:
// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
// 判斷KEY存在性并且設定KEY,帶逾時時間5分鐘
if (StringRedisTemplate#opsForValue()#hasKey("定時任務唯一字元串辨別")){
StringRedisTemplate#opsForValue()#set("定時任務唯一字元串辨別", "1[這裡暫時可以使用任何值]", 5 , TimeUnit.MINUTES);
}
// 這裡做排程正常業務邏輯
doBusiness();
// 删除KEY
StringRedisTemplate#opsForValue()#delete("定時任務唯一字元串辨別");
}
複制
上面的代碼存在如下顯然的缺陷:
- 如果應用部署多個節點,由于判斷KEY的存在性和SET操作是兩個操作(非原子操作),該定時任務有可能在同一個時刻并發執行多次。
- 如果業務邏輯執行方法
抛出了異常,會導緻删除KEY的操作無法執行,KEY會到達逾時時間後被删除,這個時候相當于加鎖時間長達5分鐘,顯然是無法接受的。doBusiness()
但是實際上,以上兩個問題在生産環境中并沒有出現過,分析一下具體原因是:
- 對于第1點,該應用在生産環境隻部署了2個節點,節點的重新開機時間并不相同,是以從天然上避免了重複執行的問題,如果CRON表達式設計為
(0秒開始每30分鐘執行一次)就有可能出現并發問題。0 */30 * * * ?
- 對于第2點,開發者在處理業務方法裡面全局捕獲異常并且沒有外抛,是以排程方法總是會執行到删除KEY的邏輯。
以上僅僅是巧合的情況下規避了問題出現的因子,但是從編碼規範的角度來看顯然是存在問題。基于
Redis
實作的分布式鎖的方案在
Redis
官方文檔中有一篇文章做了詳細的分析-Distributed locks with Redis,對于Java語言來說,有現成的類庫
Redisson
提供對應的實作。但是在解決這個問題的時候,為了簡易起見并沒有引入
Redisson
,而是想辦法通過原來的
SET
和
DEL
兩個操作的相關思路進行優化。
SET複合指令
自從
Redis
的2.6.12版本起,
SET
指令已經提供了可選的複合操作符:
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
複制
- 時間複雜度:
。O(1)
可選參數:
-
:設定逾時時間,機關是秒。EX
-
:設定逾時時間,機關是毫秒。PX
-
:NX
的縮寫,隻有KEY不存在的前提下才會設定值。IF NOT EXIST
-
:XX
的縮寫,隻有在KEY存在的前提下才會設定值。IF EXIST
列舉一些等價的指令:
原始指令 | 等價指令 |
---|---|
SETEX KEY_1 1 | SET KEY_1 EX 1 |
SETNX KEY_1 | SET KEY_1 NX |
SETNX KEY_1 && EXPIRE KEY_1 1 | SET KEY_1 EX 1 NX |
SETNX KEY_1 && PEXPIRE KEY_1 1000 | SET KEY_1 PX 1000 NX |
對比一下,發現
SET
複合指令十分簡便,可以把兩個指令合并成一個原子指令。不過注意一下,
spring-data-redis
裡面的封裝做得不太好,
ValueOperations
并沒有提供相關的方法,是以最好還是使用
Redis
的Java用戶端
Jedis
。
簡易的分布式鎖實作
其實官方文檔裡面已經有很詳細的
Redis
分布式鎖方案(盡管這個方案在某些論文裡面被熱烈讨論它存在的問題,但是生産中它已經被廣泛使用),擷取鎖的僞代碼如下:
SET RESOURCE_NAME RANDOM_VALUE NX PX 30000
複制
釋放鎖的僞代碼(
Lua
腳本)如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
複制
改造前文中提及到的例子:
// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
try (Jedis jedis = getJedis()){
SetParams params = new SetParams().ex(300).nx();
String code = jedis.set("定時任務唯一字元串辨別", "1", params);
// 加鎖成功
if ("OK".equals(code)){
// 這裡做排程正常業務邏輯
doBusiness();
}
}finally{
jedis.del("定時任務唯一字元串辨別");
}
}
複制
這裡直接在
finally
代碼塊中進行KEY的删除,實際上,我們不需要關注這個删除動作是否成功(假如在最後階段删除KEY出現
Redis
服務故障,無論使用
Lua
還是直接删除導緻的結果都是一樣的)。為了避免多餘的
DEL
操作,可以簡單優化為:
// 每30分鐘跑一次
@Scheduled(cron = "* */30 * * * ? ")
public void scheduledMethod(){
boolean lock = false;
try (Jedis jedis = getJedis()){
SetParams params = new SetParams().ex(300).nx();
String code = jedis.set("定時任務唯一字元串辨別", "1", params);
// 加鎖成功
if ("OK".equals(code)){
lock = true;
// 這裡做排程正常業務邏輯
doBusiness();
}
}finally{
if (lock){
jedis.del("定時任務唯一字元串辨別");
}
}
}
複制
通過
SET RESOURCE_NAME RANDOM_VALUE NX PX 30000
和
Redis
單線程處理的特性,就能避免定時任務重複執行。其實這裡還存在一些隐患:
- 如果一個線程加鎖時候指定的逾時時間很長,并且在跑到
代碼塊之前由于不可抗因素(例如很多人喜歡提到的斷電)中斷導緻鎖沒有釋放,那麼這個鎖就相當于一個僵屍鎖。finally
- 鎖的持有和鎖的釋放應該由同一個操作者進行,否則操作者A進行了加鎖,如果有惡意操作者B進行解鎖,那麼會導緻鎖并不安全。
上面這些隐患在
Redisson
中都有對應的解決方案,遲點分析一下
Redisson
的源碼實作。
小結
本文在改造一個老系統的時候嘗試使用改動最小的方式進行簡易的基于
Redis
實作的分布式鎖優化,實際生産環境中應該盡量使用主流的可靠的類庫,如
Redisson
(編寫本文的時候Github的星星數已經超過10200,送出和Issue都比較活躍,遇到坑了比較容易找到解決方案,值得信賴)。如果需要造輪子,那麼就需要熟練使用中間件提供的API,同時注意一下編碼規範,盡可能避免因為規範和使用方式不當帶來的問題。
附件
- Markdown檔案:https://github.com/zjcscut/blog-article-file/tree/master/20190815/middleware-redis-set-command-distributed-lock