定时任务,有过项目经历的开发者估计都不陌生,是实现一些定时执行重复操作需求的常见解决方案。
在单机的情况下,定时任务当然是越用越爽,简单粗暴直接cron表达式走起就行了,但是在微服务的场景下,要考虑多实例的问题。比如一个定时任务,由于被部署了在多台机器上(或同一台不同端口),这时候,可能会出现定时任务在同一时间被多次执行的问题。
为了保证在同一周期内,只有一个定时任务在执行,其他的不执行,可以采用redis分布式锁、数据库锁、zookeeper锁等方式去实现。
本文采用redis分布式锁的思路去实现。
创建一个springboot项目,导入web依赖包,新建一个定时任务demo,每隔5秒执行一次,代码如下
/**
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 11:58
*/
@Component
@Slf4j
public class TaskDemo {
@Scheduled(cron = "0/5 * * * * *")
public void runTask(){
log.info("机器【1】上的 demo 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
try {
//延迟,模拟业务逻辑
Thread.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
然后分别在 8090端口,和8091端口启动这个项目。模拟多实例的情况,可以看到他们分别的执行结果如下:
A 服务器
B 服务器
如上可以看到,在同一个启动时间点,两台服务器上的同个定时任务都执行了。这显然不太符合需求。
- 每个定时任务执行之前,先去redis那边获取锁,如果获取到了,则执行代码逻辑,获取失败则直接 return。
- 这个可以使用redis的setNX操作来实现,这个操作是原子性的,不过有个缺陷是没有失效时间,这时如果服务器A拿到锁了,由于宕机或者其他网络不可达情况没有释放掉,则其他的服务器永远拿不到这个锁,存在死锁的情况。
- 所以这里存redis的key是任务的名称,value就是当前的时间戳+锁过期时间。如果其他服务器获取锁失败了,看看上一个锁是否已经过期。如果过期了则直接重新获取锁。
对于加锁和解锁操作,封装成了工具类来实现,代码如下:
PS:这个获取锁的
getLock
方法,第二个参数为锁的过期时间,这里一般设置为在任务预估执行时间
和定时任务周期时间
这个时间段内,如果设置过短,可能会导致锁提前失效,任务还没跑完的问题
/**
* 实现分布式redis锁.
*
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 13:53
*/
@Component
public class RedisLockUtils {
/**
* 锁名称前缀.
*/
public static final String TASK_LOCK_PREFIX = "TASK_LOCK_";
/**
* redis操作.
*/
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 获取分布式redis锁.
* 逻辑:
* 1、使用setNX原子操作设置锁(返回 true-代表加锁成功,false-代表加锁失败)
* 2、加锁成功直接返回
* 3、加锁失败,进行监测,是否存在死锁的情况,检查上一个锁是否已经过期
* 4、如果过期,重新让当前线程获取新的锁。
* 5、这里可能会出现多个获取锁失败的线程执行到这一步,所以判断是否是加锁成功,如果没有,则返回失败
*
* @param taskName 任务名称
* @param lockExpireTime 锁的过期时间
* @return true-获取锁成功 false-获取锁失败
*/
public Boolean getLock(String taskName, long lockExpireTime) {
//锁的名称:前缀 + 任务名称
String lockName = TASK_LOCK_PREFIX + taskName;
return (Boolean) redisTemplate.execute((RedisCallback<?>) connection -> {
// 计算此次过期时间:当前时间往后延申一个expireTIme
long expireAt = System.currentTimeMillis() + lockExpireTime + 1;
// 获取锁(setNX 原子操作)
Boolean acquire = connection.setNX(lockName.getBytes(), String.valueOf(expireAt).getBytes());
// 如果设置成功
if (Objects.nonNull(acquire) && acquire) {
return true;
} else {
//防止死锁,获取旧的过期时间,与当前系统时间比是否过期,如果过期则允许其他的线程再次获取。
byte[] bytes = connection.get(lockName.getBytes());
if (Objects.nonNull(bytes) && bytes.length > 0) {
long expireTime = Long.parseLong(new String(bytes));
// 如果旧的锁已经过期
if (expireTime < System.currentTimeMillis()) {
// 重新让当前线程加锁
byte[] oldLockExpireTime = connection.getSet(lockName.getBytes(),
String.valueOf(System.currentTimeMillis() + lockExpireTime + 1).getBytes());
//这里为null证明这个新锁加锁成功,上一个旧锁已被释放
if (Objects.isNull(oldLockExpireTime)) {
return true;
}
// 防止在并发场景下,被其他线程抢先加锁成功的问题
return Long.parseLong(new String(oldLockExpireTime)) < System.currentTimeMillis();
}
}
}
return false;
});
}
/**
* 删除锁.
* 这里可能会存在一种异常情况,即如果线程A加锁成功
* 但是由于io或GC等原因在有效期内没有执行完逻辑,这时线程B也可拿到锁去执行。
* (方案,可以加锁的时候新增当前线程的id作为标识,释放锁时,判断一下,只能释放自己加的锁)
*
* @param lockName 锁名称
*/
public void delLock(String lockName) {
// 直接删除key释放redis锁
redisTemplate.delete(lockName);
}
}
逻辑已经在注释里写得很清楚了,这里不再累述。
还是同样的demo,不过这里的定时任务逻辑可能要改动一下了,执行逻辑之前先去redis获取一下锁,只有获取锁成功了才可执行。
更改后的 demo 代码如下:
/**
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 11:58
*/
@Component
@Slf4j
public class TaskDemo {
@Autowired
private RedisLockUtils redisLockUtils;
@Scheduled(cron = "0/5 * * * * *")
public void start(){
try {
Boolean lock = redisLockUtils.getLock("test", 3000);
//获取锁
if (lock) {
log.info("机器【1】上的 demo 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
//延迟一秒
Thread.sleep(1000);
}else {
log.error("获取锁失败了,此次不执行!");
}
}catch (Exception e){
log.info("获取锁异常了!");
}finally {
//释放redis锁
redisLockUtils.delLock("test");
}
}
}
执行结果如下:
A 服务器
B 服务器
结果:同一个时间间隔里只有一台服务器可以执行,与期望相符。
现在的实现方式,必须要改动定时任务的代码逻辑,添加加锁和解锁的处理代码。这种与业务逻辑的耦合度有点高,不太美观。这里使用
自定义注解
+AOP切面
的方式来让锁处理 和 业务逻辑代码解耦,减少重复的代码。
关于自定义注解介绍,可以看我之前写的一篇文章《Java使用自定义注解》
编写一个自定义注解,有两个属性,一个是定时任务名称,用来标识这个锁,一个是锁的过期时间。
/**
* 自定义redis锁注解.
* 目的:把加锁解锁逻辑与业务代码解耦.
*
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 16:50
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TaskRedisLock {
/**
* 定时任务名称.
*/
String taskName();
/**
* 定时任务锁过期时间.
*/
long expireTime();
}
编写一个AOP切面,对上面定义的自定义注解进行拦截,然后获取到里面的属性的值,最后再通过环绕通知来实现加锁和解锁的逻辑。
切面代码如下
/**
* 定时任务锁切面,对加了自定义redis锁注解的任务进行拦截.
*
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 16:59
*/
@Component
@Aspect
@Slf4j
public class RedisLockAspect {
//加锁工具类
@Autowired
private RedisLockUtils redisLockUtils;
/**
* 拦截自定义的redis锁注解.
*/
@Pointcut("@annotation(zpengblog.taskdemo.aop.custom.TaskRedisLock)")
public void pointCut(){}
/**
* 环绕通知.
*/
@Around("pointCut()")
public Object Around(ProceedingJoinPoint pjp) throws Throwable {
//获取方法
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Method method = methodSignature.getMethod();
//获取方法上的注解
TaskRedisLock annotation = method.getAnnotation(TaskRedisLock.class);
//获取任务名称
String taskName = annotation.taskName();
//获取失效时间
long expireTime = annotation.expireTime();
try {
//获取锁
Boolean lock = redisLockUtils.getLock(taskName, expireTime);
if (lock) {
return pjp.proceed();
}else {
log.error("[{} 任务] 获取redis锁失败了,此次不执行...", taskName);
}
}catch (Exception e){
log.error("[{} 任务]获取锁异常了!", taskName, e);
}finally {
//释放redis锁
redisLockUtils.delLock(taskName);
}
return null;
}
}
/**
* @author linzp
* @version 1.0.0
* CreateDate 2021/1/14 11:58
*/
@Component
@Slf4j
public class TaskDemo {
@Autowired
private RedisLockUtils redisLockUtils;
/**
* 使用自定义TaskRedisLock注解,通过aop来加锁.
*/
@TaskRedisLock(taskName = "task_1", expireTime = 4000)
@Scheduled(cron = "0/5 * * * * *")
public void run(){
log.info("task_1 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
try {
//延迟一秒
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
到此,成功把获取redis锁的逻辑和业务代码解耦,优化就完成啦。
PS:其实这个定时任务还是存在一些缺陷,目前我的场景基本满足了,当然,设计一个高可用,完美的分布式锁来说其实是一个复杂的事情。也有其他现成的方案可用,本文只是作为一个知识积累类共享,欢迎交流
https://blog.csdn.net/wang_jing_jing/article/details/106623112#comments_14608739 [参考资料]