背景:
spring项目中我们经常使用定时注解@Scheduled结合cron表达式来完成我们的定时任务需求,这种方式在单点状态下是没有问题的,但是实际使用时,为了高可用,我们经常是多节点部署项目,这时就出现了定时任务被多次执行的问题。
我们当然可以通过统一的配置中心配置项来指定具体执行定时任务的实例,但这样就失去了高可用的效果,并且也不利于后期项目交接与维护。所以需要分布式锁,来解决目前的问题。当然也有一些成型的分布式定时任务方案,如XXL等,这里为了方便,我们基于Redis实现一个简单的分布式锁。
需要:
redis(当然)
redisTemplate/jedis
1. 实现思路
每个任务在执行前,先去redis中获得锁,如果获得到了,才可以正常执行定时任务,所谓获得锁,就是去redis中存放一对kv,如果放置成功了,就认为获得到了锁,由于redis是单线程的,所以本身是线程安全的。
1. 加锁
加锁这个部分需要注意的并不多,主要是利用redis的
SET key value NX PX max-lock-time
也就是在存放kv时,如果key不存在,就放置成功,如果key存在,则存放失败,不予更新,并且在存放时设定一个超时时间,防止发生死锁。这个命令映射到redisTemplate里,是setIfAbsent这个api;在jedis中,直接jedis.set(key, value, “NX”, “PX”, timeout)即可。在设定时,key由自己的业务需求设计,value需要一个唯一值,目的是在释放锁时,能够识别出这个锁确实是自己的锁,防止误释放其它实例的锁。这里我使用UUID。代码:
/**
* 带超时时间锁定
* @param lockKey 要锁定的key
* @param uniqueValue 唯一值(UUID等)
* @param timeout 超时时间
* @return Boolean true:锁定成功 false:锁定失败,已由其它实例获得锁
*/
public Boolean lock(String lockKey, String uniqueValue, Long timeout) {
return redisUtil.setIfAbsent(lockKey, uniqueValue, timeout);
}
需要注意的是,千万不能先去set一对kv,再set超时时间,因为我们通过api分步调用这种方式无法保证原子性,一旦set完kv后,程序崩溃了,这个锁就永远存在于redis中,成为死锁。
2. 释放锁
释放锁时,主要是先去通过value校验身份,再把这对kv从redis中del掉。简单来说就是:
if (get(key) == value) {
del(key)
}
和上锁时一样,我们不能分步完成这个流程,因为我们先去get这个key,再del,无法保证原子性。所以我们使用LUA脚本来完成这个流程,LUA在执行时是可以保证原子性的,代码:
/**
* 释放锁
* @param unlockKey 要解锁的key
* @param verifyValue 用于验证身份的唯一值(要与上锁时的值一致,用于验证身份,防止误释放其它实例的锁)
* @return Boolean true:释放成功 false:释放失败,已由其它实例释放,或不是自己的锁
*/
public Boolean unLock(String unlockKey, String verifyValue) {
Long SUCCESS = 1L;
//使用LUA脚本,保证操作原子性
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object obj = redisUtil.eval(luaScript, Collections.singletonList(unlockKey), Collections.singletonList(verifyValue));
return SUCCESS.equals(obj);
}
3.完整代码
@Component
public class DistributedLockUtil {
@Autowired
RedisUtil redisUtil;
/**
* 带超时时间锁定
* @param lockKey 要锁定的key
* @param uniqueValue 唯一值(UUID等)
* @param timeout 超时时间
* @return Boolean true:锁定成功 false:锁定失败,已由其它实例获得锁
*/
public Boolean lock(String lockKey, String uniqueValue, Long timeout) {
return redisUtil.setIfAbsent(lockKey, uniqueValue, timeout);
}
/**
* 锁定(建议使用带超时时间锁定,防止发生死锁)
* @param lockKey 要锁定的key
* @param uniqueValue 唯一值(UUID等)
* @return Boolean true:锁定成功 false:锁定失败,已由其它实例获得锁
*/
public Boolean lock(String lockKey, String uniqueValue) {
return redisUtil.setIfAbsent(lockKey, uniqueValue);
}
/**
* 释放锁
* @param unlockKey 要解锁的key
* @param verifyValue 用于验证身份的唯一值(要与上锁时的值一致,用于验证身份,防止误释放其它实例的锁)
* @return Boolean true:释放成功 false:释放失败,已由其它实例释放,或不是自己的锁
*/
public Boolean unLock(String unlockKey, String verifyValue) {
Long SUCCESS = 1L;
//使用LUA脚本,保证操作原子性
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object obj = redisUtil.eval(luaScript, Collections.singletonList(unlockKey), Collections.singletonList(verifyValue));
return SUCCESS.equals(obj);
}
}
4.总结
使用时,我们只需要先成一个随机value,然后通过这个value去lock和unlock就可以了,例如:
String eqValue = UUID.randomUUID().toString().replace("-", "").toLowerCase();
distributedLockUtil.lock(RedisKey.key, eqValue, TimeConstant.THREE_HOUR);
//业务代码
...
distributedLockUtil.unLock(RedisKey.key, eqValue);
实际上,如果你的业务和我一样,是为了解决分布式定时问题,而不是同一个实例内部去抢锁,唯一value可以不必真的唯一,比如可以使用ip + port的方法,也能满足需求,这样也可以使api更易于使用。而且在上锁后,只要指定一个合理的超时时间,甚至不需要去释放锁。