ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Quartz 定时任务到点不执行?misfire 策略与并发锁复盘

Quartz 定时任务到点不执行?misfire 策略与并发锁复盘

Quartz 定时任务到点不执行?misfire 策略与并发锁复盘

导读:早上九点运营等着结算任务跑完,结果任务没触发,日志里一片安静。查了半天,问题不在代码,在 Quartz 的 misfire 策略和线程池配置。定时任务"不执行",十有八九不是 Cron 写错,而是调度器自己吞了。

Quartz 的 misfire(错失触发)机制,一句话:任务该跑的时候没跑,调度器会在恢复后按策略补偿。策略选错,轻则任务不跑,重则补跑把线上数据搞乱。加上 Quartz 默认允许同一个 job 并发执行,两个 trigger 实例一起跑,更是事故温床。

先看配置:这两个参数定生死

qkl-boot 的定时任务配置里,overwriteExistingJobs和线程池大小,是很多人忽略的地方:

# application.ymlspring:quartz:job-store-type:jdbc# 用 JDBC 存储,支持集群overwrite-existing-jobs:true# 同名 job 已存在时覆盖,防止重复注册properties:org.quartz.scheduler.instanceName:QklSchedulerorg.quartz.scheduler.instanceId:AUTOorg.quartz.threadPool.threadCount:8# 线程池大小,坑在这org.quartz.threadPool.threadPriority:5org.quartz.jobStore.isClustered:true# 集群模式org.quartz.jobStore.clusterCheckinInterval:20000org.quartz.jobStore.misfireThreshold:60000# 超时 60s 算 misfire

threadCount如果设成 1,所有任务排队跑,一个任务卡住,后面全堵——这是"任务没执行"的一个隐蔽原因。另一个是misfireThreshold:任务被延迟超过这个阈值才记 misfire,阈值设太小(比如 3000ms),网络抖动一下就把正常任务误判成 misfire,触发补跑。

Job 与 Trigger 的正确姿势

Spring 里注册 Quartz Job 有两种路子:继承QuartzJobBean,或者直接声明 JobDetail + Trigger Bean。qkl-boot 用的是后者,把调度参数外置到配置,后台可配:

@ConfigurationpublicclassQuartzConfig{@BeanpublicJobDetailsettlementJobDetail(){returnJobBuilder.newJob(SettlementJob.class).withIdentity("settlementJob").storeDurably()// 即使没有 trigger 也保留 job.requestRecovery()// 集群中执行时进程挂了,别处恢复执行.build();}@BeanpublicTriggersettlementTrigger(@Qualifier("settlementJobDetail")JobDetaildetail){returnTriggerBuilder.newTrigger().forJob(detail).withIdentity("settlementTrigger").withSchedule(CronScheduleBuilder.cronSchedule("0 30 1 * * ?")// 每天 01:30 跑结算.withMisfireHandlingInstructionDoNothing())// 错过就不补.build();}}

Misfire 策略三选一,按业务性质定:

  • withMisfireHandlingInstructionDoNothing():错过就错过,不补跑。适合"今天的结算今天必须算,昨天没算今天补没意义"——补跑反而会把两天的数据算混。qkl-boot 的日结结算用的是这个。
  • withMisfireHandlingInstructionFireAndProceed():立即补跑一次,然后按原计划继续。适合"日报生成"这种补跑有意义的任务。
  • withMisfireHandlingInstructionIgnoreMisfires():把错过的都补完。慎用!错过 10 次补 10 次,数据量大的任务能把自己跑挂。

并发执行:同一个 Job 会同时跑两个实例

Quartz 默认不禁止同一个 JobDetail 的并发执行。调度器宕机重启、集群里两台机器同时调度,都可能让同一个 job 的两个实例同时跑。对"扣钱、发消息、生成结算单"这类任务,并发跑一遍就是灾难。两个解法:

方案 A:@DisallowConcurrentExecution(单机/单调度器推荐)

@DisallowConcurrentExecutionpublicclassSettlementJobextendsQuartzJobBean{@OverrideprotectedvoidexecuteInternal(JobExecutionContextcontext){// 上一个实例没跑完,这个实例不会启动doSettlement();}}

方案 B:分布式锁(集群场景必上)

@DisallowConcurrentExecution只在同一个调度器实例内生效,多节点部署时各节点的 scheduler 是独立的,注解管不住跨节点并发。集群里要靠 Redis 锁兜底:

@DisallowConcurrentExecutionpublicclassSettlementJobextendsQuartzJobBean{@AutowiredprivateStringRedisTemplateredis;@OverrideprotectedvoidexecuteInternal(JobExecutionContextcontext){Stringkey="lock:settlement:20261013";Stringtoken=UUID.randomUUID().toString();Booleanok=redis.opsForValue().setIfAbsent(key,token,30,TimeUnit.MINUTES);if(Boolean.FALSE.equals(ok)){log.warn("结算任务已被其他节点执行,跳过。node={}",context.getFireInstanceId());return;}try{doSettlement();}finally{// 只释放自己拿到的锁,防误删别人的锁Stringcur=redis.opsForValue().get(key);if(token.equals(cur))redis.delete(key);}}}

锁过期时间要大于任务最大执行时长——任务跑了 40 分钟,锁 30 分钟就过期了,另一个节点又能抢到锁,等于没锁。加锁和解锁必须配套,finally 里释放,token 校验防误删。

踩坑实录:凌晨 1 点 30 的结算任务,一连三天没跑

现象:运营连着三天早上查账,发现昨天的结算单没生成,后台任务列表显示"上次执行时间"还是三天前。任务没报错,日志里连一条异常都没有。

排查过程:先看任务列表的 trigger 状态——Quartz 管理界面里 trigger 显示ERROR,不是NORMAL。再查日志,找到关键行:

2026-10-10 01:30:00.001 ERROR o.s.s.quartz.LocalDataSourceJobStore - Couldn't fire trigger ... job will be executed at next interval Caused by: org.quartz.SchedulerException: Job threw an unhandled exception: java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction

也就是说任务其实触发了,但执行中报"锁等待超时",Quartz 认为它执行失败,把 trigger 状态置成ERROR。而misfireThreshold=60000且策略是 DoNothing——misfire 被判定后直接放弃,后面的周期也不再自动恢复,任务就"死"在 ERROR 状态里。

定位思路:任务本身没问题,是执行环境和 SQL 冲突。结算任务深夜跑,和大批量的对账 SQL 抢 InnoDB 行锁,锁等待超过innodb_lock_wait_timeout(默认 50s)抛异常。Quartz 捕获异常后不重试,trigger 变 ERROR 卡死。根因是"任务执行失败 + misfire 策略 DoNothing + ERROR 状态不自愈"三件事叠在一起。

最终解决:三步改完,第二天任务恢复正常:

  1. SQL 层面:结算查询大表时避免长事务,分批取数(每批 2000 条),把单事务时长压到 10 秒内;
  2. 调度层面:给关键任务加Redis 分布式锁(上面的方案 B),并注册JobListener监听jobWasExecuted,捕获异常时手动把 trigger 状态复位:
// 复位:任务失败自动把 trigger 恢复为可调度scheduler.resumeTrigger(TriggerKey.triggerKey("settlementTrigger"));
  1. 监控层面:给任务执行加成功/失败埋点,失败告警到企业微信——不能等运营早上来问。

可以直接抄的结论

  1. misfire 策略按业务选:日结类用 DoNothing,日报类用 FireAndProceed,IgnoreMisfires 慎用;
  2. threadCount别设 1,misfireThreshold别小于 10s;
  3. 关键任务加@DisallowConcurrentExecution(单机)或 Redis 锁(集群),锁要带 token 防误删、过期时间大于任务最大时长;
  4. 任务失败要监听jobWasExecuted并复位 trigger,否则 ERROR 状态会"死"到天荒地老;
  5. 大表操作避免长事务,分批取数,把锁等待风险压下去。

定时任务不执行,十次里八次不是 Cron 写错,是调度器配置和任务失败处理的问题。把 misfire、并发、失败恢复三条线理清楚,定时任务才能真正"定时"。

项目源码:https://gitee.com/gzqkl/qkl-boot

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进