最近在跟进一个比较老的系统的时候,发现了所有调度任务使用了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("定时任务唯一字符串标识");
}
上面的代码存在如下显然的缺陷:
doBusiness()
抛出了异常,会导致删除KEY的操作无法执行,KEY会到达超时时间后被删除,这个时候相当于加锁时间长达5分钟,显然是无法接受的。但是实际上,以上两个问题在生产环境中并没有出现过,分析一下具体原因是:
0 */30 * * * ?
(0秒开始每30分钟执行一次)就有可能出现并发问题。以上仅仅是巧合的情况下规避了问题出现的因子,但是从编码规范的角度来看显然是存在问题。基于Redis
实现的分布式锁的方案在Redis
官方文档中有一篇文章做了详细的分析-Distributed locks with Redis,对于Java语言来说,有现成的类库Redisson
提供对应的实现。但是在解决这个问题的时候,为了简易起见并没有引入Redisson
,而是想办法通过原来的SET
和DEL
两个操作的相关思路进行优化。
自从Redis
的2.6.12版本起,SET
命令已经提供了可选的复合操作符:
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
O(1)
。可选参数:
EX
:设置超时时间,单位是秒。PX
:设置超时时间,单位是毫秒。NX
:IF NOT EXIST
的缩写,只有KEY不存在的前提下才会设置值。XX
:IF EXIST
的缩写,只有在KEY存在的前提下才会设置值。列举一些等价的命令:
原始命令 | 等价命令 |
---|---|
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
代码块之前由于不可抗因素(例如很多人喜欢提到的断电)中断导致锁没有释放,那么这个锁就相当于一个僵尸锁。上面这些隐患在Redisson
中都有对应的解决方案,迟点分析一下Redisson
的源码实现。
本文在改造一个老系统的时候尝试使用改动最小的方式进行简易的基于Redis
实现的分布式锁优化,实际生产环境中应该尽量使用主流的可靠的类库,如Redisson
(编写本文的时候Github的星星数已经超过10200,提交和Issue都比较活跃,遇到坑了比较容易找到解决方案,值得信赖)。如果需要造轮子,那么就需要熟练使用中间件提供的API,同时注意一下编码规范,尽可能避免因为规范和使用方式不当带来的问题。