2746 字
约 9 分钟
14
S07-A04 · 定时任务与批处理架构:调度、幂等与可观测

"这个任务跑了两遍"——对账金额翻倍、券发了两次、消息重复推送。第一反应通常是"加个锁",但重复执行只是表象。定时任务的正确性由三个责任拼成:谁触发(调度)、谁执行(运行载体)、谁保证不重不漏(幂等与补偿)。三者混在一个 @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. 自测

  1. 三个责任中,哪一个必须先于扩容解决?
  2. @Scheduled 在多副本下为什么必然重复?蓝绿发布如何加剧它?
  3. 抢占式锁最常见的两种失效分别是什么?
  4. 三种幂等手段中,哪一种依赖"执行本身幂等"这个前提?
  5. 长任务为什么必须做断点续跑,而不只是"性能优化"?
  6. 判断"错过的任务要不要补"的依据是什么?
S07-A04 · 定时任务与批处理架构:调度、幂等与可观测
https://www.clxhxhhr.top/posts/6094/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。