相信在日常開發中,基于 redis 天然支持分布式鎖,大家在線上分布式項目中都使用過 Redis 鎖。本文主要針對某些異常場景下,加鎖代碼執行時間超過了加鎖時間,導致任務還沒執行完,但是鎖已經釋放的問題進行講解并給出實踐代碼。本文版本說明如下:
-
Spring Boot
版本 3.0.2 -
演示項目地址:https://Github.com/wayn111/newbee-mall-pro -
github地址:http://github.com/wayn111 歡迎大家關注,點個star
一、過期時間
一般情況下我們加鎖時,都會指定過期時間參數,當任務執行時間超過了鎖過期時間,下一個任務進來時就會獲取到鎖,造成異常。
針對過期時間常見有兩種處理方法:
-
自動續期:鎖快到期時,通過定時任務自動續期 -
加鎖不設置過期時間:任務不執行完,鎖就不會過期
這里博主給出自己的分析:
第一種方案:當設置了過期時間后,如果還執行自動續期操作,那么這個鎖的實際過期時間就與我們在加鎖時設置的過期時間不符合,產生了邏輯上的沖突!所以博主認為自動續期操作對已經設置了過期時間的鎖不適用。
第二種方案:加鎖不設置過期時間的話,理論上好像是可以解決這個問題,任務不執行完,鎖就不會釋放。但是實際針對一些極端異常場景下,如果任務執行過程中,服務器宕機、網絡斷連等都可能造成鎖釋放不了,比如加鎖成功了,執行中發生了宕機,程序直接沒了,但是鎖還在,另一個任務就一直獲取不到鎖。
綜合來看:博主認為如果加鎖代碼需要添加過期時間,其實不需要進行自動續期操作。當我們需要確保當前任務沒執行完,下一個任務一定不能獲取到鎖時,可以不設置過期時間。
那怎么避免第二種方案中,異常場景下,鎖一直未釋放的問題嘞?
答案是在加鎖成功時,如果沒有指定過期時間,則給一個默認過期時間比如三十秒,通過定時任務給我們的鎖進行自動續期,這樣就既可以解決鎖一直未釋放的問題,又能保證下一任務獲取不到當前任務的鎖。
二、 代碼實踐
2.1 加鎖
首先看加鎖操作,如果不指定過期時間,則會指定默認過期時間,通過 lua
腳本加鎖
private String buildLuaLockScript() {
return """
local key = KEYS[1]
local value = ARGV[1]
local time_out = ARGV[2]
local result = redis.call('setnx', key, value)
if tonumber(result) == 1 then
redis.call('expire', key, time_out)
return 1;
else
return 0;
end
""";
}
成功后,啟動一個定時任務每隔 默認過期時間 / 3
秒后執行一次續期操作
@Autowired
public RedisTemplate redisTemplate;
public static final Integer DEFAULT_TIME_OUT = 30;
private ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
private ThreadLocal<ExecutorService> executorServiceThreadLocal = new ThreadLocal<>();
/**
* 加鎖,不指定過期時間
*
* @param key key名稱
* @return boolean
*/
public boolean lock(String key) {
return lock(key, null);
}
/**
* 加鎖
*
* @param key key名稱
* @param timeout 過期時間
* @return boolean
*/
public boolean lock(String key, Integer timeout) {
Integer timeoutTmp = timeout;
if (timeout == null) {
timeoutTmp = DEFAULT_TIME_OUT;
}
String nanoId = IdUtil.nanoId();
stringThreadLocal.set(nanoId);
RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaLockScript(), Long.class);
Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId, timeoutTmp);
boolean flag = execute != null && execute == 1;
if (flag && timeout <= 0) {
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
executorServiceThreadLocal.set(scheduledExecutorService);
scheduledExecutorService.scheduleWithFixedDelay(() -> {
RedisScript<Long> renewRedisScript = new DefaultRedisScript<>(buildLuaRenewScript(), Long.class);
Long result = (Long) redisTemplate.execute(renewRedisScript, Collections.singletonList(key), nanoId, DEFAULT_TIME_OUT);
if (result != null && result == 2) {
ThreadUtil.shutdownAndAwAItTermination(scheduledExecutorService);
}
}, 0, 10, TimeUnit.SECONDS);
}
return flag;
}
lua
續期腳本如下:
private String buildLuaRenewScript() {
return """
local key = KEYS[1]
local value = ARGV[1]
local timeout = ARGV[2]
local result = redis.call('get', key)
if result ~= value then
return 2;
end
local ttl = redis.call('ttl', key)
if tonumber(ttl) < tonumber(timeout) / 2 then
redis.call('expire', key, timeout)
return 1;
else
return 0;
end
""";
}
當發現鎖過期剩余時間小于默認超時時間時,重新賦值過期時間。
2.1 釋放鎖:
public boolean unLock(final String key) {
String nanoId = stringThreadLocal.get();
RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaUnLockScript(), Long.class);
Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId);
boolean flag = execute != null && execute == 1;
if (flag) {
if (executorServiceThreadLocal.get() != null) {
ThreadUtil.shutdownAndAwaitTermination(executorServiceThreadLocal.get());
}
}
return flag;
}
private String buildLuaUnLockScript() {
return """
local key = KEYS[1]
local value = ARGV[1]
local result = redis.call('get', key)
if result ~= value then
return 0;
else
redis.call('del', key)
end
return 1;
""";
}
到這里我們就全部完成了不設置超時時間的自動續期以及鎖釋放操作。
三、總結
簡而言之,博主認為對于主動設置了過期時間的鎖不應該再進行續期操作,我們通過加鎖時不設置過期時間(指定默認超時時間),添加自動續期邏輯,可以比較完美的解決鎖過期但是任務沒執行完的問題。
參考資料
-
https://zhuanlan.zhihu.com/p/524899977