Skip to content

定时任务从零到精通验收清单

这页用来验收你是否真的把定时任务学到了能设计、能落地、能排查、能面试的程度。

定时任务不是“写一个 cron 方法”。真正学懂要能解释:

  1. 任务由谁触发,触发时间怎么算,错过触发怎么办。
  2. 单机任务和分布式任务有什么区别,多实例为什么会重复执行。
  3. @Scheduled、Quartz、XXL-JOB、MQ 延迟消息、K8s CronJob 分别适合什么场景。
  4. XXL-JOB 调度中心、执行器、JobHandler、路由、分片、阻塞策略、失败重试的完整过程。
  5. 为什么定时任务必须幂等,为什么加锁后仍然要幂等。
  6. 大批量任务为什么要分页、分片、限流、记录进度。
  7. 任务失败后如何补偿,告警如何闭环,人工重跑如何防重复。
  8. 任务不触发、重复执行、越跑越慢、堆积、失败重试风暴怎么排查。

总学习路线

mermaid
flowchart TD
    A["阶段1:理解定时任务解决什么问题"] --> B["阶段2:Cron 和触发时间"]
    B --> C["阶段3:Spring Scheduled 本地调度"]
    C --> D["阶段4:多实例重复执行和幂等"]
    D --> E["阶段5:Quartz 持久化调度"]
    E --> F["阶段6:XXL-JOB 分布式调度"]
    F --> G["阶段7:路由、分片、阻塞和重试"]
    G --> H["阶段8:商业任务设计"]
    H --> I["阶段9:生产排查和告警闭环"]

这条路线的核心是:先理解“到点触发”只是入口,再理解“可靠执行、可观测、可补偿、可重跑、不重复、不拖垮系统”才是生产级定时任务。

阶段1:定时任务解决什么问题

商业系统里很多动作不是用户点击触发,而是后台自动推进。

场景任务不做会怎样
订单30 分钟未支付自动关单库存长期占用
支付每天渠道对账资金差异发现滞后
搜索ES 同步失败补偿搜索结果长期不一致
营销优惠券过期失效过期券仍可用
报表每小时预聚合指标实时查库压力大
采集定时扫描失败任务重试数据缺口无法恢复
风控周期扫描异常交易风险发现滞后

一个生产级任务至少要回答:

  1. 什么时候触发?
  2. 谁来执行?
  3. 执行哪批数据?
  4. 重复执行是否安全?
  5. 失败怎么重试?
  6. 慢了怎么限流?
  7. 执行日志在哪里看?
  8. 长期失败谁负责?

阶段2:Cron 和触发时间

Cron 用来描述时间规则。Spring、Quartz、XXL-JOB 常见 6 位或 7 位,Linux crontab 常见 5 位。

text
秒 分 时 日 月 周

常见例子:

表达式含义
0 0 2 * * ?每天凌晨 2 点
0 */5 * * * ?每 5 分钟
0 0/30 9-18 * * ?每天 9 点到 18 点,每 30 分钟
0 0 1 ? * MON每周一凌晨 1 点

触发流程:

mermaid
flowchart TD
    A["配置 Cron"] --> B["调度器计算 nextFireTime"]
    B --> C["当前时间到达触发点"]
    C --> D["提交任务执行"]
    D --> E["执行完成"]
    E --> F["计算下一次触发时间"]

Cron 常见坑:

后果正确做法
复制 Linux crontab 到 Spring字段数量不匹配确认框架 Cron 规则
整点大量任务同时跑数据库和下游被打爆错峰、随机延迟、分片
忽略时区跨地区执行时间错明确 timezone
错过触发不处理停机期间漏跑设计 Misfire 或补偿任务

阶段3:Spring Scheduled 原理

@Scheduled 适合单体、低频、影响面小的本地任务。

启动扫描过程:

mermaid
flowchart TD
    A["Spring 启动"] --> B["@EnableScheduling 开启调度"]
    B --> C["ScheduledAnnotationBeanPostProcessor"]
    C --> D["扫描 Bean 中 @Scheduled 方法"]
    D --> E["解析 cron/fixedRate/fixedDelay"]
    E --> F["包装成 Runnable"]
    F --> G["注册到 TaskScheduler"]

Demo:

java
@Component
public class EsSyncCompensateTask {
    private final EsSyncService esSyncService;

    public EsSyncCompensateTask(EsSyncService esSyncService) {
        this.esSyncService = esSyncService;
    }

    @Scheduled(cron = "0 */5 * * * ?")
    public void compensate() {
        esSyncService.retryFailedRecords(500);
    }
}

生产必须显式配置线程池:

java
@Configuration
public class SchedulerConfig {
    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(8);
        scheduler.setThreadNamePrefix("biz-scheduler-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        return scheduler;
    }
}

如果不配置线程池,多个任务可能互相影响。一个长任务占住调度线程,其他任务到点后可能延迟执行。

阶段4:多实例重复执行

如果服务部署 3 个实例,每个实例都有自己的 Spring 容器和本地调度器。

mermaid
flowchart TD
    A["服务部署 3 个实例"] --> B["实例 A 注册 @Scheduled"]
    A --> C["实例 B 注册 @Scheduled"]
    A --> D["实例 C 注册 @Scheduled"]
    B --> E["到点执行同一个任务"]
    C --> E
    D --> E
    E --> F["可能重复关单、重复发券、重复同步"]

解决方向:

方式说明局限
固定一台机器执行简单容灾差
分布式锁抢到锁才执行锁超时、网络抖动仍可能重复
任务幂等重复执行也不出错必须设计
XXL-JOB调度中心统一选择执行器需要部署调度中心

加锁后仍然要幂等,因为锁不是绝对可靠。

阶段5:幂等是第一原则

订单超时关闭不能这样写:

sql
update t_order
set status = 'CLOSED'
where id = ?;

因为查询出超时订单后,用户可能刚好支付成功。

正确写法:

sql
update t_order
set status = 'CLOSED',
    close_reason = 'PAY_TIMEOUT',
    updated_at = now()
where id = ?
  and status = 'WAIT_PAY';

如果更新行数是 0,说明订单已经不是待支付,不应该强行关闭。

幂等手段:

手段场景
状态条件关单、取消、结算
唯一索引防重复生成账单、对账批次
业务流水号防重复发券、发消息
防重表高风险操作防重复
乐观锁版本号状态流转
幂等结果表补偿任务

阶段6:Quartz 原理

Quartz 适合 Java 应用内需要持久化调度、动态任务、Misfire 处理的场景。

核心组件:

组件作用
Scheduler调度器
Job业务执行逻辑
JobDetail任务定义和参数
Trigger触发规则
JobStore保存任务、触发器和状态
ThreadPool执行任务线程池

执行过程:

mermaid
flowchart TD
    A["Scheduler 启动"] --> B["扫描到期 Trigger"]
    B --> C["集群场景抢占 Trigger"]
    C --> D["标记 ACQUIRED"]
    D --> E["提交到 Quartz 线程池"]
    E --> F["实例化 Job"]
    F --> G["调用 execute"]
    G --> H["更新 Trigger 下次触发时间"]

Quartz 集群通过数据库表和锁协调。多个节点共享同一套 QRTZ_* 表,只有抢到 Trigger 的节点执行。

Misfire 是错过触发:

mermaid
flowchart TD
    A["任务原本 02:00 触发"] --> B["应用停机或线程池满"]
    B --> C["02:10 才恢复"]
    C --> D["判断是否 Misfire"]
    D --> E["立即补跑、跳过或按策略处理"]

不能无脑补跑。比如每分钟任务停机 1 小时,如果恢复后补跑 60 次,可能瞬间打爆数据库。

阶段7:XXL-JOB 架构

XXL-JOB 适合微服务分布式任务治理。

mermaid
flowchart TD
    A["调度中心 Admin"] --> B["调度数据库"]
    A --> C["执行器 Registry"]
    C --> D["执行器 A"]
    C --> E["执行器 B"]
    D --> F["@XxlJob Handler"]
    E --> G["@XxlJob Handler"]
    D --> H["回传日志和结果"]
    E --> H

核心角色:

角色作用
Admin 调度中心管理任务、计算触发、下发调度、展示日志
执行器注册到 Admin,接收调度请求
JobHandler业务任务方法
调度数据库保存任务配置、日志、注册信息

执行器启动过程:

mermaid
flowchart TD
    A["业务服务启动"] --> B["创建 XxlJobSpringExecutor"]
    B --> C["扫描 @XxlJob 方法"]
    C --> D["注册 JobHandler 映射"]
    D --> E["启动执行器 HTTP 服务"]
    E --> F["向 Admin 注册 appname 和地址"]
    F --> G["持续心跳"]

Admin 调度过程:

mermaid
flowchart TD
    A["Admin 调度线程扫描任务"] --> B["发现到期任务"]
    B --> C["生成调度日志"]
    C --> D["查找在线执行器"]
    D --> E["按路由策略选择执行器"]
    E --> F["HTTP 下发调度请求"]
    F --> G["更新下次触发时间"]

执行器执行过程:

mermaid
flowchart TD
    A["执行器收到请求"] --> B["校验 accessToken"]
    B --> C["解析 jobId、handler、参数"]
    C --> D["查找 JobHandler"]
    D --> E["判断阻塞策略"]
    E --> F["提交任务线程执行"]
    F --> G["写执行日志"]
    G --> H["回调 Admin 执行结果"]

阶段8:XXL-JOB Demo

配置执行器:

yaml
xxl:
  job:
    admin:
      addresses: http://127.0.0.1:8080/xxl-job-admin
    executor:
      appname: asset-job-executor
      address:
      ip:
      port: 9999
      logpath: ./logs/xxl-job
      logretentiondays: 30
    accessToken: demo-token

任务 Handler:

java
@Component
public class EsSyncJob {
    private final EsSyncService esSyncService;

    public EsSyncJob(EsSyncService esSyncService) {
        this.esSyncService = esSyncService;
    }

    @XxlJob("esSyncCompensateJob")
    public void compensate() {
        String param = XxlJobHelper.getJobParam();
        XxlJobHelper.log("ES 同步补偿开始,param={}", param);
        int success = esSyncService.retryFailedRecords(500);
        XxlJobHelper.log("ES 同步补偿完成,success={}", success);
    }
}

业务代码要自己保证幂等和分页,不要把所有可靠性都寄托给调度框架。

阶段9:路由策略

XXL-JOB 常见路由策略:

策略适合场景
第一个/最后一个固定节点执行
轮询/随机均衡执行压力
故障转移找可用节点
忙碌转移避开正在忙的节点
一致性 Hash同一参数尽量落同一节点
分片广播所有执行器都执行一份分片

路由只决定“去哪台机器执行”,不保证业务不会重复。业务正确性仍靠幂等。

阶段10:分片广播

大批量任务适合分片广播。

mermaid
flowchart TD
    A["Admin 触发分片广播"] --> B["执行器 A shardIndex=0"]
    A --> C["执行器 B shardIndex=1"]
    A --> D["执行器 C shardIndex=2"]
    B --> E["处理 id % 3 = 0 的数据"]
    C --> F["处理 id % 3 = 1 的数据"]
    D --> G["处理 id % 3 = 2 的数据"]

Demo:

java
@XxlJob("assetRefreshShardJob")
public void refreshAssetByShard() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    XxlJobHelper.log("分片执行 shardIndex={}, shardTotal={}", shardIndex, shardTotal);

    List<Long> ids = assetMapper.selectIdsByShard(shardIndex, shardTotal, 500);
    for (Long id : ids) {
        assetService.refreshSearchIndex(id);
    }
}

SQL 示例:

sql
select id
from data_asset
where status = 'PUBLISHED'
  and mod(id, #{shardTotal}) = #{shardIndex}
order by id
limit 500;

分片注意:

  1. 分片条件必须互斥,否则会重复处理。
  2. 分片条件必须覆盖全集,否则会漏数据。
  3. 每个分片处理速度可能不同,要监控慢分片。
  4. 数据倾斜时简单 mod(id) 也可能不均匀。

阶段11:阻塞策略

阻塞策略解决“上一次没跑完,下一次又来了”。

mermaid
flowchart TD
    A["任务第1次还在执行"] --> B["第2次触发到来"]
    B --> C{"阻塞策略"}
    C -- "串行等待" --> D["排队等待上次结束"]
    C -- "丢弃后续" --> E["本次跳过"]
    C -- "覆盖之前" --> F["终止旧任务或让新任务优先"]

选择建议:

策略适合风险
串行对账、结算、不能并发的任务堆积
丢弃后续高频刷新缓存可能少跑一次
覆盖之前只关心最新结果旧任务中断要安全

不要无脑覆盖。旧任务如果正在更新数据库,强行中断可能留下半成品。

阶段12:失败重试和补偿

失败分两类:

类型例子是否适合重试
临时失败网络抖动、下游超时适合有限重试
业务失败参数非法、状态不允许不适合盲目重试

补偿表设计:

sql
create table job_compensate_record (
  id bigint primary key,
  biz_type varchar(64) not null,
  biz_id varchar(128) not null,
  status varchar(32) not null,
  retry_count int not null,
  next_retry_time datetime not null,
  last_error varchar(1024),
  created_at datetime not null,
  updated_at datetime not null,
  unique key uk_biz (biz_type, biz_id)
);

补偿流程:

mermaid
flowchart TD
    A["业务失败写补偿表"] --> B["定时任务扫描待补偿"]
    B --> C["按 next_retry_time 分页取数据"]
    C --> D["执行补偿逻辑"]
    D --> E{"是否成功"}
    E -- "成功" --> F["标记 SUCCESS"]
    E -- "失败" --> G["retry_count + 1"]
    G --> H{"是否超过上限"}
    H -- "否" --> I["计算下次重试时间"]
    H -- "是" --> J["标记 MANUAL 并告警"]

重试必须配合幂等,否则一次网络超时可能导致重复创建、重复发券、重复同步。

阶段13:商业场景设计

订单超时关闭

推荐组合:延迟消息主链路 + 定时补偿兜底。

mermaid
flowchart TD
    A["订单创建"] --> B["发送 30 分钟延迟消息"]
    B --> C["延迟消息到期"]
    C --> D["尝试关单"]
    D --> E{"status 是否 WAIT_PAY"}
    E -- "是" --> F["关闭订单并释放库存"]
    E -- "否" --> G["跳过"]
    H["定时补偿任务"] --> I["扫描漏网 WAIT_PAY 超时订单"]
    I --> D

关键点:

  1. 更新必须带状态条件。
  2. 释放库存也要幂等。
  3. 延迟消息丢失时由定时扫描兜底。
  4. 扫描要分页,不能全表扫。

ES 同步失败补偿

mermaid
flowchart TD
    A["业务数据变更"] --> B["发 MQ 同步 ES"]
    B --> C{"同步是否成功"}
    C -- "成功" --> D["结束"]
    C -- "失败" --> E["写失败记录"]
    E --> F["定时补偿扫描失败记录"]
    F --> G["重新同步 ES"]
    G --> H{"成功"}
    H -- "是" --> I["标记成功"]
    H -- "否" --> J["增加重试次数并告警"]

T+1 对账

对账任务不要直接改账务数据,而是生成差异单。

流程:

  1. 按渠道和账期创建对账批次。
  2. 拉取渠道账单。
  3. 和本地订单按订单号、金额、状态比对。
  4. 生成差异单。
  5. 人工确认或补偿处理。
  6. 记录批次状态和审计日志。

阶段14:生产排查

任务不触发

mermaid
flowchart TD
    A["任务不触发"] --> B["Cron 是否正确"]
    B --> C["应用时间和时区是否正确"]
    C --> D["@Scheduled 是否是 Spring Bean"]
    D --> E["XXL-JOB 执行器是否注册"]
    E --> F["任务是否启用"]
    F --> G["调度日志是否生成"]
    G --> H["线程池是否满"]

重复执行

mermaid
flowchart TD
    A["任务重复执行"] --> B["是否多实例本地调度"]
    B --> C["是否失败重试"]
    C --> D["是否人工重复点击"]
    D --> E["分布式锁是否过期"]
    E --> F["分片条件是否重叠"]
    F --> G["业务幂等是否缺失"]

越跑越慢

mermaid
flowchart TD
    A["任务越跑越慢"] --> B["查询 SQL 是否变慢"]
    B --> C["分页是否深分页"]
    C --> D["单批数据量是否过大"]
    D --> E["下游接口是否变慢"]
    E --> F["事务是否过大"]
    F --> G["日志是否过多"]

任务堆积

mermaid
flowchart TD
    A["任务堆积"] --> B["执行耗时是否超过调度周期"]
    B --> C["新增数据速度是否大于处理速度"]
    C --> D["线程池是否满"]
    D --> E["下游是否限流"]
    E --> F["是否需要分片或拆任务"]

失败重试风暴

mermaid
flowchart TD
    A["失败重试风暴"] --> B["是否下游整体故障"]
    B --> C["重试间隔是否太短"]
    C --> D["是否没有最大重试次数"]
    D --> E["是否所有失败同时重试"]
    E --> F["改指数退避、限流、人工处理"]

阶段15:告警闭环

告警不是发一条消息就结束。

有效告警应该包含:

  1. 任务名。
  2. 环境。
  3. 调度时间。
  4. 任务参数。
  5. 执行器地址。
  6. 异常摘要。
  7. 最近成功时间。
  8. 处理入口。
  9. 负责人。

闭环流程:

mermaid
flowchart TD
    A["任务失败"] --> B["记录失败日志"]
    B --> C["发送告警"]
    C --> D["负责人确认"]
    D --> E["执行补偿或修复"]
    E --> F["任务恢复成功"]
    F --> G["关闭告警并记录原因"]

没有负责人、没有处理入口、没有恢复确认的告警,很容易变成噪音。

阶段16:常见坑和后果

后果正确做法
只写 cron 不写日志出问题不知道跑了什么记录参数、耗时、结果
多实例 @Scheduled重复执行分布式调度或幂等锁
加锁后不幂等锁失效仍出错状态条件、唯一索引
一次扫全表数据库压力大分页、索引、游标
深分页批处理越跑越慢按 id 游标分页
失败无限重试重试风暴最大次数、退避、告警
阻塞策略乱选堆积或中断不安全按业务一致性选择
分片条件重叠重复处理分片规则互斥覆盖
无告警负责人长期失败没人处理告警闭环

阶段17:面试标准回答

问:定时任务怎么从零学到生产级?

标准回答:

定时任务不能只学 cron。要按触发时间、调度器、执行器、线程池、幂等、失败重试、执行日志、告警、分片、阻塞策略和生产排查这条链路学习。生产级任务要保证重复执行不出错、失败可补偿、执行过程可观测、长期失败有人处理。

问:@Scheduled 多实例有什么问题?

标准回答:

@Scheduled 是本地调度,每个服务实例都会扫描并注册自己的定时任务。多实例部署后同一个任务会在多个实例同时触发,可能重复关单、重复发券或重复同步。解决方式是任务幂等、分布式锁、固定实例执行,或者使用 XXL-JOB 这类分布式调度平台。

问:XXL-JOB 执行流程怎么说?

标准回答:

XXL-JOB 由 Admin 调度中心和执行器组成。执行器启动后扫描 @XxlJob 方法并向 Admin 注册。Admin 后台线程扫描到期任务,生成调度日志,按路由策略选择执行器,通过 HTTP 下发调度请求。执行器校验 token,找到 JobHandler,按阻塞策略提交任务执行,记录日志并回调 Admin 执行结果。

问:任务为什么必须幂等?

标准回答:

因为定时任务可能因为多实例、失败重试、人工重跑、锁过期、服务重启补偿而重复执行。幂等能保证重复处理同一批数据也不会产生重复扣款、重复发券、重复关单。常见手段包括状态条件、唯一索引、业务流水号、防重表和补偿记录。

最终验收题

如果下面问题答不清楚,说明还没有真正掌握定时任务:

  1. Cron 的 5 位、6 位、7 位有什么区别?
  2. fixedRatefixedDelay 有什么区别?
  3. @Scheduled 为什么多实例会重复执行?
  4. 加分布式锁后为什么仍然要幂等?
  5. Quartz 的 JobDetail 和 Trigger 为什么要分开?
  6. Misfire 是什么,为什么不能无脑补跑?
  7. XXL-JOB 执行器启动后如何注册到 Admin?
  8. Admin 如何扫描和触发到期任务?
  9. 路由策略解决什么,不解决什么?
  10. 分片广播如何保证不漏不重?
  11. 阻塞策略怎么选?
  12. 失败重试为什么可能变成重试风暴?
  13. 订单超时关闭为什么常用延迟消息加定时补偿?
  14. 任务越跑越慢怎么拆证据排查?
  15. 告警闭环应该包含哪些信息?

关联知识点跳转

本章小结

定时任务精通的关键不是会写 cron,而是能把触发、执行、日志、重试、幂等、分片、阻塞、补偿、告警和排查串成闭环。生产里任务失败并不可怕,可怕的是失败不可见、重复执行不安全、慢任务拖垮数据库、补偿没有记录、告警没人处理。