定时任务从零到精通验收清单
这页用来验收你是否真的把定时任务学到了能设计、能落地、能排查、能面试的程度。
定时任务不是“写一个 cron 方法”。真正学懂要能解释:
- 任务由谁触发,触发时间怎么算,错过触发怎么办。
- 单机任务和分布式任务有什么区别,多实例为什么会重复执行。
@Scheduled、Quartz、XXL-JOB、MQ 延迟消息、K8s CronJob 分别适合什么场景。- XXL-JOB 调度中心、执行器、JobHandler、路由、分片、阻塞策略、失败重试的完整过程。
- 为什么定时任务必须幂等,为什么加锁后仍然要幂等。
- 大批量任务为什么要分页、分片、限流、记录进度。
- 任务失败后如何补偿,告警如何闭环,人工重跑如何防重复。
- 任务不触发、重复执行、越跑越慢、堆积、失败重试风暴怎么排查。
总学习路线
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 同步失败补偿 | 搜索结果长期不一致 |
| 营销 | 优惠券过期失效 | 过期券仍可用 |
| 报表 | 每小时预聚合指标 | 实时查库压力大 |
| 采集 | 定时扫描失败任务重试 | 数据缺口无法恢复 |
| 风控 | 周期扫描异常交易 | 风险发现滞后 |
一个生产级任务至少要回答:
- 什么时候触发?
- 谁来执行?
- 执行哪批数据?
- 重复执行是否安全?
- 失败怎么重试?
- 慢了怎么限流?
- 执行日志在哪里看?
- 长期失败谁负责?
阶段2:Cron 和触发时间
Cron 用来描述时间规则。Spring、Quartz、XXL-JOB 常见 6 位或 7 位,Linux crontab 常见 5 位。
秒 分 时 日 月 周常见例子:
| 表达式 | 含义 |
|---|---|
0 0 2 * * ? | 每天凌晨 2 点 |
0 */5 * * * ? | 每 5 分钟 |
0 0/30 9-18 * * ? | 每天 9 点到 18 点,每 30 分钟 |
0 0 1 ? * MON | 每周一凌晨 1 点 |
触发流程:
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 适合单体、低频、影响面小的本地任务。
启动扫描过程:
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:
@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);
}
}生产必须显式配置线程池:
@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 容器和本地调度器。
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:幂等是第一原则
订单超时关闭不能这样写:
update t_order
set status = 'CLOSED'
where id = ?;因为查询出超时订单后,用户可能刚好支付成功。
正确写法:
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 | 执行任务线程池 |
执行过程:
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 是错过触发:
flowchart TD
A["任务原本 02:00 触发"] --> B["应用停机或线程池满"]
B --> C["02:10 才恢复"]
C --> D["判断是否 Misfire"]
D --> E["立即补跑、跳过或按策略处理"]不能无脑补跑。比如每分钟任务停机 1 小时,如果恢复后补跑 60 次,可能瞬间打爆数据库。
阶段7:XXL-JOB 架构
XXL-JOB 适合微服务分布式任务治理。
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 | 业务任务方法 |
| 调度数据库 | 保存任务配置、日志、注册信息 |
执行器启动过程:
flowchart TD
A["业务服务启动"] --> B["创建 XxlJobSpringExecutor"]
B --> C["扫描 @XxlJob 方法"]
C --> D["注册 JobHandler 映射"]
D --> E["启动执行器 HTTP 服务"]
E --> F["向 Admin 注册 appname 和地址"]
F --> G["持续心跳"]Admin 调度过程:
flowchart TD
A["Admin 调度线程扫描任务"] --> B["发现到期任务"]
B --> C["生成调度日志"]
C --> D["查找在线执行器"]
D --> E["按路由策略选择执行器"]
E --> F["HTTP 下发调度请求"]
F --> G["更新下次触发时间"]执行器执行过程:
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
配置执行器:
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:
@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:分片广播
大批量任务适合分片广播。
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:
@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 示例:
select id
from data_asset
where status = 'PUBLISHED'
and mod(id, #{shardTotal}) = #{shardIndex}
order by id
limit 500;分片注意:
- 分片条件必须互斥,否则会重复处理。
- 分片条件必须覆盖全集,否则会漏数据。
- 每个分片处理速度可能不同,要监控慢分片。
- 数据倾斜时简单
mod(id)也可能不均匀。
阶段11:阻塞策略
阻塞策略解决“上一次没跑完,下一次又来了”。
flowchart TD
A["任务第1次还在执行"] --> B["第2次触发到来"]
B --> C{"阻塞策略"}
C -- "串行等待" --> D["排队等待上次结束"]
C -- "丢弃后续" --> E["本次跳过"]
C -- "覆盖之前" --> F["终止旧任务或让新任务优先"]选择建议:
| 策略 | 适合 | 风险 |
|---|---|---|
| 串行 | 对账、结算、不能并发的任务 | 堆积 |
| 丢弃后续 | 高频刷新缓存 | 可能少跑一次 |
| 覆盖之前 | 只关心最新结果 | 旧任务中断要安全 |
不要无脑覆盖。旧任务如果正在更新数据库,强行中断可能留下半成品。
阶段12:失败重试和补偿
失败分两类:
| 类型 | 例子 | 是否适合重试 |
|---|---|---|
| 临时失败 | 网络抖动、下游超时 | 适合有限重试 |
| 业务失败 | 参数非法、状态不允许 | 不适合盲目重试 |
补偿表设计:
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)
);补偿流程:
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:商业场景设计
订单超时关闭
推荐组合:延迟消息主链路 + 定时补偿兜底。
flowchart TD
A["订单创建"] --> B["发送 30 分钟延迟消息"]
B --> C["延迟消息到期"]
C --> D["尝试关单"]
D --> E{"status 是否 WAIT_PAY"}
E -- "是" --> F["关闭订单并释放库存"]
E -- "否" --> G["跳过"]
H["定时补偿任务"] --> I["扫描漏网 WAIT_PAY 超时订单"]
I --> D关键点:
- 更新必须带状态条件。
- 释放库存也要幂等。
- 延迟消息丢失时由定时扫描兜底。
- 扫描要分页,不能全表扫。
ES 同步失败补偿
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 对账
对账任务不要直接改账务数据,而是生成差异单。
流程:
- 按渠道和账期创建对账批次。
- 拉取渠道账单。
- 和本地订单按订单号、金额、状态比对。
- 生成差异单。
- 人工确认或补偿处理。
- 记录批次状态和审计日志。
阶段14:生产排查
任务不触发
flowchart TD
A["任务不触发"] --> B["Cron 是否正确"]
B --> C["应用时间和时区是否正确"]
C --> D["@Scheduled 是否是 Spring Bean"]
D --> E["XXL-JOB 执行器是否注册"]
E --> F["任务是否启用"]
F --> G["调度日志是否生成"]
G --> H["线程池是否满"]重复执行
flowchart TD
A["任务重复执行"] --> B["是否多实例本地调度"]
B --> C["是否失败重试"]
C --> D["是否人工重复点击"]
D --> E["分布式锁是否过期"]
E --> F["分片条件是否重叠"]
F --> G["业务幂等是否缺失"]越跑越慢
flowchart TD
A["任务越跑越慢"] --> B["查询 SQL 是否变慢"]
B --> C["分页是否深分页"]
C --> D["单批数据量是否过大"]
D --> E["下游接口是否变慢"]
E --> F["事务是否过大"]
F --> G["日志是否过多"]任务堆积
flowchart TD
A["任务堆积"] --> B["执行耗时是否超过调度周期"]
B --> C["新增数据速度是否大于处理速度"]
C --> D["线程池是否满"]
D --> E["下游是否限流"]
E --> F["是否需要分片或拆任务"]失败重试风暴
flowchart TD
A["失败重试风暴"] --> B["是否下游整体故障"]
B --> C["重试间隔是否太短"]
C --> D["是否没有最大重试次数"]
D --> E["是否所有失败同时重试"]
E --> F["改指数退避、限流、人工处理"]阶段15:告警闭环
告警不是发一条消息就结束。
有效告警应该包含:
- 任务名。
- 环境。
- 调度时间。
- 任务参数。
- 执行器地址。
- 异常摘要。
- 最近成功时间。
- 处理入口。
- 负责人。
闭环流程:
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 执行结果。
问:任务为什么必须幂等?
标准回答:
因为定时任务可能因为多实例、失败重试、人工重跑、锁过期、服务重启补偿而重复执行。幂等能保证重复处理同一批数据也不会产生重复扣款、重复发券、重复关单。常见手段包括状态条件、唯一索引、业务流水号、防重表和补偿记录。
最终验收题
如果下面问题答不清楚,说明还没有真正掌握定时任务:
- Cron 的 5 位、6 位、7 位有什么区别?
fixedRate和fixedDelay有什么区别?@Scheduled为什么多实例会重复执行?- 加分布式锁后为什么仍然要幂等?
- Quartz 的 JobDetail 和 Trigger 为什么要分开?
- Misfire 是什么,为什么不能无脑补跑?
- XXL-JOB 执行器启动后如何注册到 Admin?
- Admin 如何扫描和触发到期任务?
- 路由策略解决什么,不解决什么?
- 分片广播如何保证不漏不重?
- 阻塞策略怎么选?
- 失败重试为什么可能变成重试风暴?
- 订单超时关闭为什么常用延迟消息加定时补偿?
- 任务越跑越慢怎么拆证据排查?
- 告警闭环应该包含哪些信息?
关联知识点跳转
本章小结
定时任务精通的关键不是会写 cron,而是能把触发、执行、日志、重试、幂等、分片、阻塞、补偿、告警和排查串成闭环。生产里任务失败并不可怕,可怕的是失败不可见、重复执行不安全、慢任务拖垮数据库、补偿没有记录、告警没人处理。
