一个每天全量跑一次的批任务跑了八小时。没挂、没报错,监控显示成功。第二天业务方问"昨天的数据怎么还没出来",才发现它一直在跑,而下一轮调度十六小时后就要开始。这不是故障,是参数缺了一整套:单批大小、并发度、调度频率互相牵制。
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. 自测
- 把单批大小砍半,总时长为什么不一定变短?U 形曲线左侧由什么决定?
- 任务每 6 小时跑一次、每轮 5.5 小时,不动并发的前提下能改哪两个参数腾出余量?
- 增量同步用"上次任务结束时刻"做水位线,会永久漏掉哪一类数据?为什么它不报错?
- 八小时任务持锁 TTL 30 分钟靠续期,哪一个具体事件会导致两实例同时跑?
- "任务成功"在什么情况下不可信?用什么核对手段替代它?
- 举两个判据,说明什么样的批任务不需要批次状态表与断点续跑?