困扰多年的Quartz重复调度的问题,终于找到原因
前言
内部系统基于Quartz做了定时调度模块。该模块不定期出现重复调度问题。此问题比较复杂,经常报警,且没有规律。
2019年就开始出现较多的问题,至今2021年,对此问题才得到比较清晰和完整的结论。
过程
最初的策略
增加未调度提醒,增加重复调度提醒。(毕竟这看起来是两个问题。)
后续策略
加强参数优化,监控负载情况,排除负载问题。
Misfire策略改动
修改Misfire Instruction。
Github Issue参考
设置 DisallowConcurrent
设置 acquireWithInLock
最新策略(无奈之举)
增加Quartz Listener中增加misfire的记录,严格记录发生时间。
系统化分析
由于已经有了比较完善的日志记录,根据misfire发生的时间,和调度的时间
根据Quartz源码的applyMisfire
方法找到了Misfire的判定规则,并找到了Misfire发生时进行scheduleJob
API的调用,
时间上是完全吻合的,则推测Misfire和scheduleJob
的API有关。
分析
Quartz重复调度的原因(Cluster模式):
- Quartz Issue #107
- 错误的Misfire导致错误的重跑。
Quartz Issue #107
该问题原因比较复杂,参见Issue原文,做法就是添加注解或者相应配置:
acquireTriggersWithinLock=true
不正确使用Quartz API 导致的错误Misfire
可使用TriggerListener的API,监听,并结合所有调用Quartz API的调用打点分析:
org.quartz.TriggerListener#triggerMisfired
这里,经过观察,发现有一种场景比较常见:
即:经常对QuartzSchedule进行变更,且使用同一个triggerKey
根据Quartz的API源码:
org.quartz.Scheduler#scheduleJob(org.quartz.JobDetail, org.quartz.Trigger):
和
//org.quartz.impl.triggers.CronTriggerImpl.java
@Override
public Date computeFirstFireTime(org.quartz.Calendar calendar) {
nextFireTime = getFireTimeAfter(new Date(getStartTime().getTime() - 1000l));
while (nextFireTime != null && calendar != null
&& !calendar.isTimeIncluded(nextFireTime.getTime())) {
nextFireTime = getFireTimeAfter(nextFireTime);
}
return nextFireTime;
}
这里会根据getStartTime生成一个CronExpression的下一个执行时间。
如果startTime设置的是一个比较早的时间,则生成的nextFireTime会早于 now - threshold
经过层层调用
-> org.quartz.spi.JobStore#acquireNextTriggers
-> org.terracotta.quartz.DefaultClusteredJobStore#acquireNextTriggers
-> org.terracotta.quartz.DefaultClusteredJobStore#getNextTriggerWrappers
-> org.terracotta.quartz.DefaultClusteredJobStore#applyMisfire
执行applyMisfire的时候,如果满足
getNextFireTime + threshold < now
则导致 misFire触发,此时再根据Misfire Instruction
判定是否重复触发,假如
Misfire Instruction
=org.quartz.CronTrigger#MISFIRE_INSTRUCTION_FIRE_ONCE_NOW
,
则导致定时任务重复调度。
此处分析,对应观察到重复调度时间间隔,取决于调用Scheduler#scheduleJob
和自然调度时间点的间隔。
结论
- 应当在复杂的并发条件下使用锁:
- Quartz API 构建Trigger应当使用正确的API
//Job&Trigger Key
JobKey jobKey = KeyUtil.jobKey(job);
TriggerKey triggerKey = KeyUtil.triggerKey(job, schedule);
//创建 触发器
TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger()
.withIdentity(triggerKey).forJob(jobKey)
.startAt(schedule.getStartTime()); //注意此处的时间非常重要!!
验证
LocalDateTime parse = LocalDateTime.parse("2021-11-30T15:00:00+08:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME);
Instant hongkong = parse.toInstant(ZoneOffset.ofHours(8)).plusSeconds(1L);
Date from = Date.from(hongkong);
CronTrigger trigger = TriggerBuilder.newTrigger()
.startAt(from)
.withDescription("测试NextFireTime@BySlankka")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0/1 * * * ? *")
.inTimeZone(TimeZone.getTimeZone("Asia/Shanghai")))
.build();
;
Date nextFireTime = ((OperableTrigger) trigger).computeFirstFireTime(null);
String format = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
.withLocale(Locale.getDefault())
.format(nextFireTime.toInstant());
System.out.println(format);
输出结果
2021-11-30 15:01:00
这个时间:无论什么时候执行,都是根据Cron表达式求解的下一个时间:那么一定是过去的时间,从而已经会导致misfire。
后记
Quartz作为基础应用框架,虽然功能“看起来”比较简单,但是不要轻视他。
值得花一些时间定位问题。
本文覆盖的场景不代表全部,不能保证能解决所有重复调度的问题。需要系统化分析。