"这个任务跑了两遍"——对账金额翻倍、券发了两次、消息重复推送。第一反应通常是"加个锁",但重复执行只是表象。定时任务的正确性由三个责任拼成:谁触发(调度)、谁执行(运行载体)、谁保证不重不漏(幂等与补偿)。三者混在一个 @Scheduled 方法里,任何一次扩容、发布、重试都会把问题暴露出来。
1. 三个责任必须分开
| 责任 | 归属 | 失败时的表现 |
|---|---|---|
| 触发 | 调度器(单机 cron / 分布式调度 / 消息延迟) | 不触发、重复触发、错过 |
| 执行 | 工作节点(线程池、容器、批处理框架) | 卡死、被发布打断、资源争抢 |
| 不重不漏 | 业务侧幂等 + 状态机 + 补偿 | 重复入账、漏跑、半完成 |
把三个责任塞进一个方法里,等价于让"触发频率"决定"业务正确性"——扩容一个副本,业务就多执行一次。
2. 单机定时在多实例下的必然重复
@Scheduled(cron = "0 0 2 * * *")
public void settle() { // 3 个副本 → 每天 2:00 执行 3 次
ledger.settleAll();
}
@Scheduled 是进程内调度,没有任何跨进程协调。多副本、蓝绿并存、K8s 滚动发布期间的新老 Pod 重叠,都会让它并行触发。可行的补救只有两条路:让执行本身幂等(首选),或引入跨进程互斥(次选,且要处理锁续期与失效)。
3. 分布式调度的三种实现与失效模式
| 实现 | 机制 | 典型失效 |
|---|---|---|
| 抢占式锁 | 触发前抢 DB/Redis 锁,抢到才执行 | 锁过期早于任务结束 → 二次执行;节点时钟漂移 |
| 选主 | 集群选一个 leader 负责全部触发 | 脑裂期双主;主切换窗口内任务丢失 |
| 中心化调度 | 独立调度服务按分片下发 | 调度服务自身成为单点;下发与执行状态不一致 |
| 维度 | 抢占式锁 | 选主 | 中心化 |
|---|---|---|---|
| 引入组件 | 一个共享存储 | 协调服务 | 独立调度集群 |
| 运维成本 | 低 | 中 | 高 |
| 适合任务数 | 少到中 | 中 | 多、需分片与依赖 |
| 最难处理 | 锁续期 | 切换窗口 | 调度与执行的状态对齐 |
选型的判据不是"哪个先进",而是任务数量、是否需要分片、是否能接受一个独立组件。三五个任务用中心化调度,是把运维成本换成一份额外的故障源。
锁的正确写法要带持有者标识与续期,删除时校验持有者,避免"删掉别人的锁":
String token = UUID.randomUUID().toString();
boolean got = redis.setIfAbsent("lock:settle", token, Duration.ofMinutes(10));
if (!got) return; // 未抢到直接退出,不排队
try {
runSettle();
} finally {
if (token.equals(redis.get("lock:settle"))) redis.del("lock:settle");
}
4. 幂等的三种保证
| 手段 | 适用 | 前提 | 代价 |
|---|---|---|---|
| 业务唯一键 | 有天然唯一维度(订单号+日期) | 唯一键可稳定生成 | 冲突要定义"跳过"还是"报错" |
| 状态机 | 有明确生命周期(待处理→处理中→完成) | 状态跃迁可原子更新 | 状态设计错就永远卡住 |
| 去重表 | 无天然唯一键、需留痕 | 表可清理、写入与业务同事务 | 表膨胀与清理策略 |
-- 状态机式幂等:用条件更新把"只执行一次"变成数据库语义
UPDATE task_item SET status = 'RUNNING', owner = ?, started_at = NOW()
WHERE id = ? AND status = 'PENDING'; -- 影响行数为 0 即已被别人领走
最容易被忽略的前提是:任务本身的执行也必须幂等。如果"发券"接口自身不支持重复调用,去重表只能防住调度层重复,防不住执行中途失败后的重跑。
5. 长任务:分片、断点续跑、进度
// 分片:按主键区间切,片内独立提交,单片失败不影响其他片
for (long shard = 0; shard < SHARDS; shard++) {
if (!claimShard(jobId, shard)) continue; // 已完成的片跳过
processRange(shard, SHARDS);
markShardDone(jobId, shard); // 断点:完成一片记一片
}
| 设计点 | 不做会怎样 |
|---|---|
| 固定分片数 | 无法并行、无法断点续跑 |
| 每片记录完成标记 | 失败重跑从头开始 |
| 进度落库(已处理条数) | 只能靠日志估算,无法对外展示 |
| 单片有超时 | 单片卡死拖住整个任务 |
长任务的另一个隐性成本是发布打断:滚动发布会在任意时刻杀掉进程。断点续跑不是优化项,是多副本部署下的必需品。
6. 重试与死信
| 项 | 建议 | 说明 |
|---|---|---|
| 重试次数 | 有限次(3–5 次量级) | 无限重试等于把故障变成持续负载 |
| 退避 | 指数 + 抖动 | 固定间隔会让失败任务同步重试,形成尖峰 |
| 可重试判定 | 只重试瞬时错误 | 数据错误重试一万次也不会成功 |
| 死信 | 落表 + 人工入口 | 需要能单条重放,且重放本身幂等 |
RetryPolicy policy = RetryPolicy.builder()
.maxAttempts(4)
.backoff(Backoff.exponential(Duration.ofSeconds(30), 8)) // 上限 8 倍
.retryOn(TransientException.class)
.build();
死信表要有三个字段才能用:失败原因、已重试次数、可重放标识。缺了第三个,人工只能全量重跑。
7. 错过的任务要不要补
| 场景 | 结论 | 依据 |
|---|---|---|
| 对账、结算 | 补,且按业务日补齐 | 数据完整性有硬要求 |
| 缓存预热、报表刷新 | 不补,下次覆盖 | 结果幂等于"重算一份" |
| 消息推送、提醒 | 视时效,过期不补 | 迟到的通知比没有更糟 |
| 状态推进 | 补,但要能追平 | 不补会永久停在中间态 |
补偿策略必须写进任务定义,而不是等出事再临时决定。判断标准始终是业务语义:这次执行的产出,晚到还有没有价值。
8. 批处理的节奏
int batch = 500; // 批大小:按单批耗时与锁持有时间定
while (true) {
List<Row> rows = mapper.page(cursor, batch);
if (rows.isEmpty()) break;
txTemplate.execute(s -> { rows.forEach(s::update); return null; });
cursor = rows.get(rows.size() - 1).id();
Thread.sleep(50); // 提交间隔:给主库留出喘息
}
批大小的约束来自两头:太小则事务与网络开销占比高,太大则单事务持锁时间长、undo 膨胀、主从延迟放大。典型做法是压测出"单批耗时在百毫秒级"的量级,再按主库负载下调。提交间隔不是为了"优雅",是为了让在线流量不被批任务挤出队列。
9. 依赖:DAG 与触发器
| 方式 | 适用 | 风险 |
|---|---|---|
| 固定时间偏移(A 完成后 B 隔 10 分钟) | 依赖简单、耗时稳定 | 上游变慢就撞车 |
| 事件触发(A 完成发消息) | 链路长、耗时不定 | 消息丢失则下游永不启动 |
| DAG 编排 | 多依赖、需可视化 | 引入调度平台,编排本身要维护 |
时间偏移最便宜也最脆:它把"上游耗时"写成了常量。依赖超过两层就该用事件或 DAG,否则每次上游变慢都要人工调时间。
10. 可观测:四个必看指标
| 指标 | 采集点 | 异常信号 |
|---|---|---|
| 执行次数 vs 计划次数 | 调度侧 + 执行侧 | 执行次数大于计划 → 重复触发 |
| 单次耗时 | 任务包装器 | P99 持续上升 → 数据量或下游退化 |
| 成功率 / 失败分类 | 死信表 | 某类错误集中出现 → 上游契约变更 |
| 积压量 | 待处理表或队列深度 | 单调上升 → 消费能力不足 |
告警应该挂在积压量与成功率上,而不是"任务是否在跑"。任务在跑但一片都没成功,比不跑更危险。
11. 变更与回滚
# 任务定义带版本,新老实例并存时按版本路由
job: settle
version: 2026-11-01 # 与代码同版本发布
shardStrategy: id-range
concurrency: 4
发布期间新老实例可能同时在线,任务逻辑变更必须满足:新逻辑能处理旧数据、旧逻辑不拒绝新数据。做不到就得分两批发布(先兼容读,再切写),并把任务版本写进执行记录,便于按版本回滚。
12. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 同一天执行多次 | 多副本 + 无互斥 | 先加幂等,再评估是否需要锁 |
| 任务卡在 RUNNING | 进程被杀或异常未捕获 | 加执行超时与心跳,超时置为可重试 |
| 部分数据未处理 | 分片标记与实际不符 | 分片完成标记与数据写入同事务 |
| 重试风暴 | 固定间隔 + 大量失败 | 指数退避 + 抖动,限制重试总量 |
| 主库抖动与任务时段重合 | 批大小过大、无间隔 | 调小批次,加提交间隔,错峰 |
13. 边界
| 场景 | 结论 | 理由 |
|---|---|---|
| 单机脚本任务 | 不上调度平台 | cron 足够,平台只增加故障面 |
| 无重跑需求 | 不必做断点续跑 | 全量重来成本可接受 |
| 数据量小(分钟级内跑完) | 不必分片 | 分片复杂度高于收益 |
| 结果幂等且可覆盖 | 不必做去重表 | 重算一份结果相同 |
14. 自测
- 三个责任中,哪一个必须先于扩容解决?
@Scheduled在多副本下为什么必然重复?蓝绿发布如何加剧它?- 抢占式锁最常见的两种失效分别是什么?
- 三种幂等手段中,哪一种依赖"执行本身幂等"这个前提?
- 长任务为什么必须做断点续跑,而不只是"性能优化"?
- 判断"错过的任务要不要补"的依据是什么?