2975 字
约 9 分钟
15
S05-A11 · 批量任务的设计:一次处理多少、多久跑一次
2024-12-31
2026-11-09

一个每天全量跑一次的批任务跑了八小时。没挂、没报错,监控显示成功。第二天业务方问"昨天的数据怎么还没出来",才发现它一直在跑,而下一轮调度十六小时后就要开始。这不是故障,是参数缺了一整套:单批大小、并发度、调度频率互相牵制。


1. 三个参数与批大小判据

单批时长三段构成,只有前两段随批大小变化:取数与处理近似线性、事务提交是线性加固定成本、连接与预热不随大小变化。所以"单批变小、批数变多"的总时长是 U 形——批太大吃内存和长事务,批太小被固定开销淹没,最优点靠测不靠算。并发度是缩短排队而非让每批变快:1 全串行,2 到 4 重叠 IO 等待收益最明显,8 以上持平甚至为负。

确定顺序反了会反复推翻:先由"数据最晚何时必须可见"倒推调度频率,频率定死后可用窗口就定了,再在窗口一半以内反推单批大小与并发度。

批大小的判据不是行数:同一张表 2000 行,宽表窄表差十倍,命中主键与全表扫差几个量级。真正要看的是单批时长与资源占用是否仍在平坦段,测法是台阶法,只动批大小,每档跑同一数据切片三次取中位数:

// 台阶法:每档 3 次,记单批时长、峰时 RSS、下游 p99
for bin in 200 500 1000 2000 5000; do
  for i in 1 2 3; do
    /usr/bin/time -v ./batch-runner --bin-size "$bin" --slice test-slice \
      2>&1 | tee "out-$bin-$i.log"
  done
done

平坦段有三个特征:单批时长与批大小近似成正比、峰时 RSS 线性上升且可预测、单批时长除以批大小近似常数。越过拐点则分别表现为斜率突然变陡(撞内存或 swap)、RSS 非线性跳变(有跨批累积的缓存)、比值开始上升(固定开销已摊薄到极致)。

三条都在平坦段的最大批大小才是候选,同时必须盯下游 p99:自己曲线好看但下游从 20ms 涨到 300ms,压力已经泄出去了。IN 列表型批量查询的经验上限通常在千级,超过就分片——SQL 解析与执行计划本身有成本;此类边界按 MySQL 8.0 核对过行为。


2. 全量还是增量:看变化分布,不看总量

全量每次重来,天然幂等自愈,代价是成本与总量成正比、与变化量无关;增量便宜,但要维护水位线、变更捕获与删除同步。判据是变化量占比加变化能否被可靠捕获,不是总量多大:

场景 首选 理由
以新增为主,几乎不删改 增量 全量是几十倍的浪费
大量更新且时间戳不可靠 全量 增量会漏,漏了还不自知
存在硬删除 全量加增量核对 单靠增量会留"幽灵数据"
总量小、一次几分钟 全量 状态维护成本超过收益

增量最贵的不是代码,是水位线:它必须来自数据本身(自增 ID 或严格单调的更新时间),不应取"上次任务结束时刻"——偏差窗口内写的数据会被永久跳过且不报错,只表现为某几个用户的记录长期不更新。全量的陷阱则是必须先算再覆盖,不要先删再插,否则窗口期内下游读到空表。

─── 差集写入:先落临时表,同事务内替换,避免中间态为空
CREATE TEMPORARY TABLE stage_t AS SELECT id, status, updated_at FROM src_orders;
BEGIN;
DELETE FROM dim_orders WHERE id NOT IN (SELECT id FROM stage_t);
INSERT INTO dim_orders (id, status, updated_at) SELECT id, status, updated_at FROM stage_t
  ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = VALUES(updated_at);
COMMIT;

3. 时间窗口与资源竞争

窗口不成立时,批任务的读请求会和在线流量抢资源,表现为高峰期接口变慢而监控上找不到慢查询——慢的是批任务。窗口核算算四项:平均耗时取近 30 次中位数(别拿最快一次当预期)、峰值耗时看月结季结补录后那几次、重跑余量至少留一次完整重跑、收尾与核对含对账与通知;四项之和小于窗口长度才算成立,否则处理顺序固定是降频率 → 缩批 → 加并发。另一个易漏点:批任务与备份、索引重建、报表导出共用低峰期,都挤在 02:00–05:00 就是把低峰做成新高峰。

资源竞争抢的从来不是 CPU,而是四样具体资源。

争用资源 批任务怎么吃掉它 在线侧的现象
数据库连接 长事务占用连接池 接口超时、连接等待
Buffer Pool 扫大表把热页挤出内存 原本快的查询变慢
IOPS 与磁盘队列 顺序大批量读 p99 抖动,无慢查询

所以限速要落在资源维度(连接数、每秒读行数、对象存储 QPS),而不是限制任务线程数:线程少了但每线程吞吐不变,压力不少一分。

─── 资源维度限速,而非线程维度
batch:
  job: order-reconcile
  throttle:
    max-db-connections: 4        ── 与在线池物理隔离,独立连接池
    max-rows-per-second: 20000   ── 读侧令牌桶,按行计数
    max-object-store-qps: 50
  concurrency:
    workers: 4                   ── 只在上述限速之下才有意义
    per-worker-timeout: 10m      ── 单批超时,防一批卡死整轮

4. 重跑、幂等与可观测性

重跑不是出错后的补救,是正常操作的一部分。八小时的任务总有些日子会失败,失败后唯一的选择就是重跑;重跑会产出重复数据,任务在设计上就不成立。

要素 要求 缺失后的表现
幂等键 每批主键集合固定,写入走 upsert 重跑后数量翻倍
批次状态表 记录批号、状态、游标、行数 判断不了从哪续,只能全量重来
断点游标 可复现的 (updated_at, id) 复合键 按时间猜边界,重复或遗漏

断点续跑的边际成本在第一版最低,之后每一版都在涨价:它要求状态外置、每批提交与进度更新同事务、失败能从任意一批开始,这些约束会渗进处理逻辑的每一处,第三版才想加通常等于重写。"执行到一半失败"的规则只有一条:每批是完整事务边界,批之间没有必须延续的内存状态;若有聚合中间值也必须持久化,否则续跑算出来的数是错的,而错误不会报错。

─── 批次状态表:续跑只查 status <> 'DONE' 的最小未完成批
CREATE TABLE batch_progress (
  job_name VARCHAR(64), run_id VARCHAR(32), batch_no INT,
  cursor_lo BIGINT, cursor_hi BIGINT,
  status VARCHAR(16),        ── PENDING / RUNNING / DONE / FAILED
  PRIMARY KEY (job_name, run_id, batch_no)
);

三种重跑在状态表里须有不同语义,否则互相覆盖进度:自动重试(同 run_id、同批)、手动重跑(新 run_id)、补数重跑(指定范围)。

批任务最缺的指标不是 QPS,是进度:没有它,你回答不了"还要多久",也判断不了要不要杀掉重跑。batch.processed_total 除以总批数给进度,batch.batch_duration_seconds 直方图看拐点,batch.failure_total 与 batch.retry_runs_total 给失败率与重跑率。剩余时间用"最近 N 批中位时长乘剩余批数"而非平均已用时长——批间密度不同,平均会被前段快批拖低。


5. 排错、失效情形与边界

现象 最可能的原因 处理
成功但数据缺一段 游标按时间范围,边界被跳过 改 (updated_at, id) 复合游标,含左不含右
重跑后数量翻倍 写入非 upsert,或批内删除范围不对 加唯一键加 upsert,删除与插入范围同源
高峰接口变慢、无慢查询 扫表把热页挤出缓存 限读行速率,或走从库加独立连接池
月结那几天必然超时 批大小按平常日估,尖峰日批更大 按峰值数据量定批,或尖峰日自动降批

"跑得慢就加并行"在三种情况下反向。下游先被打垮:并发 4 加到 16,单批 3 分钟降到 2 分半,但下游 p99 从 40ms 涨到 800ms,重试与超时出现,整轮反而更长。锁竞争吃掉收益:批之间共享热点行,并发越高行锁等待越久,平台期很早就到。长任务的锁失效:八小时任务持有 TTL 30 分钟靠续期的锁,续期因 GC 抖动漏一次,另一实例接管,两实例同时跑,同一批写两遍;这类锁要求 TTL 远大于单批时长、续期与心跳分离、释放带所有者校验。

两个容易漏的判断。"任务成功"本身不可信:循环里 catch 异常继续跑的批任务最后一定返回成功,所以必须有行数核对,且用抽样加边界而非全量 count(大表上本身就是一次重扫)。窗口余量最该先建:它把"跑得慢"变成能画趋势线的数。

调度频率与窗口余量应在配置里显式声明,而不是散落在代码常量中:

─── 调度声明:窗口按频率倒推,余量可被监控直接读取
schedule:
  job: order-reconcile
  cron: "0 30 1 * * *"         ── 01:30 起跑,避开 02:00 的备份与报表
  window-seconds: 10800        ── 03:30 前必须结束
  expected-peak-seconds: 5400  ── 峰值一轮上限,超过即告警
  max-reruns: 1                ── 只留一次完整重跑

三种情况下正式批量设计属于过度投入:数据量小(一次几十秒到几分钟,重跑成本低于维护成本,只需失败告警加手动重跑);执行时长远短于调度周期(每 5 分钟跑一次、每次 40 秒,本质是高频定时器,不需要批量语义);业务可容忍 T+1 延迟(超时不是事故,复杂度该花在补数方便上)。反过来,任务时长接近窗口、有对外承诺的新鲜度 SLA、涉及金额或对账、需要补数重跑、多个下游依赖完成通知——任一条成立就必须正式化。


6. 自测

  1. 把单批大小砍半,总时长为什么不一定变短?U 形曲线左侧由什么决定?
  2. 任务每 6 小时跑一次、每轮 5.5 小时,不动并发的前提下能改哪两个参数腾出余量?
  3. 增量同步用"上次任务结束时刻"做水位线,会永久漏掉哪一类数据?为什么它不报错?
  4. 八小时任务持锁 TTL 30 分钟靠续期,哪一个具体事件会导致两实例同时跑?
  5. "任务成功"在什么情况下不可信?用什么核对手段替代它?
  6. 举两个判据,说明什么样的批任务不需要批次状态表与断点续跑?
S05-A11 · 批量任务的设计:一次处理多少、多久跑一次
https://www.clxhxhhr.top/posts/6348/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。