相信在日常开发中,基于 redis 天然支持分布式锁,大家在线上分布式项目中都使用过 Redis 锁。本文主要针对某些异常场景下,加锁代码执行时间超过了加锁时间,导致任务还没执行完,但是锁已经释放的问题进行讲解并给出实践代码。本文版本说明如下:
Spring Boot
版本 3.0.2一般情况下我们加锁时,都会指定过期时间参数,当任务执行时间超过了锁过期时间,下一个任务进来时就会获取到锁,造成异常。
针对过期时间常见有两种处理方法:
这里博主给出自己的分析:
第一种方案:当设置了过期时间后,如果还执行自动续期操作,那么这个锁的实际过期时间就与我们在加锁时设置的过期时间不符合,产生了逻辑上的冲突!所以博主认为自动续期操作对已经设置了过期时间的锁不适用。
第二种方案:加锁不设置过期时间的话,理论上好像是可以解决这个问题,任务不执行完,锁就不会释放。但是实际针对一些极端异常场景下,如果任务执行过程中,服务器宕机、网络断连等都可能造成锁释放不了,比如加锁成功了,执行中发生了宕机,程序直接没了,但是锁还在,另一个任务就一直获取不到锁。
综合来看:博主认为如果加锁代码需要添加过期时间,其实不需要进行自动续期操作。当我们需要确保当前任务没执行完,下一个任务一定不能获取到锁时,可以不设置过期时间。
那怎么避免第二种方案中,异常场景下,锁一直未释放的问题嘞?
答案是在加锁成功时,如果没有指定过期时间,则给一个默认过期时间比如三十秒,通过定时任务给我们的锁进行自动续期,这样就既可以解决锁一直未释放的问题,又能保证下一任务获取不到当前任务的锁。
首先看加锁操作,如果不指定过期时间,则会指定默认过期时间,通过 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
""";
}
当发现锁过期剩余时间小于默认超时时间时,重新赋值过期时间。
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;
""";
}
到这里我们就全部完成了不设置超时时间的自动续期以及锁释放操作。
简而言之,博主认为对于主动设置了过期时间的锁不应该再进行续期操作,我们通过加锁时不设置过期时间(指定默认超时时间),添加自动续期逻辑,可以比较完美的解决锁过期但是任务没执行完的问题。
参考资料