二十几个跑批任务落在同一分钟刻度上的代价,不是"慢一点",而是总工作量按平方增长。这篇拆开抖动该加在哪一层、窗口怎么算,以及加之前必须先成立的两个前提。
白天排需求、夜里跑批,是企业系统最常见的节奏。问题往往出在配置上:十几个到二十几个作业的表达式不约而同写成 0 2 * * *,于是同一分钟里一堆进程同时醒来,去抢同一批数据库连接、同一张表、同一路下游接口。监控上看起来是"整体变慢",本质上是一次由自己发起的对齐攻击。
AWS 架构博客那篇被反复引用的抖动实验用乐观并发控制做了仿真:当 N 个客户端在同一时刻尝试改同一行记录,每一轮必然只有一个成功,于是要完成全部更新需要 N 轮——完成时间随竞争数线性增长,而系统付出的总工作量按 N 的平方增长(AWS 架构博客的抖动实验)。被浪费掉的不是时间,是 CPU、连接数和 IO。
更麻烦的是它会被下游放大。同一分钟里的一批作业,通常各自还带着"失败重试三次"的配置;上游一慢,重试同时发生,第二波流量比第一波更高。所以排查这类故障时,单任务的耗时曲线看不出问题,要看的是分钟粒度的并发度曲线。
这也解释了为什么"把退避时间调长"没用。同一份仿真的结论是:带指数退避但缺随机性的版本,调用依然成簇出现——它只是把连续竞争变成了脉冲式竞争,负载并没有被摊平。
这件事最容易做错的是"层"的选择。三层解决的是不同问题,混着加不但没效果,还难排查。
| 加在哪一层 | 具体做法 | 解决什么 | 代价与前提 |
|---|---|---|---|
| 调度层 | 把 cron 的分钟位打散;或用调度平台按分片、路由把作业分到不同执行器 | 削掉同一时刻的集中启动 | 需要一张"谁占哪个刻度"的台账,新增作业必须登记 |
| 任务层 | 进程起来后先随机等一段,再进业务逻辑 | 同一模板批量生成的作业(例如多租户同步) | 对"必须在整点触发"的业务语义不适用 |
| 重试层 | 退避时间随机化 | 失败路径与下游限流的二次冲击 | 会拉长单任务尾延迟,必须配截止时间兜底 |
一个典型顺序错误:先给重试加随机化。那只会让失败路径更平滑,02:00 的启动峰值一点没动——因为改动落在了错误的层。
同一份实验把随机化方式分成三档做对比:睡眠时间在 [0, 上限] 之间均匀取样(全量抖动);保留一部分退避、只对另一部分加随机(等量抖动);以及让随机上限跟随上一次取值变化的去相关抖动。结论很直接:全量抖动在 100 个竞争客户端的场景下把调用次数砍掉了 一半以上,完成时间也明显缩短;等量抖动做的工作略多、耗时明显更长;去相关抖动的调用次数最高。也就是说,多数失败重试场景先用全量抖动就够了,不必一上来就追求"更聪明"的变体。
# 失败路径:指数退避 + 全量随机化(含上限)
import random, time
def wait_before_retry(attempt, cap_seconds=60):
ceiling = min(cap_seconds, 2 ** attempt)
time.sleep(random.uniform(0, ceiling))
启动阶段的写法更简单,难点在窗口取值——它等于"这批作业允许的调度漂移":
# 启动路径:在允许窗口内随机落点
import random, time
DRIFT_WINDOW = 300 # 业务允许的最晚启动偏移,单位秒
time.sleep(random.uniform(0, DRIFT_WINDOW))
窗口不要拍脑袋。它由三者共同收紧:上游依赖什么时候就绪、下游能承受多高的并发、报表与对账要求的出数时间。取最小值,而不是取一个"看起来安全"的整数。
以容器编排里最常见的定时作业对象为例,官方文档里与错峰窗口直接相关的字段有两个(Kubernetes 官方 CronJob 文档):
200,即允许最晚 200 秒后才被创建。有了它,错峰才是可控的——否则一次偏移可能把作业拖出业务窗口,甚至挤进白天的业务高峰。为什么这一节写得这么短:触发语义、并发三取值、以及"平台只承诺至少一次"这条前提,我们在 9 月那篇一次发布重复事故复盘里已经逐条拆过。那篇的落点是幂等键怎么写,本篇的落点是负载怎么摊平——两件事,别混。
另外,如果项目用的是 Quartz,注意它的表达式是七段而不是 Unix crontab 的五段,第一段是秒。教程里的示例 0 42 10 ? * WED 表示"周三 10:42:00",直接照抄五段写法会整体错位(Quartz 的 CronTrigger 教程)。它的 misfire 指令是另一条独立路径:错过触发点之后是跳过还是补跑,由指令常量决定,这个选择必须和错峰窗口一起定下来。
国内项目常用 XXL-JOB 这类调度平台,它由 2015 年成立的开源社区维护,定位是分布式任务调度(XXL-JOB 官网)。但要分清它解决的是什么:平台解决"何时触发、发给谁执行",进程起来后的随机等待仍写在任务代码里,两者不互相替代。
取三个约束的最小值:上游就绪时间、下游可承受并发对应的最短错峰间隔、业务要求的出数时间。如果算出来小于任务的典型执行时长,说明这个任务不该靠错峰解决,而应该拆任务或加执行器。
会,所以要把"允许晚多久"显式写进配置,而不是让它隐式发生。容器编排里用启动截止值表达(超过即跳过并按失败计),Quartz 里用 misfire 指令表达,两者都是把"迟到"从意外变成约定。
禁止并发是"上一轮没跑完就跳过这次",适合不允许叠加的批处理;替换上一次是"中断正在跑的那次、换成新的",只适合可被中断且幂等的任务——否则等于让错峰顺手制造一次半途而废的数据写入。
要。平台管的是触发与分发,进程启动之后的随机等待、失败重试的随机化都在任务代码里。反过来也一样:代码里写了随机化,仍要确认调度侧的并发策略与截止时间,否则窗口形同虚设。
蓝曜炬辉(www.lanyaoai.com)在做 ERP、WMS 这类跑批密集的定制项目时,会把错峰和幂等一起列为上线检查项。如果你手里也有一张"挤在同一刻度"的作业清单,把 cron 表达式和各任务允许的业务窗口发过来,可以一起把窗口算清楚——从联系我们开始。