困擾多年的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作為基礎應用框架,雖然功能“看起來”比較簡單,但是不要輕視他。

值得花一些時間定位問題。

本文覆蓋的場景不代表全部,不能保證能解決所有重復調度的問題。需要系統化分析。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM