定时任务生产实践与排查
定时任务上线后最怕两类问题:一类是“不跑”,业务数据不推进;另一类是“乱跑”,重复处理、跑太慢、压垮数据库、生成脏数据。
这章只讲商业系统常见任务:订单关单、支付对账、报表汇总、ES 同步补偿、优惠券过期、会员等级刷新。
为什么要专门讲生产实践
定时任务在开发环境里通常很好跑,但生产环境的数据量、实例数量、网络情况和业务风险完全不同。如果只写一个能跑的方法,不考虑幂等、分页、超时、告警和补偿,常见后果是:订单重复关闭、报表重复生成、数据库被全表扫描拖慢、任务失败没人知道、资金对账差异长期堆积。
所以生产实践的核心不是把任务写出来,而是让任务在异常情况下仍然可控、可查、可恢复。
生产级任务设计流程
flowchart TD
A["明确业务目标"] --> B["确定触发方式"]
B --> C["设计幂等规则"]
C --> D["设计分页和批大小"]
D --> E["设计失败重试和告警"]
E --> F["设计执行日志"]
F --> G["灰度和压测"]
G --> H["上线观察指标"]不要一上来就写代码。先问清楚:
- 任务处理哪些数据。
- 可以重复执行吗。
- 一次最多处理多少。
- 失败后能不能重试。
- 是否影响资金、库存、权益。
- 任务执行慢时是否允许跳过下一轮。
幂等是第一原则
定时任务一定要假设自己会重复执行。
重复来源包括:
- 调度中心失败重试。
- 执行器超时但业务已经执行成功。
- 服务重启后人工补偿。
- 多实例误配置。
- 网络超时导致 Admin 认为失败。
订单关单幂等更新:
update t_order
set status = 'CLOSED',
close_reason = 'PAY_TIMEOUT',
updated_at = now()
where id = ?
and status = 'WAIT_PAY';发放权益幂等表:
create table t_member_benefit_grant (
id bigint primary key,
user_id bigint not null,
benefit_type varchar(64) not null,
biz_date date not null,
created_at datetime not null,
unique key uk_user_benefit_date (user_id, benefit_type, biz_date)
);如果任务重复执行,唯一索引会阻止重复发放。
分页不要用大 offset
错误示例:
select id
from t_order
where status = 'WAIT_PAY'
order by id
limit 100000, 500;数据越往后越慢,因为数据库需要先扫描并丢弃前面大量数据。
推荐游标分页:
select id
from t_order
where id > ?
and status = 'WAIT_PAY'
order by id
limit 500;代码示例:
long lastId = 0L;
int batchSize = 500;
while (true) {
List<Long> ids = repository.findIdsAfter(lastId, batchSize);
if (ids.isEmpty()) {
break;
}
for (Long id : ids) {
handleOne(id);
}
lastId = ids.get(ids.size() - 1);
}批大小怎么定
批大小不是越大越好。
| 批大小 | 问题 |
|---|---|
| 太小 | 调度次数、SQL 次数、网络开销大 |
| 太大 | 单次事务长、锁时间长、内存高、失败重试成本高 |
常见建议:
- 单批 100 到 1000 起步压测。
- 任务里不要开一个超大事务处理全部数据。
- 每批处理完记录进度和摘要日志。
- 下游慢时减小批大小或限流。
任务日志记录什么
不要在循环里打印每条数据的完整日志,否则日志会爆炸。
推荐记录:
| 日志 | 内容 |
|---|---|
| 开始日志 | jobName、参数、分片号、开始时间 |
| 批次日志 | batchNo、lastId、batchSize、success、failed |
| 异常日志 | 业务 ID、异常类型、错误原因 |
| 结束日志 | 总处理数、成功数、失败数、耗时 |
示例:
long start = System.currentTimeMillis();
int success = 0;
int failed = 0;
try {
// 执行业务
} finally {
log.info("job finished, jobName={}, success={}, failed={}, costMs={}",
"productEsSyncCompensateJob", success, failed, System.currentTimeMillis() - start);
}常见商业任务设计
订单超时关闭
设计重点:
- 查询超时未支付订单。
- 更新时带状态条件。
- 关闭后释放库存或发送订单关闭事件。
- 延迟消息负责实时关单,定时任务负责补偿。
流程:
flowchart TD
A["扫描 WAIT_PAY 超时订单"] --> B["条件更新为 CLOSED"]
B --> C{"更新行数是否为 1"}
C -- "是" --> D["发送订单关闭事件"]
C -- "否" --> E["说明订单状态已变化,跳过"]
D --> F["释放库存、回退优惠券"]T+1 支付对账
设计重点:
- 拉取三方支付账单。
- 和本地支付单按流水号匹配。
- 差异进入对账差异表。
- 不能自动乱改资金状态。
差异表:
create table t_pay_reconcile_diff (
id bigint primary key,
pay_no varchar(64) not null,
channel_no varchar(64),
local_amount decimal(18, 2),
channel_amount decimal(18, 2),
diff_type varchar(32) not null,
status varchar(32) not null,
created_at datetime not null
);资金类任务宁愿保守,也不要静默自动修错。异常要留痕、告警、人工确认。
ES 同步补偿
设计重点:
- 业务变更先写同步记录。
- MQ 消费成功后标记成功。
- 定时任务扫描失败或超时未成功记录。
- 重建搜索文档再写 ES。
flowchart TD
A["商品变更"] --> B["写 product_sync_record"]
B --> C["发送 MQ"]
C --> D["消费者写 ES"]
D --> E["标记同步成功"]
F["定时补偿任务"] --> G["扫描未成功记录"]
G --> D报表汇总
设计重点:
- 不要每次页面访问都实时扫大表。
- 定时把订单、支付、用户行为汇总到报表表。
- 以业务日期作为幂等维度。
- 支持重跑某一天。
唯一索引:
create unique index uk_report_date_shop
on t_shop_daily_report (report_date, shop_id);保存时使用插入或更新,保证重复跑同一天不会生成两份报表。
告警规则
至少配置这些告警:
| 告警 | 说明 |
|---|---|
| 连续失败 | 同一任务连续失败 3 次 |
| 长时间未触发 | 任务超过预期时间没有执行记录 |
| 执行超时 | 超过任务预设最大耗时 |
| 失败数据过多 | 单次失败数量超过阈值 |
| 执行器离线 | XXL-JOB 执行器不在线 |
没有告警的定时任务,本质上是“出了事等用户发现”。
故障排查总流程
生产上遇到定时任务问题,不要一上来就改代码或重启。先把链路拆开,看问题卡在哪一层。
flowchart TD
A["发现任务异常"] --> B["确认是否到触发时间"]
B --> C["检查调度层是否触发"]
C --> D["检查执行器是否收到请求"]
D --> E["检查业务代码是否执行"]
E --> F["检查业务数据是否变化"]
F --> G["检查日志、告警、补偿记录"]
G --> H["定位是调度问题、执行问题还是业务问题"]可以用一句话记:
先证明“有没有触发”,再证明“有没有执行”,最后证明“业务有没有成功落库”。
这三个问题不能混在一起。比如 XXL-JOB 控制台显示调度成功,只能说明 Admin 把请求发出去了,不代表业务一定处理成功;业务日志显示执行成功,也不代表数据库事务一定提交成功。
任务不触发排查全过程
任务不触发的本质是:调度层没有在预期时间把任务提交给执行层。
flowchart TD
A["任务没有执行记录"] --> B{"是哪种方案"}
B -- "@Scheduled" --> C["查 EnableScheduling 和 Bean 扫描"]
B -- "Quartz" --> D["查 Scheduler、Trigger、QRTZ 表"]
B -- "XXL-JOB" --> E["查任务状态、Cron、Admin 调度日志"]
C --> F["查线程池是否被阻塞"]
D --> F
E --> G["查执行器是否在线"]
F --> H["确认是否真的未触发"]
G --> H第一步:确认时间和 Cron
先确认任务真的应该执行。
| 检查项 | 为什么 |
|---|---|
| Cron 表达式字段数量 | Spring/XXL-JOB 常见 6 位,Linux crontab 常见 5 位 |
| 时区 | 容器、JVM、数据库、服务器时区不一致会导致误判 |
| 任务是否暂停 | Quartz/XXL-JOB 可能被人工暂停 |
| 是否在启动延迟期 | 应用刚启动时任务可能还没注册完成 |
| 是否错过触发 | 应用停机期间 @Scheduled 不会自动补全部错过任务 |
Spring Cron 示例:
@Scheduled(cron = "0 */5 * * * ?")
public void task() {
}Linux crontab 的 */5 * * * * 不能直接照搬到 Spring,因为少了秒字段。
第二步:按框架查调度层
| 框架 | 优先证据 |
|---|---|
@Scheduled | 应用启动日志、是否加 @EnableScheduling、任务类是否是 Bean |
| Quartz | Scheduler 是否启动、Trigger 状态、QRTZ_TRIGGERS |
| XXL-JOB | Admin 调度日志、任务是否启用、执行器是否在线 |
XXL-JOB 如果没有调度日志,问题多半在 Admin 侧:任务暂停、Cron 不到点、Admin 时间异常、调度线程异常。
如果有调度日志但触发失败,问题多半在 Admin 到 Executor:执行器离线、网络不通、端口被拦、防火墙、accessToken 不一致。
第三步:查线程池是否被占满
任务不触发有时不是没触发,而是触发后排队。
典型现象:
- 日志里能看到任务偶尔开始,但时间明显晚于 Cron。
- 多个任务集中延迟。
- 有一个慢任务长期运行。
- JVM 线程栈里能看到调度线程被占用。
处理思路:
- 给任务线程命名,例如
biz-schedule-。 - 打印任务开始和结束日志。
- 记录每次耗时。
- 对慢任务拆批、限流或迁移到 XXL-JOB。
任务跑了但没效果排查全过程
“任务跑了但没效果”比“不触发”更隐蔽。它说明调度层和执行层可能都正常,但业务条件、事务或数据状态不符合预期。
flowchart TD
A["任务日志显示执行"] --> B["检查查询条件"]
B --> C["检查是否查到数据"]
C --> D["检查更新行数"]
D --> E["检查事务是否提交"]
E --> F["检查后续事件是否发送"]
F --> G["检查下游是否处理成功"]以订单关单为例,必须拆开看:
| 环节 | 证据 | 可能问题 |
|---|---|---|
| 查询超时订单 | 查询 SQL 返回多少 ID | 时间条件错、状态错、索引错 |
| 条件更新 | update 行数 | 订单已支付、已关闭、状态机变化 |
| 发送事件 | MQ 发送日志 | 事件发送失败 |
| 释放库存 | 库存流水 | 下游消费失败 |
推荐任务日志不要只写“success”,而要写数量:
log.info("close order task finished, scanned={}, closed={}, skipped={}, failed={}, costMs={}",
scanned, closed, skipped, failed, costMs);如果 scanned=0,说明查询条件或数据范围有问题;如果 scanned>0 但 closed=0,说明状态条件没有命中;如果 closed>0 但库存没释放,说明后续事件链路有问题。
重复执行排查全过程
重复执行不是只看“任务跑了几次”,还要看“业务是否重复生效”。
flowchart TD
A["发现重复数据"] --> B["查调度日志是否多次触发"]
B --> C["查是否多实例本地调度"]
C --> D["查是否失败重试"]
D --> E["查是否人工补偿重复触发"]
E --> F["查业务幂等是否缺失"]
F --> G["修复脏数据并补幂等约束"]重复来源
| 来源 | 现象 | 处理 |
|---|---|---|
@Scheduled 多实例 | 每台实例同一时间都有日志 | 加锁、幂等或迁移 XXL-JOB |
| XXL-JOB 失败重试 | 调度日志里同一任务多次重试 | 区分临时失败和业务失败 |
| 手动补偿 | 运维或开发重复点击执行 | 补偿任务必须按批次幂等 |
| 锁过期 | 两个实例先后进入任务 | 缩短任务、续期锁、业务幂等 |
| 分片写错 | 每个分片都扫全量 | SQL 加分片条件 |
先止血,再修设计
如果已经重复发券、重复生成报表、重复推送消息,处理顺序是:
- 暂停任务,防止继续扩大。
- 查调度日志和业务流水,确定重复范围。
- 根据唯一业务键找重复数据。
- 对外部影响数据制定回滚或冲正方案。
- 补唯一索引、状态条件或防重表。
- 灰度恢复任务。
不要只删除重复数据就完事。如果不补幂等,下一次重试还会重复。
执行慢排查全过程
任务慢的本质是:单次处理耗时超过预期,导致下一轮延迟、阻塞、堆积或压垮下游。
flowchart TD
A["任务执行慢"] --> B["拆分耗时"]
B --> C["SQL 查询耗时"]
B --> D["业务处理耗时"]
B --> E["下游接口耗时"]
B --> F["日志和序列化耗时"]
C --> G["看 EXPLAIN 和索引"]
D --> H["看批大小和事务"]
E --> I["看超时、限流、重试"]
F --> J["减少循环日志"]先做耗时拆分
不要只说“任务慢”。要把耗时拆成:
| 指标 | 说明 |
|---|---|
| 查询耗时 | 扫库是否慢 |
| 单条处理耗时 | 每条业务逻辑是否慢 |
| 下游调用耗时 | Redis、MQ、ES、三方接口是否慢 |
| 提交事务耗时 | 是否大事务、锁等待 |
| 日志耗时 | 是否循环打印大量日志 |
示例:
long queryStart = System.currentTimeMillis();
List<Long> ids = repository.findTimeoutOrders(lastId, batchSize);
long queryCost = System.currentTimeMillis() - queryStart;
long handleStart = System.currentTimeMillis();
for (Long id : ids) {
handleOne(id);
}
long handleCost = System.currentTimeMillis() - handleStart;
log.info("task batch finished, size={}, queryCostMs={}, handleCostMs={}",
ids.size(), queryCost, handleCost);SQL 慢怎么查
先用 EXPLAIN 看是否走合适索引:
explain
select id
from t_order
where status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
and id > ?
order by id
limit 500;可能的问题:
| 问题 | 表现 | 处理 |
|---|---|---|
| 没索引 | type=ALL,扫描行数大 | 建联合索引 |
| 索引列顺序不合适 | rows 很大,过滤性差 | 调整索引顺序 |
| 深分页 | offset 越大越慢 | 改游标分页 |
| 排序 filesort | order by 没利用索引 | 让查询和排序匹配索引 |
| 单批太大 | 锁时间长、内存高 | 减小 batchSize |
任务堆积排查全过程
堆积指任务产生速度大于处理速度。定时任务里常见两种堆积:
- 调度堆积:上一次没跑完,下一次又来了。
- 数据堆积:失败表、补偿表、待处理表越积越多。
flowchart TD
A["发现任务堆积"] --> B{"堆积类型"}
B -- "调度堆积" --> C["看任务耗时是否超过周期"]
B -- "数据堆积" --> D["看新增速度和处理速度"]
C --> E["调大周期、拆任务、分片"]
D --> F["扩容消费者、优化 SQL、限流下游"]
E --> G["确认幂等和补偿"]
F --> G调度堆积
例子:任务每 1 分钟触发一次,但每次执行 5 分钟。
处理方式:
| 方案 | 说明 |
|---|---|
| 调大调度间隔 | 让周期大于正常执行耗时 |
| 减小批大小 | 降低单次耗时 |
| 分片并行 | 多执行器分担数据 |
| 阻塞策略丢弃后续 | 允许漏掉中间轮次时可用 |
| 拆分任务 | 把一个大任务拆成多个小任务 |
数据堆积
例子:ES 同步失败表每分钟新增 500 条,但补偿任务每分钟只能处理 100 条。
先算清楚:
净增长 = 新增速度 - 处理速度
预计清空时间 = 当前积压量 / 每分钟净处理能力如果新增速度大于处理速度,单纯“等它跑完”没有意义。
处理方式:
- 优先恢复主链路,比如 MQ 消费、ES 写入。
- 增加补偿任务分片或执行器。
- 优化失败表索引。
- 降低单条处理耗时。
- 对永久失败数据转人工,不要无限重试。
补偿任务设计全过程
补偿任务不是“失败了再扫一下”这么简单。它要有明确的数据来源、状态流转、重试次数和人工兜底。
flowchart TD
A["主链路处理失败"] --> B["写入失败记录"]
B --> C["状态 WAIT_RETRY"]
C --> D["补偿任务扫描"]
D --> E{"重试是否成功"}
E -- "成功" --> F["状态 SUCCESS"]
E -- "失败但未超限" --> G["retry_count + 1"]
E -- "失败且超限" --> H["状态 MANUAL_CHECK"]
G --> C
H --> I["告警人工处理"]失败表建议字段:
| 字段 | 作用 |
|---|---|
biz_type | 区分订单、商品、支付等业务 |
biz_id | 业务主键 |
status | WAIT_RETRY、SUCCESS、MANUAL_CHECK |
retry_count | 已重试次数 |
next_retry_time | 下次重试时间 |
last_error | 最近失败原因 |
created_at | 首次失败时间 |
updated_at | 最近更新时间 |
补偿任务 SQL:
select id, biz_type, biz_id, retry_count
from t_retry_task
where status = 'WAIT_RETRY'
and retry_count < 5
and next_retry_time <= now()
order by id
limit 200;更新时也要带状态:
update t_retry_task
set status = 'SUCCESS',
updated_at = now()
where id = ?
and status = 'WAIT_RETRY';这样可以防止人工已经处理或其他任务已经处理成功后,又被旧任务覆盖状态。
告警闭环设计
告警不是“发一条消息”就结束,必须能推动人处理问题。
flowchart TD
A["任务失败"] --> B["记录失败日志"]
B --> C["判断是否达到告警阈值"]
C -- "否" --> D["等待下一次"]
C -- "是" --> E["发送告警给负责人"]
E --> F["负责人确认"]
F --> G["处理或转人工补偿"]
G --> H["恢复后关闭告警"]一条有效告警至少包含:
| 信息 | 为什么 |
|---|---|
| 任务名 | 知道哪个任务出问题 |
| 环境 | 避免测试、预发、生产混淆 |
| 失败时间 | 判断影响窗口 |
| 失败参数 | 判断影响范围 |
| 异常摘要 | 快速定位类型 |
| 最近成功时间 | 判断中断多久 |
| 处理入口 | 链接到日志、控制台或补偿页面 |
无效告警的典型特征:
- 只有“任务失败”四个字。
- 没有任务参数。
- 没有负责人。
- 每分钟刷屏但没人处理。
- 恢复后不会自动关闭。
排查清单
| 现象 | 优先检查 |
|---|---|
| 任务没跑 | cron、任务状态、调度中心时间、执行器注册 |
| 任务跑了但没效果 | 业务查询条件、状态过滤、事务是否提交 |
| 重复处理 | 幂等条件、唯一索引、重试、分布式锁 |
| 执行慢 | SQL 索引、批大小、下游接口、日志量 |
| 数据库压力高 | 扫描范围、是否全表扫、是否整点集中 |
| 偶发失败 | 网络、连接池、下游限流、锁等待 |
| 任务卡住 | 死循环、外部接口无超时、线程池耗尽 |
SQL 排查 Demo
查慢 SQL 执行计划:
explain
select id
from t_order
where status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
order by id
limit 500;推荐索引要结合查询条件,例如:
create index idx_order_status_created_id
on t_order (status, created_at, id);索引不是固定答案,要根据实际 SQL、数据分布和执行计划判断。
上线前检查表
| 检查项 | 是否必须 |
|---|---|
| 任务是否幂等 | 必须 |
| 是否分页处理 | 必须 |
| 是否限制单次处理量 | 必须 |
| 是否有执行日志 | 必须 |
| 是否有失败告警 | 必须 |
| 是否有超时设置 | 必须 |
| 是否能人工补偿 | 关键任务必须 |
| 是否避开业务高峰 | 建议 |
| 是否压测过数据量 | 建议 |
本章小结
生产级定时任务的核心不是 cron,而是稳定性和可恢复性。
真正可靠的任务应该做到:
- 重复执行不会错。
- 执行失败能知道。
- 失败后能重试或人工补偿。
- 数据量大时不会拖垮数据库。
- 任务结果有记录,出了问题能追溯。
