凌晨三点,所有副本同时醒来:定时任务惊群实战解剖
你部署了 12 个副本,每个副本里都塞了一个 cron。然后某个凌晨,数据库的连接数曲线像心电图一样炸了 12 个尖峰,你的定时任务日志里出现了整整 12 份一模一样的账单推送。
这不是段子,是分布式系统里最经典、也最容易被"我加个定时任务而已"掩盖掉的事故。今天咱们把这颗雷从头拆到脚:它为什么炸、怎么炸得更隐蔽、以及怎么用分层方案把它焊死。
一、先认清本质:cron 是一个"单机语义"
cron 的设计假设从来就不是集群。它假设的是"这台机器上的某个用户,每天 3 点执行一次"。这个假设在你把应用打包进容器、然后复制 12 份的时候,被完整地、无声地撕碎了。
# 每个副本里都有这么一行
0 3 * * * /app/billing/run.sh
# 于是 03:00:00.000 这个瞬间:
# pod-1 起了,pod-2 起了,pod-3 起了 ... pod-12 起了
关键点在于:这些进程启动的时间误差可能只有几毫秒。你以为是"随机散布",实际上更像是十二个人同时听到广播,然后同时站起来。他们不只抢数据库连接,还在同一秒里抢同一批库存、同一把分布式锁、同一份下游限流配额。
惊群的本质不是"重复执行",而是"资源需求被瞬间放大 N 倍"。前者只是浪费,后者会直接把依赖方打死。
而且定时任务往往是整个系统里最重的操作——扫全表、批量导出、发几百封邮件、调几十次外部 API。它平时一天跑一次,谁都懒得管它有多重;然后在整点那一刻,12 份重量级负载同时压下来。
二、容器环境里的三重错觉
很多人把 cron 从 crontab 挪进容器后,以为问题就解决了("每个容器有自己的 crontab")。实际上你只是换了个坑。
错觉一:我用同一个镜像,所以行为一致。 确实一致,一致地错。镜像一致性解决了"配置漂移",没解决"副本数量"。12 副本就是 12 个进程,同一份配置,同一个触发时刻。
错觉二:随机化一下就没事了。 有人会说,把 cron 改成随机分钟,比如 0-59 * * * *,让它散开。问题在于:随机化只在"时间维度"打散了,而惊群的另一半压力在空间维度。你的数据库主库、你的 Redis 单点、你的第三方 API 配额,承受的总流量一天下来没变,只是被摊薄到了 60 分钟里。如果某个下游有"每分钟 N 次"的硬限制,摊薄反而让你在每个分钟都踩线。
# 你以为的解决方案
0-59 3 * * * /app/report.sh
# 问题:这只是把 12 次并发变成了 12 次分散并发,
# 而下游的日配额、配额桶尖峰、批处理窗口一点没变
错觉三:日志里有记录,出问题能查到。 容器里的 cron 输出如果没做持久化,Pod 一重启日志就跟着没了。更阴的是,容器被驱逐、节点缩容时,正在跑的 cron 进程会被直接 SIGKILL,没有优雅退出、没有清理、没有 checkpoint,下次起来又是从头再来一遍。
三、你的数据可能已经被"执行了 12 遍"
定时任务最常见的写法里藏着重复副作用:
# 每个副本都会执行的"幂等"代码(其实并不幂等)
rows = db.query("SELECT * FROM orders WHERE status = 'PENDING'")
for r in rows:
pay(r)
db.update("SET status='PAID' WHERE id=?", r.id)
问题出在 SELECT 和 UPDATE 之间那几百毫秒的窗口:12 个进程同时 SELECT,拿到同一批 5000 行,然后各自去支付。你不仅浪费了 12 倍的支付调用,还可能对着同一个第三方订单号发起 12 次扣款——对账的时候才会发现。
更糟的是,任务通常不是事务里的。它做的是"读一批、逐条处理、标记完成",天生是一个可重入的长流程。这类任务如果不做状态机和幂等键,等于把"重复执行"这件事写进了 DNA。
四、分层治理:别指望一把钥匙开所有锁
正确的做法不是找一个银弹,而是分三层,每层解决不同的问题。
第一层:用平台原生的"去重"能力兜底
能用平台解决就别自己造轮子。Kubernetes 的 CronJob 天生带这个字段,很多人却从来不填:
apiVersion: batch/v1
kind: CronJob
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid # 上一次没跑完就不启新的
startingDeadlineSeconds: 300 # 错过 5 分钟就别补跑了
successfulJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: billing
image: billing:1.4.2
concurrencyPolicy: Forbid 解决"同一个集群里重复启动",startingDeadlineSeconds 解决"节点挂了之后补跑出一串雪崩式任务"。这两个字段是免费的,但没人用。
如果跑在云上,EventBridge / Cloud Scheduler 之类的托管调度器本身就带"目标组只投递一次"的语义,用它比在容器里塞 cron 干净得多。系统级 cron 的随机化窗口(Debian/Ubuntu 的 anacron、systemd timer 的 RandomizedDelaySec=)也是同一个思路。
第二层:分布式选主(Leader Election),让只有一个副本真的跑
当你的任务必须在应用内部跑(比如依赖应用里的连接池、ORM、内存缓存),那就在应用层选主:所有副本都到点,但只有一个抢到租约的执行。
// 伪代码:租约式选主,注意 fencing token
lease, err := etcd.Grant(ctx, 30) // 30 秒租约
defer lease.Close()
key := "/locks/cron/billing"
txnResp, err := kv.Txn(ctx).
If(etcd.Compare(etcd.CreateRevision(key), "=", 0)). // 只能创建,不存在才成功
Then(etcd.OpPut(key, instanceID, etcd.WithLease(lease.ID))).
Else(etcd.OpGet(key)).
Commit()
if txnResp.Succeeded {
// 我是 leader,但注意:这个判断有过期窗口
runBillingJob()
} else {
// 不是 leader —— 直接退出,别在这儿"等一会儿再看"
return
}
这里有个绝大多数人栽过的坑:选主成功 ≠ 你一直安全。租约会在你卡住(GC pause、网络分区、节点假死)时静默过期,此时另一个副本认为自己是 leader 并开始跑,而你手上的任务还在跑。
解决办法是 fencing token:每次成功选主时,从 etcd 拿一个单调递增的版本号,带着它去操作外部资源(数据库、对象存储、下游 API)。下游拒绝比旧版本号更小的请求。
# 资源侧做 fencing:旧 leader 即使"诈尸"也动不了手
UPDATE billing_run
SET state = 'DONE'
WHERE job = 'daily'
AND fencing_token >= ? -- 传入当前 token
没有 fencing 的选主,本质上是"可能双主"的分布式锁——它给你一种安全的错觉,比没有锁更危险。
第三层:让任务本身可重入(这才是保命的)
前两层都是在减少"跑几次",但你永远无法保证只跑一次:网络抖动、进程被 kill、人工重跑、发布回滚。所以任务本身必须幂等,这是唯一能兜住所有未知情况的层。
# 幂等三件套
# 1. 业务幂等键:用业务维度而非时间维度做唯一约束
ALTER TABLE payment
ADD UNIQUE KEY uk_biz (biz_type, biz_id);
# 2. 状态机:每条记录有明确的状态流转,不依赖"跑没跑过"
-- PENDING -> PROCESSING -> DONE / FAILED
UPDATE orders SET status='PROCESSING'
WHERE id=? AND status='PENDING';
-- 影响行数为 0 = 别人抢到了,直接跳过
# 3. 分片 + 断点续跑:把大任务切小,记录进度
INSERT INTO billing_progress(job, shard_id, cursor, done)
VALUES ('daily', ?, ?, ?)
ON DUPLICATE KEY UPDATE cursor=VALUES(cursor), done=VALUES(done);
注意第 2 条那个"条件更新"——它比加锁更轻,比"先查再改"更可靠。乐观锁的成本几乎为零,却能挡掉 99% 的重复处理。
五、别忘了把这些坑也一起埋掉
- 时区:容器里默认 UTC,你的业务方要的是 Asia/Shanghai。跨时区团队协作时,"3 点跑批"这个约定会在某个人改了夏令时之后静默漂移。
- 运行身份:cron 里的进程通常以 root 跑,文件权限和目录创建位置全都不一样,出错时排查会很别扭。
- 优雅退出:收到 SIGTERM 时,任务要能"停在一个可恢复的点",而不是被腰斩。配合分片游标,重启后从上次位置继续。
- 时钟漂移:节点 NTP 不同步,"凌晨三点"在 A 机器是 03:00,在 B 机器是 02:58。两边都会跑。
- 日志落盘:任务日志必须重定向到持久化存储或日志系统,Pod 一重启就丢的日志在事故复盘时毫无价值。
六、怎么知道自己中招了
别等出事才发现。加三个观测点,比加三个副本便宜得多:
- 按分钟分桶的任务启动次数直方图。正常应该是 1,稳态下大于 1 就说明存在并发执行。
- 任务处理条数 vs 幂等跳过条数的比例。跳过比例突然飙升,说明上游在重复触发。
- 下游依赖的 429 / 超时率。定时任务往往是压垮下游的最后一根稻草。
最后
说句实在话:定时任务是分布式系统里最容易被低估的"放大器"。它平时安静得像不存在,一旦到了触发时刻,就会把你在架构设计里偷的每一点懒,全部如数奉还给你。
如果你现在正在做一件事——"给每个副本加个 cron 先跑起来"——那我建议今晚就把上面的三层检查一遍。能用平台能力去重的,就别自己写锁;能不重复执行的,就别指望它只执行一次。
毕竟,一个每天准时执行 12 次的"完美"定时任务,比一个偶尔跑不到的定时任务贵太多了。