Quartz
Quartz 是 Java 生态里非常成熟的任务调度框架。它解决的不是“写一个定时方法”,而是把任务、触发时间、执行状态、错过触发、持久化和集群协调都抽象出来,让任务可以更稳定地在 Java 应用里运行。
零基础可以先这样理解:
@Scheduled像一个本地闹钟,Quartz 像一个带数据库记忆和集群协调能力的任务调度器,XXL-JOB 像一个带控制台、执行器治理、日志和告警的分布式调度平台。
学习目标
学完本页你应该能回答:
- Quartz 和
@Scheduled、XXL-JOB 的区别是什么。 - Scheduler、Job、JobDetail、Trigger、JobStore 分别负责什么。
- Quartz 是怎么计算触发时间并执行任务的。
- JDBC JobStore 为什么能支持集群。
- Misfire 是什么,为什么生产环境必须配置。
- 商业项目里如何设计一个可重试、可幂等、可排查的 Quartz 任务。
适用场景
Quartz 适合这些场景:
| 场景 | 为什么适合 |
|---|---|
| 单体或少量服务内置调度 | 不想单独部署 XXL-JOB 调度中心 |
| 任务需要持久化 | 应用重启后任务定义和触发器还在 |
| 任务触发规则复杂 | 支持 CronTrigger、Calendar、Misfire 策略 |
| Java 应用内需要动态增删任务 | 可以通过 Scheduler API 创建、暂停、恢复任务 |
| 集群部署但不想重复执行 | JDBC JobStore 可通过数据库锁协调节点 |
不太适合:
| 不适合场景 | 原因 |
|---|---|
| 大型微服务统一任务治理 | Quartz 没有天然统一控制台、执行日志和告警体系 |
| 任务需要跨很多服务统一编排 | XXL-JOB、Airflow、DolphinScheduler 更合适 |
| 只是一个极简单本地任务 | @Scheduled 更简单 |
| 秒级海量延迟触发 | 延迟队列、时间轮、MQ 延迟消息更合适 |
核心概念
| 概念 | 作用 | 类比 |
|---|---|---|
| Scheduler | 调度器,负责注册、暂停、恢复、触发任务 | 任务总控台 |
| Job | 具体要执行的业务逻辑 | 真正干活的方法 |
| JobDetail | Job 的定义和元数据 | 任务档案 |
| Trigger | 触发器,决定什么时候执行 | 闹钟规则 |
| JobStore | 保存 JobDetail、Trigger 和状态 | 任务数据库 |
| ThreadPool | 执行任务的线程池 | 工人池 |
| Misfire | 任务错过触发时间后的处理策略 | 错过闹钟后怎么办 |
Job
Job 是业务代码入口,需要实现 org.quartz.Job:
public class CloseTimeoutOrderJob implements Job {
@Override
public void execute(JobExecutionContext context) {
// 查询超时未支付订单,逐批关闭
}
}Job 本身最好保持无状态。任务参数放在 JobDataMap,业务依赖通过 Spring 注入或调用 service。
JobDetail
JobDetail 描述“这是一个什么任务”:
JobDetail jobDetail = JobBuilder.newJob(CloseTimeoutOrderJob.class)
.withIdentity("closeTimeoutOrderJob", "order")
.usingJobData("batchSize", 500)
.storeDurably()
.build();withIdentity 很重要。生产环境不要随手用随机名称,否则后续暂停、恢复、删除和排查都很痛苦。
Trigger
Trigger 描述“什么时候触发”。
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("closeTimeoutOrderTrigger", "order")
.forJob(jobDetail)
.withSchedule(CronScheduleBuilder.cronSchedule("0 */5 * * * ?"))
.build();常见触发器:
| Trigger | 适合 |
|---|---|
| SimpleTrigger | 固定间隔执行,例如每 10 秒执行 1 次 |
| CronTrigger | 按 cron 表达式执行,例如每天凌晨 2 点 |
| CalendarIntervalTrigger | 按日历周期执行,例如每月执行 |
| DailyTimeIntervalTrigger | 每天某个时间段内按间隔执行 |
JobStore
JobStore 决定任务定义和执行状态存在哪里。
| JobStore | 特点 | 适合 |
|---|---|---|
| RAMJobStore | 保存在内存,重启丢失 | 本地测试、临时任务 |
| JDBC JobStore | 保存在数据库表 | 生产持久化、集群 |
生产环境如果希望“应用重启后任务不丢”,一般使用 JDBC JobStore。
执行原理
Quartz 的核心流程可以拆成两条线:注册任务和触发执行。
flowchart TD
A["创建 JobDetail<br/>定义任务是谁"] --> B["创建 Trigger<br/>定义什么时候执行"]
B --> C["Scheduler 注册任务"]
C --> D["JobStore 保存任务和触发器"]
D --> E["调度线程扫描到期 Trigger"]
E --> F["从线程池取线程"]
F --> G["实例化并执行 Job"]
G --> H["记录执行结果和下次触发时间"]更细一点:
- 应用启动后创建 Scheduler。
- 把 JobDetail 和 Trigger 注册进去。
- Quartz 把任务信息保存到 JobStore。
- 调度线程不断查找即将到期的 Trigger。
- 到点后把任务交给线程池。
- 线程执行 Job 的
execute方法。 - 执行完成后更新 Trigger 的下一次触发时间。
启动全过程
很多人学 Quartz 只记住了 Job + Trigger,但真正上线出问题时,往往卡在“任务到底什么时候被注册、什么时候开始扫描、为什么启动后没有触发”。所以要先把启动过程拆开。
flowchart TD
A["Spring Boot 创建 Quartz 自动配置"] --> B["读取 spring.quartz 配置"]
B --> C["创建 SchedulerFactoryBean"]
C --> D["创建 Scheduler 实例"]
D --> E["初始化 JobStore"]
E --> F["初始化 Quartz 线程池"]
F --> G["注册 JobDetail 和 Trigger"]
G --> H["启动调度线程"]
H --> I["开始扫描可触发任务"]第一步:读取配置
Spring Boot 会读取 spring.quartz 下面的配置,例如:
spring:
quartz:
job-store-type: jdbc
properties:
org.quartz.scheduler.instanceName: orderScheduler
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true
org.quartz.threadPool.threadCount: 10这里每个配置都不是摆设:
| 配置 | 影响 | 配错会怎样 |
|---|---|---|
job-store-type | 决定任务存在内存还是数据库 | 用 memory 时应用重启任务丢失 |
instanceName | 调度器名称 | 多套调度混在一起时难排查 |
instanceId | 集群节点身份 | 多节点重复会导致心跳和锁异常 |
isClustered | 是否启用集群协调 | 多实例可能重复抢任务 |
threadCount | 可并发执行任务数 | 太小会堆积,太大会打爆下游 |
第二步:创建 Scheduler
Scheduler 可以理解为 Quartz 的“大脑”。它不直接写业务逻辑,而是负责:
- 接收 JobDetail 和 Trigger。
- 把任务信息交给 JobStore 保存。
- 启动调度线程扫描 Trigger。
- 到点后把任务丢给线程池执行。
- 维护任务暂停、恢复、删除、触发状态。
如果 Scheduler 没启动,Job 和 Trigger 即使定义了也不会执行。排查“不触发”时第一步就是看 Scheduler 是否启动成功。
第三步:初始化 JobStore
JobStore 是 Quartz 的“记忆”。RAMJobStore 把记忆放在 JVM 内存里;JDBC JobStore 把记忆放在数据库表里。
flowchart TD
A["Scheduler 注册任务"] --> B{"JobStore 类型"}
B -- "RAMJobStore" --> C["写入 JVM 内存"]
B -- "JDBC JobStore" --> D["写入 QRTZ_* 表"]
C --> E["应用重启后丢失"]
D --> F["应用重启后可恢复"]这就是为什么生产环境常用 JDBC JobStore:任务定义、Trigger 状态、节点心跳都在数据库里,应用重启后还能继续调度。
第四步:初始化线程池
Quartz 有自己的任务执行线程池。调度线程只负责发现“该执行了”,真正业务代码由线程池执行。
如果线程池太小,任务会排队;如果太大,任务同时打数据库、Redis、第三方接口,可能把下游压垮。
| 任务类型 | 线程池建议 |
|---|---|
| CPU 计算型 | 接近 CPU 核心数,不要太大 |
| IO 调用型 | 可以略大,但必须有超时和限流 |
| 扫库批处理 | 不要只加线程,先控制批次和索引 |
| 调用第三方接口 | 线程数受第三方限流影响 |
第五步:注册任务
注册任务不是只注册一个类,而是注册“任务定义 + 触发规则”。
flowchart TD
A["Job 类"] --> B["JobDetail"]
B --> C["任务名、分组、参数"]
D["Cron 或间隔"] --> E["Trigger"]
E --> F["触发器名、分组、下次触发时间"]
B --> G["Scheduler.scheduleJob"]
E --> G
G --> H["写入 JobStore"]为什么要拆 JobDetail 和 Trigger?因为同一个 Job 可以被不同 Trigger 触发。例如同一个“生成报表”Job,可以有“每日 2 点生成日报”和“每月 1 号生成月报”两个 Trigger。任务逻辑一样,但触发规则和参数不同。
任务注册全过程
下面把 scheduleJob(jobDetail, trigger) 展开看。
flowchart TD
A["调用 scheduleJob"] --> B["校验 JobKey 和 TriggerKey"]
B --> C["计算 Trigger 第一次触发时间"]
C --> D["保存 JobDetail"]
D --> E["保存 Trigger"]
E --> F["通知调度线程有新任务"]
F --> G["调度线程重新计算等待时间"]为什么要计算第一次触发时间
Trigger 不只是保存一个 Cron 表达式,它还会保存下一次应该触发的时间。
例如 0 */5 * * * ? 表示每 5 分钟执行一次。当前时间是 10:02:30,Quartz 会计算出下一次触发时间是 10:05:00。调度线程后续扫描的不是“每次重新理解一遍 Cron”,而是看 Trigger 的 nextFireTime 是否已经到期。
这样做的好处是:
| 好处 | 说明 |
|---|---|
| 扫描更直接 | 比较当前时间和 nextFireTime 即可 |
| 便于持久化 | 数据库可以保存下一次触发时间 |
| 便于集群抢占 | 多节点都基于同一个 Trigger 状态判断 |
| 便于 Misfire 判断 | 当前时间远大于 nextFireTime 就可能错过触发 |
为什么 JobKey 和 TriggerKey 要稳定
Quartz 里任务身份由名称和分组组成。生产环境建议命名包含业务域:
JobKey: order.closeTimeoutOrderJob
TriggerKey: order.closeTimeoutOrderEveryFiveMinutes不要每次启动都生成随机名称,否则会出现:
- 每次启动都注册一套新任务。
- 老任务还在数据库里,导致重复执行。
- 运维不知道该暂停哪个任务。
- 任务日志无法按稳定名称聚合。
Trigger 抢占全过程
Quartz 到点执行不是所有节点一起执行,而是“扫描到期 Trigger,然后抢到的节点执行”。
flowchart TD
A["调度线程醒来"] --> B["查询 nextFireTime 已到期的 Trigger"]
B --> C["尝试获取数据库锁"]
C --> D{"是否抢到锁"}
D -- "否" --> E["等待下一轮扫描"]
D -- "是" --> F["把 Trigger 标记为 ACQUIRED"]
F --> G["释放数据库锁"]
G --> H["提交给本机线程池执行"]这里有三个关键点:
| 关键点 | 为什么重要 |
|---|---|
| 查询到期 Trigger | Quartz 不会凭空执行任务,它看的是 nextFireTime |
| 获取锁 | 集群下防止多个节点同时改同一个 Trigger |
标记 ACQUIRED | 表示这个 Trigger 已被某个节点拿走 |
如果没有锁,会发生什么?
flowchart TD
A["节点 A 查到 Trigger 到期"] --> C["执行同一任务"]
B["节点 B 也查到 Trigger 到期"] --> C
C --> D["重复关单、重复发券、重复对账"]所以 Quartz 集群不是“神奇地天然不重复”,它依赖共享数据库和状态更新。数据库是协调中心,锁是互斥手段,Trigger 状态是任务流转记录。
执行全过程
抢到 Trigger 后,Quartz 才开始真正执行 Job。
flowchart TD
A["Trigger 已获取"] --> B["创建 JobRunShell"]
B --> C["实例化 Job"]
C --> D["构造 JobExecutionContext"]
D --> E["调用 JobListener before"]
E --> F["执行 Job.execute"]
F --> G{"是否异常"}
G -- "成功" --> H["调用 JobListener after"]
G -- "异常" --> I["记录异常并按策略处理"]
H --> J["更新 Trigger 下次触发时间"]
I --> JJobExecutionContext 里有什么
JobExecutionContext 是 Job 执行时拿到的上下文。它能拿到:
| 内容 | 用途 |
|---|---|
JobDetail | 当前执行的是哪个任务 |
Trigger | 当前由哪个触发器触发 |
MergedJobDataMap | 合并后的任务参数 |
FireTime | 本次实际触发时间 |
ScheduledFireTime | 原计划触发时间 |
PreviousFireTime | 上一次触发时间 |
NextFireTime | 下一次触发时间 |
如果任务要排查“为什么延迟执行”,可以比较 FireTime 和 ScheduledFireTime。如果实际时间比计划时间晚很多,通常说明线程池满、应用卡顿、数据库锁等待或 Misfire。
Job 实例为什么不要存业务状态
Job 对象不应该依赖成员变量保存业务进度,例如:
public class BadJob implements Job {
private int processedCount = 0;
@Override
public void execute(JobExecutionContext context) {
processedCount++;
}
}这样写的问题是:
- Job 实例创建策略和生命周期不适合作为业务状态存储。
- 多节点部署时每个节点都有自己的内存,状态不共享。
- 应用重启后状态丢失。
- 并发执行时成员变量可能线程不安全。
正确做法是把进度放到数据库、Redis 或任务批次表里。
完成后的状态更新过程
Job 执行完并不代表事情结束了。Quartz 还要决定这个 Trigger 下一次什么时候执行。
flowchart TD
A["Job 执行结束"] --> B{"Trigger 是否还有下一次"}
B -- "有" --> C["计算 nextFireTime"]
C --> D["Trigger 状态改回 WAITING"]
B -- "没有" --> E["Trigger 标记 COMPLETE"]
D --> F["等待下一轮扫描"]
E --> G["不会再触发"]例如 CronTrigger 会根据 Cron 表达式计算下一次时间;SimpleTrigger 如果已经达到重复次数,就会结束。
如果这个步骤失败,会出现:
| 失败点 | 后果 |
|---|---|
更新 nextFireTime 失败 | 任务可能不再按预期触发 |
| Trigger 状态没恢复 | 后续扫描不到任务 |
| 事务提交失败 | 数据库状态和内存状态不一致 |
| 节点宕机 | 需要集群恢复机制接管 |
Quartz 表怎么看
使用 JDBC JobStore 时,Quartz 会使用一组 QRTZ_* 表。不同版本表结构可能略有差异,但核心含义类似。
| 表 | 作用 | 排查价值 |
|---|---|---|
QRTZ_JOB_DETAILS | 保存 JobDetail | 看任务是否注册 |
QRTZ_TRIGGERS | 保存 Trigger 基础信息 | 看触发器状态和下次触发时间 |
QRTZ_CRON_TRIGGERS | 保存 CronTrigger 表达式 | 看 Cron 是否正确 |
QRTZ_SIMPLE_TRIGGERS | 保存 SimpleTrigger 配置 | 看重复次数和间隔 |
QRTZ_FIRED_TRIGGERS | 保存正在执行或已获取的 Trigger | 排查卡住和重复执行 |
QRTZ_SCHEDULER_STATE | 保存调度节点心跳 | 看节点是否存活 |
QRTZ_LOCKS | 保存锁名称 | 排查数据库锁竞争 |
QRTZ_PAUSED_TRIGGER_GRPS | 保存暂停的触发器组 | 排查任务为什么不执行 |
Trigger 状态怎么理解
| 状态 | 含义 | 常见场景 |
|---|---|---|
WAITING | 等待触发 | 正常等待下一次执行 |
ACQUIRED | 已被某个节点获取 | 即将交给线程池执行 |
EXECUTING | 正在执行 | 任务运行中 |
PAUSED | 已暂停 | 人工暂停或组暂停 |
COMPLETE | 已完成 | SimpleTrigger 次数用完 |
ERROR | 错误状态 | Job 类加载失败等严重问题 |
BLOCKED | 被阻塞 | 禁止并发执行的任务正在跑 |
看到 ACQUIRED 或 BLOCKED 长时间不变,就要怀疑节点宕机、线程池耗尽、任务卡死或数据库状态没有恢复。
为什么 Quartz 能支持集群
Quartz 集群不是靠节点之间互相通信,而是靠共享数据库表协调。
flowchart TD
A["节点 A"] --> D["Quartz 数据库表"]
B["节点 B"] --> D
C["节点 C"] --> D
D --> E["Trigger 状态"]
D --> F["Scheduler 节点心跳"]
D --> G["数据库锁"]
E --> H["只有抢到 Trigger 的节点执行任务"]JDBC JobStore 集群的关键点:
| 机制 | 作用 |
|---|---|
| 共享数据库 | 所有节点看到同一份任务和触发器 |
| 节点心跳 | 记录调度节点是否存活 |
| 数据库锁 | 防止多个节点同时获取同一个 Trigger |
| Trigger 状态 | 标记等待、已获取、阻塞、完成等状态 |
| 失败恢复 | 节点宕机后其他节点可接管可恢复任务 |
所以 Quartz 集群依赖数据库。如果数据库慢、锁等待严重、时钟差异大,调度稳定性会受影响。
集群故障恢复过程
集群里最常见的问题是:某个节点抢到了 Trigger,任务还没执行完,节点宕机了。Quartz 必须判断这个节点已经死了,然后让其他节点接管可恢复任务。
flowchart TD
A["节点 A 抢到 Trigger"] --> B["节点 A 执行任务"]
B --> C["节点 A 宕机"]
C --> D["节点 A 心跳停止更新"]
D --> E["节点 B 扫描 SCHEDULER_STATE"]
E --> F["发现节点 A 超过心跳阈值"]
F --> G["恢复节点 A 未完成的 Trigger"]
G --> H["节点 B 后续重新调度"]这里要注意:Quartz 能恢复的是“调度状态”,不是自动保证你的业务一定没问题。
例如订单关单任务已经关闭了 300 条订单,节点宕机后恢复任务又重新跑了一次。如果业务 SQL 没有幂等条件,就可能重复处理。所以生产任务一定要把“调度恢复”和“业务幂等”一起设计。
心跳间隔和失效判断
clusterCheckinInterval 控制节点多久向 QRTZ_SCHEDULER_STATE 写一次心跳。
spring:
quartz:
properties:
org.quartz.jobStore.clusterCheckinInterval: 10000如果设置过大,节点宕机后接管会变慢;如果设置过小,数据库心跳写入更频繁。生产环境要结合任务时效性和数据库压力设置。
| 设置 | 后果 |
|---|---|
| 太大 | 节点宕机后较久才被发现,任务恢复慢 |
| 太小 | 心跳更新频繁,增加数据库压力 |
| 多节点时钟差异大 | 可能误判 Misfire 或心跳异常 |
Misfire 是什么
Misfire 指任务本来应该在某个时间触发,但因为某些原因错过了。
常见原因:
| 原因 | 例子 |
|---|---|
| 应用停机 | 2 点任务,应用 1:50 到 2:30 停机 |
| 线程池满 | 前面的任务长期占用线程 |
| 数据库锁等待 | 集群节点获取 Trigger 太慢 |
| 系统负载高 | JVM 卡顿、GC 时间太长 |
| 任务执行时间超过周期 | 每分钟任务执行 5 分钟 |
Misfire 策略决定“错过后怎么办”:
| 策略思路 | 含义 | 风险 |
|---|---|---|
| 立即补跑一次 | 恢复后尽快执行 | 可能形成补偿洪峰 |
| 跳过错过的触发 | 只等下一次正常触发 | 可能漏掉业务动作 |
| 按剩余次数继续 | 对 SimpleTrigger 有意义 | 需要理解重复次数 |
| 使用默认策略 | 交给 Quartz 推断 | 面试和生产都容易说不清 |
示例:
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("dailySettleTrigger", "settle")
.withSchedule(CronScheduleBuilder
.cronSchedule("0 0 2 * * ?")
.withMisfireHandlingInstructionFireAndProceed())
.build();这里的含义是:如果错过了触发,恢复后尽量触发一次,然后继续按后续 Cron 执行。
Misfire 判断过程
Quartz 会拿当前时间和 Trigger 的 nextFireTime 比较。如果当前时间已经超过 nextFireTime 很久,并且超过了 misfire 阈值,就认为发生了 Misfire。
flowchart TD
A["读取 Trigger.nextFireTime"] --> B["读取当前时间 now"]
B --> C{"now 是否明显晚于 nextFireTime"}
C -- "否" --> D["正常等待或触发"]
C -- "是" --> E["判断超过 misfireThreshold"]
E -- "否" --> D
E -- "是" --> F["应用 Misfire 策略"]
F --> G["更新 nextFireTime 或立即触发"]为什么不能无脑补跑
假设一个任务每分钟执行一次,应用停机 2 小时。如果恢复后把错过的 120 次全部补跑,可能导致:
- 数据库突然被 120 批查询打满。
- 下游接口被集中调用触发限流。
- Quartz 线程池被补偿任务占满,正常任务也无法执行。
- 同一批业务数据被重复扫描,增加锁冲突。
所以 Misfire 策略一定要结合业务:
| 业务 | 推荐思路 | 原因 |
|---|---|---|
| 订单超时关闭 | 跳过错过触发,下一轮扫描补偿 | 关单看数据库状态,不需要补每一分钟 |
| 每日对账 | 恢复后补跑一次指定账期 | 账期不能漏 |
| 报表生成 | 按批次补偿,不盲目补所有触发 | 防止报表重复生成 |
| 心跳清理 | 可以跳过 | 下一轮清理即可 |
| 计费结算 | 必须按账期和幂等流水补偿 | 不能漏,也不能重复扣 |
CronTrigger 常见 Misfire 策略
| 方法 | 含义 | 适合 |
|---|---|---|
withMisfireHandlingInstructionDoNothing | 错过就跳过,等待下一次 Cron | 高频扫描、可由下一轮覆盖 |
withMisfireHandlingInstructionFireAndProceed | 立即触发一次,再按后续 Cron | 每日任务、需要补一次 |
| 默认策略 | Quartz 根据 Trigger 类型处理 | 不建议生产核心任务依赖默认 |
SimpleTrigger 常见 Misfire 策略
| 策略 | 含义 | 风险 |
|---|---|---|
| 立即触发剩余次数 | 尽量补足执行次数 | 可能补偿洪峰 |
| 跳到下一次 | 不追赶历史 | 可能漏掉次数型业务 |
| 重新计算重复次数 | 按剩余规则继续 | 需要明确业务含义 |
并发控制过程
有些任务不能并发执行。例如“生成昨天对账文件”不能同时跑两份。Quartz 可以通过 @DisallowConcurrentExecution 禁止同一个 JobDetail 并发执行。
@DisallowConcurrentExecution
public class DailySettleJob implements Job {
@Override
public void execute(JobExecutionContext context) {
// 同一个 JobDetail 上一次没结束时,下一次不会并发进入
}
}它解决的是“同一个 JobDetail 的并发执行”,不是所有业务重复问题。
| 场景 | 是否能解决 |
|---|---|
| 同一个 JobDetail 上次没跑完,下次又触发 | 可以阻止并发 |
| 两个不同 JobDetail 操作同一张业务表 | 不能自动阻止 |
| 应用重启后业务已经处理一半 | 不能自动回滚业务 |
| 多系统同时处理同一订单 | 不能替代业务幂等 |
所以它只能作为调度层保护,不能替代数据库状态条件、唯一索引、业务流水和分布式锁。
可运行 Demo
下面是一个 Spring Boot 中使用 Quartz 做“订单超时关闭”的最小示例。
Maven 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>配置
测试环境可以先用内存 JobStore:
spring:
quartz:
job-store-type: memory
properties:
org.quartz.threadPool.threadCount: 5生产环境建议使用 JDBC JobStore,并提前初始化 Quartz 表:
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: never
properties:
org.quartz.scheduler.instanceName: assetScheduler
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true
org.quartz.jobStore.clusterCheckinInterval: 10000
org.quartz.threadPool.threadCount: 10Job 代码
public class CloseTimeoutOrderJob implements Job {
private final OrderService orderService;
public CloseTimeoutOrderJob(OrderService orderService) {
this.orderService = orderService;
}
@Override
public void execute(JobExecutionContext context) {
int batchSize = context.getMergedJobDataMap().getInt("batchSize");
orderService.closeTimeoutOrders(batchSize);
}
}业务代码
@Service
public class OrderService {
private final OrderMapper orderMapper;
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
@Transactional(rollbackFor = Exception.class)
public void closeTimeoutOrders(int batchSize) {
List<Long> ids = orderMapper.selectTimeoutOrderIds(batchSize);
for (Long id : ids) {
int updated = orderMapper.closeIfWaitPay(id);
if (updated == 0) {
// 说明订单已经支付或已关闭,属于正常并发结果
continue;
}
}
}
}SQL 必须带状态条件:
select id
from t_order
where status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
order by id
limit 500;
update t_order
set status = 'CLOSED',
close_reason = 'PAY_TIMEOUT',
updated_at = now()
where id = ?
and status = 'WAIT_PAY';不加 status = 'WAIT_PAY' 的后果是:任务查到订单后,用户刚好支付成功,定时任务仍然可能把已支付订单关闭。
注册任务
@Configuration
public class QuartzConfig {
@Bean
public JobDetail closeTimeoutOrderJobDetail() {
return JobBuilder.newJob(CloseTimeoutOrderJob.class)
.withIdentity("closeTimeoutOrderJob", "order")
.usingJobData("batchSize", 500)
.storeDurably()
.build();
}
@Bean
public Trigger closeTimeoutOrderTrigger(JobDetail closeTimeoutOrderJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(closeTimeoutOrderJobDetail)
.withIdentity("closeTimeoutOrderTrigger", "order")
.withSchedule(CronScheduleBuilder
.cronSchedule("0 */5 * * * ?")
.withMisfireHandlingInstructionDoNothing())
.build();
}
}这里选择 DoNothing,表示错过触发后不追赶补跑,只等下一次。订单关单通常还有延迟消息或低频补偿任务兜底,避免服务恢复后瞬间补跑很多批次。
商业场景设计
每日对账
对账任务通常在凌晨执行:
- 查询昨天支付成功订单。
- 拉取支付渠道账单。
- 按订单号、金额、状态比对。
- 生成差异单。
- 差异单进入人工或自动补偿流程。
关键点:
| 点 | 说明 |
|---|---|
| 不要直接修数据 | 对账先产出差异,再补偿 |
| 任务要可重跑 | 某天对账失败后可以指定日期重跑 |
| 参数要明确 | 账期、渠道、批次号必须入库 |
| 结果要留痕 | 成功数、失败数、差异数、耗时都要记录 |
报表生成
报表任务适合 Quartz 的原因是触发时间稳定,失败后需要可重跑。
flowchart TD
A["到达报表生成时间"] --> B["创建报表批次"]
B --> C["分页读取业务数据"]
C --> D["聚合计算指标"]
D --> E["写入报表结果表"]
E --> F["标记批次成功"]
D --> G["异常"]
G --> H["标记批次失败并告警"]报表任务不应该每次都全表扫描。常见做法是按业务时间、主键范围、分区表或增量标记处理。
ES 同步补偿
商品、资产、订单数据通常通过 MQ 同步 ES。如果 MQ 消费失败,可以用 Quartz 定时扫描失败表:
select id, biz_type, biz_id, retry_count
from t_es_sync_fail
where status = 'WAIT_RETRY'
and retry_count < 5
order by id
limit 200;补偿成功后标记成功,失败则增加重试次数。这样 ES 更新失败不会无限静默。
常见坑
| 问题 | 原因 | 解决 |
|---|---|---|
| 多节点重复执行 | 没开 JDBC JobStore 集群或任务本身不幂等 | 开集群,业务更新带状态条件 |
| 应用重启任务丢失 | 使用 RAMJobStore | 生产改 JDBC JobStore |
| 错过触发后突然补跑很多次 | Misfire 策略不清楚 | 按业务设置跳过或补一次 |
| 任务越来越慢 | 一次扫太多数据、无索引、下游慢 | 分页、索引、超时、限流 |
| 任务一直不触发 | Trigger 没注册、Scheduler 未启动、Cron 写错 | 查启动日志和 Quartz 表 |
| Job 注入失败 | Job 实例不是 Spring 管理 | 使用 Spring Boot Starter Quartz |
| 集群锁等待严重 | Quartz 表压力大或事务过长 | 优化库表、隔离 Quartz 数据源 |
排查方法
任务不触发
- 看应用启动日志,确认 Scheduler 是否启动。
- 检查 JobDetail 和 Trigger 是否注册。
- 检查 Cron 表达式字段数量和时区。
- JDBC JobStore 场景检查 Quartz 表是否有对应记录。
- 集群场景检查节点心跳和数据库锁。
任务重复执行
- 确认是否多实例部署。
- 确认是否使用 JDBC JobStore 集群。
- 确认
instanceId是否唯一。 - 检查业务更新是否幂等。
- 检查是否手动注册了多个同名或不同名 Trigger。
任务执行慢
- 看任务耗时分布,不只看平均值。
- 看 SQL 是否走索引。
- 看单批数据量是否过大。
- 看下游接口是否超时。
- 看 Quartz 线程池是否被慢任务占满。
- 看是否需要拆分任务、分片处理或迁移到 XXL-JOB。
和其他方案对比
| 对比项 | @Scheduled | Quartz | XXL-JOB |
|---|---|---|---|
| 上手成本 | 低 | 中 | 中 |
| 任务持久化 | 弱 | 强 | 强 |
| 集群避免重复 | 需要自己做 | JDBC JobStore 支持 | 调度中心支持 |
| 控制台 | 无 | 通常需自建 | 自带 |
| 执行日志 | 需自建 | 需自建或扩展 | 自带 |
| 路由策略 | 无 | 弱 | 强 |
| 分片广播 | 无 | 需自研 | 支持 |
| 微服务治理 | 弱 | 中 | 强 |
简单选择:
- 单机简单任务:用
@Scheduled。 - Java 应用内需要持久化调度:用 Quartz。
- 微服务里需要控制台、日志、告警、分片:用 XXL-JOB。
- 单条业务数据到期触发:优先考虑 MQ 延迟消息,再用定时任务兜底。
面试标准回答
Quartz 是 Java 里成熟的任务调度框架,核心由 Scheduler、JobDetail、Trigger、JobStore 和线程池组成。JobDetail 描述任务是什么,Trigger 描述什么时候执行,Scheduler 负责注册和调度,JobStore 负责保存任务和触发器状态。生产环境通常使用 JDBC JobStore,这样任务可以持久化,并且多个节点可以通过共享数据库和数据库锁协调 Trigger,避免同一个任务被多个节点同时执行。
Quartz 和 @Scheduled 的区别是:@Scheduled 更轻量,适合简单本地任务;Quartz 支持任务持久化、Misfire 策略、动态任务和集群;XXL-JOB 则更适合微服务分布式任务治理,提供控制台、执行日志、路由、分片、重试和告警。生产中无论用哪种方案,业务任务都必须幂等,要有执行日志、超时控制、失败重试和告警。
