前言
內部系統基於Quartz做了定時調度模塊。該模塊不定期出現重復調度問題。此問題比較復雜,經常報警,且沒有規律。
2019年就開始出現較多的問題,至今2021年,對此問題才得到比較清晰和完整的結論。
過程
最初的策略
增加未調度提醒,增加重復調度提醒。(畢竟這看起來是兩個問題。)
后續策略
加強參數優化,監控負載情況,排除負載問題。
Misfire策略改動
修改Misfire Instruction。
Github Issue參考
設置 DisallowConcurrent
設置 acquireWithInLock
最新策略(無奈之舉)
增加Quartz Listener中增加misfire的記錄,嚴格記錄發生時間。
系統化分析
由於已經有了比較完善的日志記錄,根據misfire發生的時間,和調度的時間
根據Quartz源碼的applyMisfire方法找到了Misfire的判定規則,並找到了Misfire發生時進行scheduleJobAPI的調用,
時間上是完全吻合的,則推測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作為基礎應用框架,雖然功能“看起來”比較簡單,但是不要輕視他。
值得花一些時間定位問題。
本文覆蓋的場景不代表全部,不能保證能解決所有重復調度的問題。需要系統化分析。
