困扰多年的Quartz重复调度的问题,终于找到原因


前言

内部系统基于Quartz做了定时调度模块。该模块不定期出现重复调度问题。此问题比较复杂,经常报警,且没有规律。
2019年就开始出现较多的问题,至今2021年,对此问题才得到比较清晰和完整的结论。

过程

最初的策略

增加未调度提醒,增加重复调度提醒。(毕竟这看起来是两个问题。)

后续策略

加强参数优化,监控负载情况,排除负载问题。

Misfire策略改动

修改Misfire Instruction。

Github Issue参考

设置 DisallowConcurrent
设置 acquireWithInLock

最新策略(无奈之举)

增加Quartz Listener中增加misfire的记录,严格记录发生时间。

系统化分析

由于已经有了比较完善的日志记录,根据misfire发生的时间,和调度的时间

根据Quartz源码的applyMisfire方法找到了Misfire的判定规则,并找到了Misfire发生时进行scheduleJobAPI的调用,

时间上是完全吻合的,则推测Misfire和scheduleJob的API有关。

分析

Quartz重复调度的原因(Cluster模式):

  1. Quartz Issue #107
  2. 错误的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和自然调度时间点的间隔。

结论

  1. 应当在复杂的并发条件下使用锁:
  2. 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作为基础应用框架,虽然功能“看起来”比较简单,但是不要轻视他。

值得花一些时间定位问题。

本文覆盖的场景不代表全部,不能保证能解决所有重复调度的问题。需要系统化分析。