背景描述
有小伙伴私信我,关于存在定时任务的项目在集群环境下部署如何解决重复执行的问题。
PS:定时任务没有单独拆分。
概述:之前的项目都是单机器部署,所以定时任务不会重复消费,只会执行一次。而在集群环境部署下,比如两台机器部署了当前的项目,如果不做任何处理的话势必会执行两次,通常重复执行会影响现有数据。所以要解决的就是在某个时间点,只能让一个项目执行这个定时任务。
考察知识点:锁。
正文部分
这个问题最简单的操作方式是啥?
答:那就是一个打包带定时任务,一个打包不带定时任务...
咳咳,开个玩笑。显然这样不行啊,要是用这种操作先不说后面升级时每次打两个包多麻烦,单说这种方式就完全失去了集群部署的意义... 存在单点故障。
如果能找到唯一值的话,其实也是一种解决思路,比如可以通过数据库的唯一索引、或者主键索引来实现等。
下文则主要通过找不到唯一值的情况进行分析。
实现思路:数据库行级锁、redis分布式锁。
前面不是写过 Redis 分布式锁的文章吗,这次正好实践一下。
所以这次的技术选型就用 Redis 分布式锁来解决集群模式下定时任务重复执行的问题。
Redis 分布式锁有两种实现方式,一种是 Redisson+RLock,另一种是 SetNX+Lua脚本实现。
如果不了解的可以看一下下面这两篇文章,内含源码,本文皆以该源码操作。
Redis分布式锁—Redisson+RLock可重入锁实现篇
简单分析:
这两篇 Redis 分布式锁的 demo,主要就是为了解决,在分布式部署中的商品接口避免超卖的情况。简单点说就是,无论用户的下单请求落在哪个服务实例上,首先你要保证顺序性,也就是你不能两个实例的同一方法同时执行业务逻辑,而是同一时间内只能由一个实例完成操作(减库存操作);一个实例完成操作,则另一个才正常往下走。
和定时任务重复执行的问题有点类似了,但是与本文模拟的例子还是有一点点区别的,一个实例执行了定时任务,而另一个实例的定时任务是不能再继续执行业务代码的,因为换做以前可以通过商品的库存来进行判断,然后return掉,但是现在的情况是找不到唯一值,或者说找不到判定的条件,如果直接套上之前的代码,那么是没法阻止另一个实例定时任务执行的。
如下是之前 RLock 示例,用户下单的方法:
这里面有个判断库存的地方,大家可以看一下注释,定时任务遇到的问题。
@Transactional(rollbackFor = Exception.class)
public boolean createOrder(String userId, String productId) {
/** 如果不加锁,必然超卖 **/
RLock lock = redissonClient.getLock("stock:" + productId);
try {
/** 这一步相当于锁住,串连 **/
lock.lock(10, TimeUnit.SECONDS);
/*
* 第一个实例执行完或者说锁在10秒后释放后,第二个实例永远也会走到下面这一步
* 无非就是在之前的例子中可以判断库存的形式进行返回,但是定时任务不行,
* 商品可以通过库存来判断,但是定时任务做不到,
* 所以加下来就是对当前这段代码进行改造。
*/
int stock = stockService.get(productId).getStockNum();
log.info("剩余库存:{}", stock);
if (stock <= 0) {
return false;
}
String orderNo = UUID.randomUUID().toString().replace("-", "").toUpperCase();
/** 减库存操作 **/
if (stockService.decrease(productId)) {
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setOrderNo(orderNo);
Date now = new Date();
order.setCreateTime(now);
order.setUpdateTime(now);
orderDao.save(order);
return true;
}
} catch (Exception ex) {
log.error("下单失败", ex);
} finally {
lock.unlock();
}
return false;
}
1、SETNX+Lua脚本实现篇
至于 Lua 脚本怎么写的我就不在这赘述了,大家可以翻看上面的文章链接。
直接从代码下手,没什么变化,方法后面说一下过程。
@Scheduled(cron = "0 47 23 * * ?")
public void generateData() {
/** 定时任务的名称作为key **/
String key = "generateData";
/** 设置随机key **/
String value = UUID.randomUUID().toString().replace("-", "");
/*
* setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
* set expire time 20 s
*/
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
if (flag != null && flag) {
log.info("{} 锁定成功,开始处理业务", key);
try {
/** 模拟处理业务逻辑,15秒 **/
Thread.sleep(1000 * 15);
} catch (InterruptedException e) {
e.printStackTrace();
}
/*
* 业务逻辑处理完毕,释放锁,正常情况下,由于上边 setIfAbsent 已经设置过期时间了,
* 所以在规定时间内,Redis 会自动删除过期的 key,但是这个删除由于不确实是什么删除策略,
* 所以最后执行完再删除一遍比较保险。
*/
String lockValue = (String) redisTemplate.opsForValue().get(key);
/** 只有:值未被释放(也就是当前未达到过期时间),且是自己加锁设置的值(不要释放别人的所),这种情况下才会释放锁 **/
if (lockValue != null && lockValue.equals(value)) {
System.out.println("lockValue========:" + lockValue);
List<String> keys = new ArrayList<>();
keys.add(key);
Long execute = redisTemplate.execute(script, keys, lockValue);
System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);
log.info("{} 解锁成功,结束处理业务", key);
}
} else {
log.info("{} 获取锁失败", key);
}
}
首先方法顶部是一个 cron 的表达式,在每天的 23 点 47 分执行。
核心部分仍是 setIfAbsent() 方法,在这设置了一个 20 秒的过期时间,过期时间一到,默认会对 key 进行删除操作。
这个方法是个原子操作,所以两个实例同时执行的话,会产生锁竞争,返回的 Boolean 类型的 flag 即表示加锁状态。
为 true 表示获取锁成功,则另一个实例,或者另外所有的实例都会获取锁失败,即 flag = false 走 else 逻辑。
中间模拟了个 15 秒的业务执行,如果业务逻辑执行时间超过设置的 key 的过期时间,则 redisTemplate.opsForValue().get(key) 拿到的可能为 null 或者不一定为 null,为 null 说明 redis 自动触发了删除操作,不为 null 则虽然 key 值过期了,但是并没有立刻删除。
所以这种情况就需要删除一下。
删除也是一个小的细节,怎么讲?代码删除之前一定要判断是否是当前线程设置的 value,否则会出现释放别的线程锁的情况。
这个地方可能比较绕。
举个例子:比如A、B线程同时进入该方法执行,从 setIfAbsent() 方法加锁,到处理业务业务代码15秒一切都很正常,此过程也只会有一个线程获得锁,另一个线程有 else 操作。但是需要注意的是,你没法保证两个定时任务同时执行,???因为你无法保证两台机器的时间永远一直,也就是会出现误差,这种情况就很恶心了,所以在设置 value 的时候用的是随机参数,这有个好处就是在删除之前先从 redis 再查询一遍,一致就删除释放锁,不一致就不释放。
2、Redisson + RLock
上面的问题代码贴过了,修改后如下:
@Scheduled(cron = "0 21 14 * * ?")
public void test(){
RLock lock = redissonClient.getLock("test");
/** 加锁状态 **/
boolean flag = false;
try {
flag = lock.tryLock(10,20, TimeUnit.SECONDS);
if(flag){
log.info("加锁成功,开始执行业务");
try {
log.info("模拟处理业务逻辑");
/** 模拟处理业务逻辑,15秒 **/
Thread.sleep(1000 * 15);
} catch (InterruptedException e) {
e.printStackTrace();
}
}else{
log.info("加锁失败,没有获取到锁");
}
} catch (Exception ex) {
log.error("下单失败", ex);
} finally {
if(!flag){
return;
}
lock.unlock();
log.info("Redisson分布式锁释放锁");
}
}
简单分析一下代码。
核心代码主要是 lock.tryLock(0,20, TimeUnit.SECONDS),tryLock 方法有好几个重载方法,在上篇 [Redisson + RLock] 分布式锁中有写过,而今天我们用的是带三个参数的 tryLock。
/**
* 这里比上面多一个参数,多添加一个锁的有效时间
*
* @param waitTime 等待时间
* @param leaseTime 锁有效时间
* @param unit 时间单位 小时、分、秒、毫秒等
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
方法解释:在尝试获取锁时,如果被其他线程先拿到锁则会进入等待状态,等待 waitTime 时间后,如果还没用机会获取到锁就放弃,返回 false;如果获得了锁,除非是调用 unlock 释放,否则会一直持有锁,一直持有到超过 leaseTime 时间后自动释放锁。
套入解释:线程尝试加锁,但最多等待 10 秒,上锁以后 20 秒后自动释放锁,返回 true 表示加锁成功,返回 false 则表示加锁失败。
细节补充:需要注意的是,在 finally 释放锁的时候,一定要判断当前的线程是否持有锁,只有在持有锁的情况下才能释放锁,否则会造成释放别的线程的锁。
其实这个地方单单靠否持有锁 flag 标志还是会存在问题。
前面也有提到了服务器时间不一致的问题,但是正常情况下,这个误差不会太大,但假如说,如果误差超过业务逻辑执行的时间或者设置的锁有效时间,那么问题就很明显了,第一个实例执行完,无论是自己释放的锁,还是20秒后自动释放的锁,都会出现重复执行的问题。
最后补充
无论是采用 Redisson+RLock 还是 SetNX+Lua,在一定程度上确实可以解决集群部署下,定时任务重复执行的问题。
但是从严谨性来看,并不代表不会出现问题。
1、首先 Redis 分布式锁依赖的是 Redis 集群,如果不是使用 Redis 集群的小伙伴,建议理性选择如上方案,毕竟单机 Redis 挂了,那么定时任务这块的代码基本也就挂了。
2、使用了 Redis 集群还是会存在故障重启带来的锁的安全性问题。
我在之前的文中有提到过,master / slave 主从节点切换导致数据丢失的情况,为了解决这种情况如果加入了持久化操作,任然会存在锁的安全性问题,比如节点重启~
3、上面这1、2项都是说的Redis自身的问题,再就是服务器本身的时间差问题。
如果服务器的时间出现误差的话,那么就需要考虑释放锁的这一步骤了,我们可以尽量的选择使用自动的过期时间,而不是自己通过代码去释放锁,因为不同于别的接口,如果是一个正常的接口的话,你长时间的(过期时间)占着锁不释放,那么肯定是有问题的,相当于这个接口在这段时间内就是挂掉了。但是对于定时任务就不一样了,通常定时任务是每隔多长时间执行一次,或者说一天就执行一次,那么我们就可以考虑在过期时间或者等待时间上做功夫了。
比如定时任务每天就执行一次,但是又怕服务器存在时间差,那么就可以选择一个2小时的过期时间,总不能误差超过2小时吧?
再就是并不是不能保证服务器时间存在误差的问题。
PS:既然有问题,那么 Redis 分布式还可选吗?
可选,其实关于Redis分布式锁,在很多商城项目中也有应用,考虑好误删、原子性、超时等待等情况是没什么问题的。
如果对数据要求比较高则可以考虑 Zookeeper 分布式锁。后面会准备码一下 Zookeeper 锁相关的 demo。