Skip to content

定时任务生产实践与排查

定时任务上线后最怕两类问题:一类是“不跑”,业务数据不推进;另一类是“乱跑”,重复处理、跑太慢、压垮数据库、生成脏数据。

这章只讲商业系统常见任务:订单关单、支付对账、报表汇总、ES 同步补偿、优惠券过期、会员等级刷新。

为什么要专门讲生产实践

定时任务在开发环境里通常很好跑,但生产环境的数据量、实例数量、网络情况和业务风险完全不同。如果只写一个能跑的方法,不考虑幂等、分页、超时、告警和补偿,常见后果是:订单重复关闭、报表重复生成、数据库被全表扫描拖慢、任务失败没人知道、资金对账差异长期堆积。

所以生产实践的核心不是把任务写出来,而是让任务在异常情况下仍然可控、可查、可恢复。

生产级任务设计流程

mermaid
flowchart TD
    A["明确业务目标"] --> B["确定触发方式"]
    B --> C["设计幂等规则"]
    C --> D["设计分页和批大小"]
    D --> E["设计失败重试和告警"]
    E --> F["设计执行日志"]
    F --> G["灰度和压测"]
    G --> H["上线观察指标"]

不要一上来就写代码。先问清楚:

  1. 任务处理哪些数据。
  2. 可以重复执行吗。
  3. 一次最多处理多少。
  4. 失败后能不能重试。
  5. 是否影响资金、库存、权益。
  6. 任务执行慢时是否允许跳过下一轮。

幂等是第一原则

定时任务一定要假设自己会重复执行。

重复来源包括:

  1. 调度中心失败重试。
  2. 执行器超时但业务已经执行成功。
  3. 服务重启后人工补偿。
  4. 多实例误配置。
  5. 网络超时导致 Admin 认为失败。

订单关单幂等更新:

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

发放权益幂等表:

sql
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

错误示例:

sql
select id
from t_order
where status = 'WAIT_PAY'
order by id
limit 100000, 500;

数据越往后越慢,因为数据库需要先扫描并丢弃前面大量数据。

推荐游标分页:

sql
select id
from t_order
where id > ?
  and status = 'WAIT_PAY'
order by id
limit 500;

代码示例:

java
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 次数、网络开销大
太大单次事务长、锁时间长、内存高、失败重试成本高

常见建议:

  1. 单批 100 到 1000 起步压测。
  2. 任务里不要开一个超大事务处理全部数据。
  3. 每批处理完记录进度和摘要日志。
  4. 下游慢时减小批大小或限流。

任务日志记录什么

不要在循环里打印每条数据的完整日志,否则日志会爆炸。

推荐记录:

日志内容
开始日志jobName、参数、分片号、开始时间
批次日志batchNo、lastId、batchSize、success、failed
异常日志业务 ID、异常类型、错误原因
结束日志总处理数、成功数、失败数、耗时

示例:

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

常见商业任务设计

订单超时关闭

设计重点:

  1. 查询超时未支付订单。
  2. 更新时带状态条件。
  3. 关闭后释放库存或发送订单关闭事件。
  4. 延迟消息负责实时关单,定时任务负责补偿。

流程:

mermaid
flowchart TD
    A["扫描 WAIT_PAY 超时订单"] --> B["条件更新为 CLOSED"]
    B --> C{"更新行数是否为 1"}
    C -- "是" --> D["发送订单关闭事件"]
    C -- "否" --> E["说明订单状态已变化,跳过"]
    D --> F["释放库存、回退优惠券"]

T+1 支付对账

设计重点:

  1. 拉取三方支付账单。
  2. 和本地支付单按流水号匹配。
  3. 差异进入对账差异表。
  4. 不能自动乱改资金状态。

差异表:

sql
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 同步补偿

设计重点:

  1. 业务变更先写同步记录。
  2. MQ 消费成功后标记成功。
  3. 定时任务扫描失败或超时未成功记录。
  4. 重建搜索文档再写 ES。
mermaid
flowchart TD
    A["商品变更"] --> B["写 product_sync_record"]
    B --> C["发送 MQ"]
    C --> D["消费者写 ES"]
    D --> E["标记同步成功"]
    F["定时补偿任务"] --> G["扫描未成功记录"]
    G --> D

报表汇总

设计重点:

  1. 不要每次页面访问都实时扫大表。
  2. 定时把订单、支付、用户行为汇总到报表表。
  3. 以业务日期作为幂等维度。
  4. 支持重跑某一天。

唯一索引:

sql
create unique index uk_report_date_shop
on t_shop_daily_report (report_date, shop_id);

保存时使用插入或更新,保证重复跑同一天不会生成两份报表。

告警规则

至少配置这些告警:

告警说明
连续失败同一任务连续失败 3 次
长时间未触发任务超过预期时间没有执行记录
执行超时超过任务预设最大耗时
失败数据过多单次失败数量超过阈值
执行器离线XXL-JOB 执行器不在线

没有告警的定时任务,本质上是“出了事等用户发现”。

故障排查总流程

生产上遇到定时任务问题,不要一上来就改代码或重启。先把链路拆开,看问题卡在哪一层。

mermaid
flowchart TD
    A["发现任务异常"] --> B["确认是否到触发时间"]
    B --> C["检查调度层是否触发"]
    C --> D["检查执行器是否收到请求"]
    D --> E["检查业务代码是否执行"]
    E --> F["检查业务数据是否变化"]
    F --> G["检查日志、告警、补偿记录"]
    G --> H["定位是调度问题、执行问题还是业务问题"]

可以用一句话记:

先证明“有没有触发”,再证明“有没有执行”,最后证明“业务有没有成功落库”。

这三个问题不能混在一起。比如 XXL-JOB 控制台显示调度成功,只能说明 Admin 把请求发出去了,不代表业务一定处理成功;业务日志显示执行成功,也不代表数据库事务一定提交成功。

任务不触发排查全过程

任务不触发的本质是:调度层没有在预期时间把任务提交给执行层。

mermaid
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 示例:

java
@Scheduled(cron = "0 */5 * * * ?")
public void task() {
}

Linux crontab 的 */5 * * * * 不能直接照搬到 Spring,因为少了秒字段。

第二步:按框架查调度层

框架优先证据
@Scheduled应用启动日志、是否加 @EnableScheduling、任务类是否是 Bean
QuartzScheduler 是否启动、Trigger 状态、QRTZ_TRIGGERS
XXL-JOBAdmin 调度日志、任务是否启用、执行器是否在线

XXL-JOB 如果没有调度日志,问题多半在 Admin 侧:任务暂停、Cron 不到点、Admin 时间异常、调度线程异常。

如果有调度日志但触发失败,问题多半在 Admin 到 Executor:执行器离线、网络不通、端口被拦、防火墙、accessToken 不一致。

第三步:查线程池是否被占满

任务不触发有时不是没触发,而是触发后排队。

典型现象:

  1. 日志里能看到任务偶尔开始,但时间明显晚于 Cron。
  2. 多个任务集中延迟。
  3. 有一个慢任务长期运行。
  4. JVM 线程栈里能看到调度线程被占用。

处理思路:

  1. 给任务线程命名,例如 biz-schedule-
  2. 打印任务开始和结束日志。
  3. 记录每次耗时。
  4. 对慢任务拆批、限流或迁移到 XXL-JOB。

任务跑了但没效果排查全过程

“任务跑了但没效果”比“不触发”更隐蔽。它说明调度层和执行层可能都正常,但业务条件、事务或数据状态不符合预期。

mermaid
flowchart TD
    A["任务日志显示执行"] --> B["检查查询条件"]
    B --> C["检查是否查到数据"]
    C --> D["检查更新行数"]
    D --> E["检查事务是否提交"]
    E --> F["检查后续事件是否发送"]
    F --> G["检查下游是否处理成功"]

以订单关单为例,必须拆开看:

环节证据可能问题
查询超时订单查询 SQL 返回多少 ID时间条件错、状态错、索引错
条件更新update 行数订单已支付、已关闭、状态机变化
发送事件MQ 发送日志事件发送失败
释放库存库存流水下游消费失败

推荐任务日志不要只写“success”,而要写数量:

java
log.info("close order task finished, scanned={}, closed={}, skipped={}, failed={}, costMs={}",
        scanned, closed, skipped, failed, costMs);

如果 scanned=0,说明查询条件或数据范围有问题;如果 scanned>0closed=0,说明状态条件没有命中;如果 closed>0 但库存没释放,说明后续事件链路有问题。

重复执行排查全过程

重复执行不是只看“任务跑了几次”,还要看“业务是否重复生效”。

mermaid
flowchart TD
    A["发现重复数据"] --> B["查调度日志是否多次触发"]
    B --> C["查是否多实例本地调度"]
    C --> D["查是否失败重试"]
    D --> E["查是否人工补偿重复触发"]
    E --> F["查业务幂等是否缺失"]
    F --> G["修复脏数据并补幂等约束"]

重复来源

来源现象处理
@Scheduled 多实例每台实例同一时间都有日志加锁、幂等或迁移 XXL-JOB
XXL-JOB 失败重试调度日志里同一任务多次重试区分临时失败和业务失败
手动补偿运维或开发重复点击执行补偿任务必须按批次幂等
锁过期两个实例先后进入任务缩短任务、续期锁、业务幂等
分片写错每个分片都扫全量SQL 加分片条件

先止血,再修设计

如果已经重复发券、重复生成报表、重复推送消息,处理顺序是:

  1. 暂停任务,防止继续扩大。
  2. 查调度日志和业务流水,确定重复范围。
  3. 根据唯一业务键找重复数据。
  4. 对外部影响数据制定回滚或冲正方案。
  5. 补唯一索引、状态条件或防重表。
  6. 灰度恢复任务。

不要只删除重复数据就完事。如果不补幂等,下一次重试还会重复。

执行慢排查全过程

任务慢的本质是:单次处理耗时超过预期,导致下一轮延迟、阻塞、堆积或压垮下游。

mermaid
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、三方接口是否慢
提交事务耗时是否大事务、锁等待
日志耗时是否循环打印大量日志

示例:

java
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 看是否走合适索引:

sql
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 越大越慢改游标分页
排序 filesortorder by 没利用索引让查询和排序匹配索引
单批太大锁时间长、内存高减小 batchSize

任务堆积排查全过程

堆积指任务产生速度大于处理速度。定时任务里常见两种堆积:

  1. 调度堆积:上一次没跑完,下一次又来了。
  2. 数据堆积:失败表、补偿表、待处理表越积越多。
mermaid
flowchart TD
    A["发现任务堆积"] --> B{"堆积类型"}
    B -- "调度堆积" --> C["看任务耗时是否超过周期"]
    B -- "数据堆积" --> D["看新增速度和处理速度"]
    C --> E["调大周期、拆任务、分片"]
    D --> F["扩容消费者、优化 SQL、限流下游"]
    E --> G["确认幂等和补偿"]
    F --> G

调度堆积

例子:任务每 1 分钟触发一次,但每次执行 5 分钟。

处理方式:

方案说明
调大调度间隔让周期大于正常执行耗时
减小批大小降低单次耗时
分片并行多执行器分担数据
阻塞策略丢弃后续允许漏掉中间轮次时可用
拆分任务把一个大任务拆成多个小任务

数据堆积

例子:ES 同步失败表每分钟新增 500 条,但补偿任务每分钟只能处理 100 条。

先算清楚:

text
净增长 = 新增速度 - 处理速度
预计清空时间 = 当前积压量 / 每分钟净处理能力

如果新增速度大于处理速度,单纯“等它跑完”没有意义。

处理方式:

  1. 优先恢复主链路,比如 MQ 消费、ES 写入。
  2. 增加补偿任务分片或执行器。
  3. 优化失败表索引。
  4. 降低单条处理耗时。
  5. 对永久失败数据转人工,不要无限重试。

补偿任务设计全过程

补偿任务不是“失败了再扫一下”这么简单。它要有明确的数据来源、状态流转、重试次数和人工兜底。

mermaid
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业务主键
statusWAIT_RETRY、SUCCESS、MANUAL_CHECK
retry_count已重试次数
next_retry_time下次重试时间
last_error最近失败原因
created_at首次失败时间
updated_at最近更新时间

补偿任务 SQL:

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;

更新时也要带状态:

sql
update t_retry_task
set status = 'SUCCESS',
    updated_at = now()
where id = ?
  and status = 'WAIT_RETRY';

这样可以防止人工已经处理或其他任务已经处理成功后,又被旧任务覆盖状态。

告警闭环设计

告警不是“发一条消息”就结束,必须能推动人处理问题。

mermaid
flowchart TD
    A["任务失败"] --> B["记录失败日志"]
    B --> C["判断是否达到告警阈值"]
    C -- "否" --> D["等待下一次"]
    C -- "是" --> E["发送告警给负责人"]
    E --> F["负责人确认"]
    F --> G["处理或转人工补偿"]
    G --> H["恢复后关闭告警"]

一条有效告警至少包含:

信息为什么
任务名知道哪个任务出问题
环境避免测试、预发、生产混淆
失败时间判断影响窗口
失败参数判断影响范围
异常摘要快速定位类型
最近成功时间判断中断多久
处理入口链接到日志、控制台或补偿页面

无效告警的典型特征:

  1. 只有“任务失败”四个字。
  2. 没有任务参数。
  3. 没有负责人。
  4. 每分钟刷屏但没人处理。
  5. 恢复后不会自动关闭。

排查清单

现象优先检查
任务没跑cron、任务状态、调度中心时间、执行器注册
任务跑了但没效果业务查询条件、状态过滤、事务是否提交
重复处理幂等条件、唯一索引、重试、分布式锁
执行慢SQL 索引、批大小、下游接口、日志量
数据库压力高扫描范围、是否全表扫、是否整点集中
偶发失败网络、连接池、下游限流、锁等待
任务卡住死循环、外部接口无超时、线程池耗尽

SQL 排查 Demo

查慢 SQL 执行计划:

sql
explain
select id
from t_order
where status = 'WAIT_PAY'
  and created_at < now() - interval 30 minute
order by id
limit 500;

推荐索引要结合查询条件,例如:

sql
create index idx_order_status_created_id
on t_order (status, created_at, id);

索引不是固定答案,要根据实际 SQL、数据分布和执行计划判断。

上线前检查表

检查项是否必须
任务是否幂等必须
是否分页处理必须
是否限制单次处理量必须
是否有执行日志必须
是否有失败告警必须
是否有超时设置必须
是否能人工补偿关键任务必须
是否避开业务高峰建议
是否压测过数据量建议

本章小结

生产级定时任务的核心不是 cron,而是稳定性和可恢复性。

真正可靠的任务应该做到:

  1. 重复执行不会错。
  2. 执行失败能知道。
  3. 失败后能重试或人工补偿。
  4. 数据量大时不会拖垮数据库。
  5. 任务结果有记录,出了问题能追溯。