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下一次触发时间

如果任务要排查“为什么延迟执行”,可以比较 FireTimeScheduledFireTime。如果实际时间比计划时间晚很多,通常说明线程池满、应用卡顿、数据库锁等待或 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被阻塞禁止并发执行的任务正在跑

看到 ACQUIREDBLOCKED 长时间不变,就要怀疑节点宕机、线程池耗尽、任务卡死或数据库状态没有恢复。

为什么 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 则更适合微服务分布式任务治理,提供控制台、执行日志、路由、分片、重试和告警。生产中无论用哪种方案,业务任务都必须幂等,要有执行日志、超时控制、失败重试和告警。

关联知识点