Skip to content

Quartz ​

Quartz 是 Java 生态里非常成熟的任务调度框架。它解决的不是“写一个定时方法”,而是把任务、触发时间、执行状态、错过触发、持久化和集群协调都抽象出来,让任务可以更稳定地在 Java 应用里运行。

零基础可以先这样理解:

@Scheduled 像一个本地闹钟,Quartz 像一个带数据库记忆和集群协调能力的任务调度器,XXL-JOB 像一个带控制台、执行器治理、日志和告警的分布式调度平台。

学习目标 ​

学完本页你应该能回答:

  1. Quartz 和 @Scheduled、XXL-JOB 的区别是什么。
  2. Scheduler、Job、JobDetail、Trigger、JobStore 分别负责什么。
  3. Quartz 是怎么计算触发时间并执行任务的。
  4. JDBC JobStore 为什么能支持集群。
  5. Misfire 是什么,为什么生产环境必须配置。
  6. 商业项目里如何设计一个可重试、可幂等、可排查的 Quartz 任务。

适用场景 ​

Quartz 适合这些场景:

场景为什么适合
单体或少量服务内置调度不想单独部署 XXL-JOB 调度中心
任务需要持久化应用重启后任务定义和触发器还在
任务触发规则复杂支持 CronTrigger、Calendar、Misfire 策略
Java 应用内需要动态增删任务可以通过 Scheduler API 创建、暂停、恢复任务
集群部署但不想重复执行JDBC JobStore 可通过数据库锁协调节点

不太适合:

不适合场景原因
大型微服务统一任务治理Quartz 没有天然统一控制台、执行日志和告警体系
任务需要跨很多服务统一编排XXL-JOB、Airflow、DolphinScheduler 更合适
只是一个极简单本地任务@Scheduled 更简单
秒级海量延迟触发延迟队列、时间轮、MQ 延迟消息更合适

核心概念 ​

概念作用类比
Scheduler调度器,负责注册、暂停、恢复、触发任务任务总控台
Job具体要执行的业务逻辑真正干活的方法
JobDetailJob 的定义和元数据任务档案
Trigger触发器,决定什么时候执行闹钟规则
JobStore保存 JobDetail、Trigger 和状态任务数据库
ThreadPool执行任务的线程池工人池
Misfire任务错过触发时间后的处理策略错过闹钟后怎么办

Job ​

Job 是业务代码入口,需要实现 org.quartz.Job:

java
public class CloseTimeoutOrderJob implements Job {
    @Override
    public void execute(JobExecutionContext context) {
        // 查询超时未支付订单,逐批关闭
    }
}

Job 本身最好保持无状态。任务参数放在 JobDataMap,业务依赖通过 Spring 注入或调用 service。

JobDetail ​

JobDetail 描述“这是一个什么任务”:

java
JobDetail jobDetail = JobBuilder.newJob(CloseTimeoutOrderJob.class)
        .withIdentity("closeTimeoutOrderJob", "order")
        .usingJobData("batchSize", 500)
        .storeDurably()
        .build();

withIdentity 很重要。生产环境不要随手用随机名称,否则后续暂停、恢复、删除和排查都很痛苦。

Trigger ​

Trigger 描述“什么时候触发”。

java
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 的核心流程可以拆成两条线:注册任务和触发执行。

mermaid
flowchart TD
    A["创建 JobDetail<br/>定义任务是谁"] --> B["创建 Trigger<br/>定义什么时候执行"]
    B --> C["Scheduler 注册任务"]
    C --> D["JobStore 保存任务和触发器"]
    D --> E["调度线程扫描到期 Trigger"]
    E --> F["从线程池取线程"]
    F --> G["实例化并执行 Job"]
    G --> H["记录执行结果和下次触发时间"]

更细一点:

  1. 应用启动后创建 Scheduler。
  2. 把 JobDetail 和 Trigger 注册进去。
  3. Quartz 把任务信息保存到 JobStore。
  4. 调度线程不断查找即将到期的 Trigger。
  5. 到点后把任务交给线程池。
  6. 线程执行 Job 的 execute 方法。
  7. 执行完成后更新 Trigger 的下一次触发时间。

启动全过程 ​

很多人学 Quartz 只记住了 Job + Trigger,但真正上线出问题时,往往卡在“任务到底什么时候被注册、什么时候开始扫描、为什么启动后没有触发”。所以要先把启动过程拆开。

mermaid
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 下面的配置,例如:

yaml
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 的“大脑”。它不直接写业务逻辑,而是负责:

  1. 接收 JobDetail 和 Trigger。
  2. 把任务信息交给 JobStore 保存。
  3. 启动调度线程扫描 Trigger。
  4. 到点后把任务丢给线程池执行。
  5. 维护任务暂停、恢复、删除、触发状态。

如果 Scheduler 没启动,Job 和 Trigger 即使定义了也不会执行。排查“不触发”时第一步就是看 Scheduler 是否启动成功。

第三步:初始化 JobStore ​

JobStore 是 Quartz 的“记忆”。RAMJobStore 把记忆放在 JVM 内存里;JDBC JobStore 把记忆放在数据库表里。

mermaid
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 调用型可以略大,但必须有超时和限流
扫库批处理不要只加线程,先控制批次和索引
调用第三方接口线程数受第三方限流影响

第五步:注册任务 ​

注册任务不是只注册一个类,而是注册“任务定义 + 触发规则”。

mermaid
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) 展开看。

mermaid
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 里任务身份由名称和分组组成。生产环境建议命名包含业务域:

text
JobKey: order.closeTimeoutOrderJob
TriggerKey: order.closeTimeoutOrderEveryFiveMinutes

不要每次启动都生成随机名称,否则会出现:

  1. 每次启动都注册一套新任务。
  2. 老任务还在数据库里,导致重复执行。
  3. 运维不知道该暂停哪个任务。
  4. 任务日志无法按稳定名称聚合。

Trigger 抢占全过程 ​

Quartz 到点执行不是所有节点一起执行,而是“扫描到期 Trigger,然后抢到的节点执行”。

mermaid
flowchart TD
    A["调度线程醒来"] --> B["查询 nextFireTime 已到期的 Trigger"]
    B --> C["尝试获取数据库锁"]
    C --> D{"是否抢到锁"}
    D -- "否" --> E["等待下一轮扫描"]
    D -- "是" --> F["把 Trigger 标记为 ACQUIRED"]
    F --> G["释放数据库锁"]
    G --> H["提交给本机线程池执行"]

这里有三个关键点:

关键点为什么重要
查询到期 TriggerQuartz 不会凭空执行任务,它看的是 nextFireTime
获取锁集群下防止多个节点同时改同一个 Trigger
标记 ACQUIRED表示这个 Trigger 已被某个节点拿走

如果没有锁,会发生什么?

mermaid
flowchart TD
    A["节点 A 查到 Trigger 到期"] --> C["执行同一任务"]
    B["节点 B 也查到 Trigger 到期"] --> C
    C --> D["重复关单、重复发券、重复对账"]

所以 Quartz 集群不是“神奇地天然不重复”,它依赖共享数据库和状态更新。数据库是协调中心,锁是互斥手段,Trigger 状态是任务流转记录。

执行全过程 ​

抢到 Trigger 后,Quartz 才开始真正执行 Job。

mermaid
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 --> J

JobExecutionContext 里有什么 ​

JobExecutionContext 是 Job 执行时拿到的上下文。它能拿到:

内容用途
JobDetail当前执行的是哪个任务
Trigger当前由哪个触发器触发
MergedJobDataMap合并后的任务参数
FireTime本次实际触发时间
ScheduledFireTime原计划触发时间
PreviousFireTime上一次触发时间
NextFireTime下一次触发时间

如果任务要排查“为什么延迟执行”,可以比较 FireTime 和 ScheduledFireTime。如果实际时间比计划时间晚很多,通常说明线程池满、应用卡顿、数据库锁等待或 Misfire。

Job 实例为什么不要存业务状态 ​

Job 对象不应该依赖成员变量保存业务进度,例如:

java
public class BadJob implements Job {
    private int processedCount = 0;

    @Override
    public void execute(JobExecutionContext context) {
        processedCount++;
    }
}

这样写的问题是:

  1. Job 实例创建策略和生命周期不适合作为业务状态存储。
  2. 多节点部署时每个节点都有自己的内存,状态不共享。
  3. 应用重启后状态丢失。
  4. 并发执行时成员变量可能线程不安全。

正确做法是把进度放到数据库、Redis 或任务批次表里。

完成后的状态更新过程 ​

Job 执行完并不代表事情结束了。Quartz 还要决定这个 Trigger 下一次什么时候执行。

mermaid
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 集群不是靠节点之间互相通信,而是靠共享数据库表协调。

mermaid
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 必须判断这个节点已经死了,然后让其他节点接管可恢复任务。

mermaid
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 写一次心跳。

yaml
spring:
  quartz:
    properties:
      org.quartz.jobStore.clusterCheckinInterval: 10000

如果设置过大,节点宕机后接管会变慢;如果设置过小,数据库心跳写入更频繁。生产环境要结合任务时效性和数据库压力设置。

设置后果
太大节点宕机后较久才被发现,任务恢复慢
太小心跳更新频繁,增加数据库压力
多节点时钟差异大可能误判 Misfire 或心跳异常

Misfire 是什么 ​

Misfire 指任务本来应该在某个时间触发,但因为某些原因错过了。

常见原因:

原因例子
应用停机2 点任务,应用 1:50 到 2:30 停机
线程池满前面的任务长期占用线程
数据库锁等待集群节点获取 Trigger 太慢
系统负载高JVM 卡顿、GC 时间太长
任务执行时间超过周期每分钟任务执行 5 分钟

Misfire 策略决定“错过后怎么办”:

策略思路含义风险
立即补跑一次恢复后尽快执行可能形成补偿洪峰
跳过错过的触发只等下一次正常触发可能漏掉业务动作
按剩余次数继续对 SimpleTrigger 有意义需要理解重复次数
使用默认策略交给 Quartz 推断面试和生产都容易说不清

示例:

java
Trigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("dailySettleTrigger", "settle")
        .withSchedule(CronScheduleBuilder
                .cronSchedule("0 0 2 * * ?")
                .withMisfireHandlingInstructionFireAndProceed())
        .build();

这里的含义是:如果错过了触发,恢复后尽量触发一次,然后继续按后续 Cron 执行。

Misfire 判断过程 ​

Quartz 会拿当前时间和 Trigger 的 nextFireTime 比较。如果当前时间已经超过 nextFireTime 很久,并且超过了 misfire 阈值,就认为发生了 Misfire。

mermaid
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 次全部补跑,可能导致:

  1. 数据库突然被 120 批查询打满。
  2. 下游接口被集中调用触发限流。
  3. Quartz 线程池被补偿任务占满,正常任务也无法执行。
  4. 同一批业务数据被重复扫描,增加锁冲突。

所以 Misfire 策略一定要结合业务:

业务推荐思路原因
订单超时关闭跳过错过触发,下一轮扫描补偿关单看数据库状态,不需要补每一分钟
每日对账恢复后补跑一次指定账期账期不能漏
报表生成按批次补偿,不盲目补所有触发防止报表重复生成
心跳清理可以跳过下一轮清理即可
计费结算必须按账期和幂等流水补偿不能漏,也不能重复扣

CronTrigger 常见 Misfire 策略 ​

方法含义适合
withMisfireHandlingInstructionDoNothing错过就跳过,等待下一次 Cron高频扫描、可由下一轮覆盖
withMisfireHandlingInstructionFireAndProceed立即触发一次,再按后续 Cron每日任务、需要补一次
默认策略Quartz 根据 Trigger 类型处理不建议生产核心任务依赖默认

SimpleTrigger 常见 Misfire 策略 ​

策略含义风险
立即触发剩余次数尽量补足执行次数可能补偿洪峰
跳到下一次不追赶历史可能漏掉次数型业务
重新计算重复次数按剩余规则继续需要明确业务含义

并发控制过程 ​

有些任务不能并发执行。例如“生成昨天对账文件”不能同时跑两份。Quartz 可以通过 @DisallowConcurrentExecution 禁止同一个 JobDetail 并发执行。

java
@DisallowConcurrentExecution
public class DailySettleJob implements Job {
    @Override
    public void execute(JobExecutionContext context) {
        // 同一个 JobDetail 上一次没结束时,下一次不会并发进入
    }
}

它解决的是“同一个 JobDetail 的并发执行”,不是所有业务重复问题。

场景是否能解决
同一个 JobDetail 上次没跑完,下次又触发可以阻止并发
两个不同 JobDetail 操作同一张业务表不能自动阻止
应用重启后业务已经处理一半不能自动回滚业务
多系统同时处理同一订单不能替代业务幂等

所以它只能作为调度层保护,不能替代数据库状态条件、唯一索引、业务流水和分布式锁。

可运行 Demo ​

下面是一个 Spring Boot 中使用 Quartz 做“订单超时关闭”的最小示例。

Maven 依赖 ​

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

配置 ​

测试环境可以先用内存 JobStore:

yaml
spring:
  quartz:
    job-store-type: memory
    properties:
      org.quartz.threadPool.threadCount: 5

生产环境建议使用 JDBC JobStore,并提前初始化 Quartz 表:

yaml
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: 10

Job 代码 ​

java
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);
    }
}

业务代码 ​

java
@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 必须带状态条件:

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' 的后果是:任务查到订单后,用户刚好支付成功,定时任务仍然可能把已支付订单关闭。

注册任务 ​

java
@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,表示错过触发后不追赶补跑,只等下一次。订单关单通常还有延迟消息或低频补偿任务兜底,避免服务恢复后瞬间补跑很多批次。

商业场景设计 ​

每日对账 ​

对账任务通常在凌晨执行:

  1. 查询昨天支付成功订单。
  2. 拉取支付渠道账单。
  3. 按订单号、金额、状态比对。
  4. 生成差异单。
  5. 差异单进入人工或自动补偿流程。

关键点:

点说明
不要直接修数据对账先产出差异,再补偿
任务要可重跑某天对账失败后可以指定日期重跑
参数要明确账期、渠道、批次号必须入库
结果要留痕成功数、失败数、差异数、耗时都要记录

报表生成 ​

报表任务适合 Quartz 的原因是触发时间稳定,失败后需要可重跑。

mermaid
flowchart TD
    A["到达报表生成时间"] --> B["创建报表批次"]
    B --> C["分页读取业务数据"]
    C --> D["聚合计算指标"]
    D --> E["写入报表结果表"]
    E --> F["标记批次成功"]
    D --> G["异常"]
    G --> H["标记批次失败并告警"]

报表任务不应该每次都全表扫描。常见做法是按业务时间、主键范围、分区表或增量标记处理。

ES 同步补偿 ​

商品、资产、订单数据通常通过 MQ 同步 ES。如果 MQ 消费失败,可以用 Quartz 定时扫描失败表:

sql
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 数据源

排查方法 ​

任务不触发 ​

  1. 看应用启动日志,确认 Scheduler 是否启动。
  2. 检查 JobDetail 和 Trigger 是否注册。
  3. 检查 Cron 表达式字段数量和时区。
  4. JDBC JobStore 场景检查 Quartz 表是否有对应记录。
  5. 集群场景检查节点心跳和数据库锁。

任务重复执行 ​

  1. 确认是否多实例部署。
  2. 确认是否使用 JDBC JobStore 集群。
  3. 确认 instanceId 是否唯一。
  4. 检查业务更新是否幂等。
  5. 检查是否手动注册了多个同名或不同名 Trigger。

任务执行慢 ​

  1. 看任务耗时分布,不只看平均值。
  2. 看 SQL 是否走索引。
  3. 看单批数据量是否过大。
  4. 看下游接口是否超时。
  5. 看 Quartz 线程池是否被慢任务占满。
  6. 看是否需要拆分任务、分片处理或迁移到 XXL-JOB。

和其他方案对比 ​

对比项@ScheduledQuartzXXL-JOB
上手成本低中中
任务持久化弱强强
集群避免重复需要自己做JDBC JobStore 支持调度中心支持
控制台无通常需自建自带
执行日志需自建需自建或扩展自带
路由策略无弱强
分片广播无需自研支持
微服务治理弱中强

简单选择:

  1. 单机简单任务:用 @Scheduled。
  2. Java 应用内需要持久化调度:用 Quartz。
  3. 微服务里需要控制台、日志、告警、分片:用 XXL-JOB。
  4. 单条业务数据到期触发:优先考虑 MQ 延迟消息,再用定时任务兜底。

面试标准回答 ​

Quartz 是 Java 里成熟的任务调度框架,核心由 Scheduler、JobDetail、Trigger、JobStore 和线程池组成。JobDetail 描述任务是什么,Trigger 描述什么时候执行,Scheduler 负责注册和调度,JobStore 负责保存任务和触发器状态。生产环境通常使用 JDBC JobStore,这样任务可以持久化,并且多个节点可以通过共享数据库和数据库锁协调 Trigger,避免同一个任务被多个节点同时执行。

Quartz 和 @Scheduled 的区别是:@Scheduled 更轻量,适合简单本地任务;Quartz 支持任务持久化、Misfire 策略、动态任务和集群;XXL-JOB 则更适合微服务分布式任务治理,提供控制台、执行日志、路由、分片、重试和告警。生产中无论用哪种方案,业务任务都必须幂等,要有执行日志、超时控制、失败重试和告警。

关联知识点 ​