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 算 misfirethreadCount如果设成 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 状态不自愈"三件事叠在一起。
最终解决:三步改完,第二天任务恢复正常:
- SQL 层面:结算查询大表时避免长事务,分批取数(每批 2000 条),把单事务时长压到 10 秒内;
- 调度层面:给关键任务加
Redis 分布式锁(上面的方案 B),并注册JobListener监听jobWasExecuted,捕获异常时手动把 trigger 状态复位:
// 复位:任务失败自动把 trigger 恢复为可调度scheduler.resumeTrigger(TriggerKey.triggerKey("settlementTrigger"));- 监控层面:给任务执行加成功/失败埋点,失败告警到企业微信——不能等运营早上来问。
可以直接抄的结论
- misfire 策略按业务选:日结类用 DoNothing,日报类用 FireAndProceed,IgnoreMisfires 慎用;
threadCount别设 1,misfireThreshold别小于 10s;- 关键任务加
@DisallowConcurrentExecution(单机)或 Redis 锁(集群),锁要带 token 防误删、过期时间大于任务最大时长; - 任务失败要监听
jobWasExecuted并复位 trigger,否则 ERROR 状态会"死"到天荒地老; - 大表操作避免长事务,分批取数,把锁等待风险压下去。
定时任务不执行,十次里八次不是 Cron 写错,是调度器配置和任务失败处理的问题。把 misfire、并发、失败恢复三条线理清楚,定时任务才能真正"定时"。
项目源码:https://gitee.com/gzqkl/qkl-boot