专业书籍精读 · SRE · 第 24 章
Site Reliability Engineering · Ch 24 · Google(Štěpán Davidovič)· 2016
你银行卡上那笔「每月 8 号自动扣房租」——扣两次很麻烦,一次没扣也麻烦。服务器上负责「每天几点几分自动干一件事」的那个小管家,叫 cron(定时任务)。SRE 第 24 章讲的就是:当这件事不再交给一台机器、而是交给一整个机房时,这个小管家要怎么重做。
在一台机器上,cron 是最不起眼的老工具,几十年没人操心过它。可一旦机器变成一百台,「每天凌晨两点跑一次」这句再普通不过的话,突然就保证不了了——要么那天根本没跑、而且没人发现;要么同时跑了两遍,把工资发了两次。
老办法只有两种,都不行。把闹钟交给一台指定的机器:这台机器哪天坏了,闹钟和闹钟清单一起消失——而且是静悄悄地消失,因为没响的闹钟不会报警,等你发现时可能已经过去一个周末。给每台机器都装同一个闹钟:一到点,一百台机器一起开工,同一件事被干了一百遍。
先让这群机器选一个值班的——谁也不能自封,得过半数点头。只有值班那台按点开工,其余的不开工,但必须跟着记同一本账,随时能接班。
账怎么记才是关键。值班的每次开工要记两笔:开工前先写一句「我马上要干这件事了」,发起完再写一句「我发起了,编号 X」。为什么非要多写第一笔?因为值班的可能正好倒在开工那一瞬间。接班的翻开账本,看见只有前一笔、没有后一笔,就知道这件事可能干了、也可能没干——它卡在一个说不清的窗口里。这时接班的先去干活的车间问一声「有没有一个叫 X 的活儿在跑」;问得到就好办,问不到,就只能按事先定好的规矩办。
Google 的规矩是:宁可漏跑一次,也不重跑。理由很实在——漏跑了你能看见,数据缺一块、报警响一声,补一次就行;重跑的后果(邮件发两遍、钱扣两回)常常收不回来。
代价也得说清楚:这意味着你的任务真的会偶尔不跑,所以必须专门为「这次没发生」配一个报警,否则你只是把吵闹换成了沉默。
几乎所有人写定时任务都写「凌晨 0 点」。于是整点一到,成千个任务同时向机房要机器,挤成一波洪峰,剩下 59 分钟机器闲着。所以这套 cron 允许你说一句「这一小时里你随便挑个时间」,把洪峰摊平。
定时任务一旦跨出一台机器,就不再是个小工具,而是一个要选主、要记账的服务;而它的正确性做不到两全——漏跑和重跑之间你必须先选一边,然后给没选的那一边配上报警。
想看崩溃窗口的时序、状态存哪、以及 Kubernetes 的公开对照? → 切到精读版
cron 在单机上是个几十年没人操心的小工具;搬进数据中心,它立刻变成一个有状态、必须靠分布式共识才能做对的服务。而本章最硬的一句是:它天然拿不到「恰好一次」——你只能在「可能漏跑」与「可能重跑」之间先选一边,再用幂等与报警去兜住另一边。
crontab 是那张时间表,0 2 * * * 意思是「每天 2:00 跑一次」。本章作者 Štěpán Davidovič,属《SRE》Part III「实践」中围绕有状态系统可靠性的那一串。上承 Ch23《管理关键状态》——那章讲共识本身,本章是它最小、最具体的一次落地:整个系统要靠共识维护的状态,少到只有「每个任务上一次什么时候启动过」;下启 Ch25《数据处理管线》——那章讲的正是被 cron 触发的那些周期作业。现实对应:Kubernetes CronJob、Airflow / Argo 的定时触发、云上的托管调度器,以及任何一句「每天凌晨跑一次」的对账、清理与报表任务。
单机 cron 的可靠性模型只有一句话:等于那一台机器的可靠性。它悄悄藏了两个假设——进程一直活着、本地状态不会丢。数据中心里这两条都不成立:机器会被抢占、会被重装、会整机架断电;而 crontab 本身也在那台机器上,机器没了,连「该跑什么」的清单都一起没了。
但真正致命的不是概率,是失败模式:一次成功的请求变成 500,你在监控上能看见;一次没有发生的定时任务不产生任何信号。它不报错、不写日志、不进指标——它只是没发生。这类事故的典型发现路径是「周一早上有人问:上周五的报表呢?」
于是规模一上来就必须做两件事:①把「按点开工」这件事和单台机器解绑;②把「跑没跑」记在一个死不了的地方。而只要多台机器都可能执行、状态又必须跨机器可见,你就一头撞进这章真正的问题:重复启动与漏掉启动,二者必居其一。
做法是把 cron 做成一个多副本服务:副本之间用 Paxos 选出 leader,只有 leader 会真的发起任务,其余副本不发起,但必须跟住同一份状态——因为它们随时要接班。
一个常见的偷懒替代是「每台都跑,靠抢一把分布式锁去重」。这在本章的语境里是不够的:抢到锁不等于下游认这把锁。持有者可能在拿到锁之后、真正发起之前卡住几十秒(长 GC、网络拥塞),锁早已过期被别人拿走,而它醒来后照样发起——两个「唯一发起者」就这么并存了。要让锁真正管用,必须配 fencing:每次授权带一个递增序号,下游(这里是数据中心调度器)校验并拒收旧序号。这就是 Ch23 的老结论:你以为在用锁,其实在实现共识。
这是全章的技术核心。leader 每到一个调度点,做的不是一个动作而是三个:①先把「我即将启动 X、本次调度时间 T」写进共识日志;②再向数据中心调度器发起;③发起返回后,把「已启动」写回日志。
为什么要多写第一笔?因为 leader 可以在任何一个瞬间消失。分三种情况看:崩在 ① 之前——新 leader 什么都没看见,确定没启动,按点补发即可;崩在 ③ 之后——确定已启动,跳过;崩在 ① 与 ③ 之间——不确定:请求可能压根没发出去,也可能已经被调度器收下、只是回执没写成。这个窗口通常只有几十到几百毫秒,但它不会消失。
消解办法是把「问一句」变得可行:让每次启动的实例名由(任务名,调度时间)唯一确定,比如 job-X@2026-09-25T02:00。新 leader 接班后拿这个名字去问调度器「有没有这个实例」——问到了,记为已启动;确认没有,补发一次。只有当调度器自己也答不上来(它正在重启、或请求超时)时,才落到那条兜底策略上。
这里要看透的是:cron 和调度器是两个系统,两个系统之间没有原子提交。所有「恰好一次」的幻觉,都死在这条缝里。本章的诚实之处,就是不假装这条缝能被填上,而是明码标价地选一边:Google 的这套 cron 选择宁可漏跑,不可重跑——漏跑是可观测、可补救的;重复的对外副作用往往不可撤回。
需要持久化的状态小得惊人:每个任务只要一条「上一次启动的调度时间」以及那条未决的启动意图。这就让「自带共识日志」变得非常划算——状态可以整份放内存、定期快照,一次启动只对应一次共识写,代价是1 个网络往返 + 1 次 fsync(同机房往返亚毫秒、fsync 几毫秒,合计个位数毫秒),相对于一个按分钟计的调度周期完全可以忽略。
反过来把状态外挂到 etcd / ZooKeeper / 数据库也能做,省掉自建复制的工程量,但把 cron 的可用性钉在另一个系统上——更糟的是,那个系统挂掉时,你连「这次到底跑没跑」都答不出来。
本章划了一条很清楚的界:分布式 cron 的职责止于把任务交给数据中心调度器,之后任务跑多久、跑成什么样,是调度器和任务自己的事。这不是偷懒,是防止职责蔓延——一旦 cron 开始追踪长任务的生命周期、表达「A 跑完才跑 B」,它就变成了一个工作流引擎,而那是另一个更难的问题。
落到实践就是一条很实用的经验:cron 触发的东西越短越好,最好只是「发起」这一下;真正的多步长流程交给专门的工作流引擎。
人写 crontab 有强烈的整点偏好。假设一个集群上有 2000 个周期任务、其中四成写着 0 0 * * *,那就是 800 个任务在同一秒同时向调度器要资源——调度器排队、启动延迟飙升,而接下来的 59 分钟机器闲着。所以这套 cron 在 crontab 语法上做了扩展:允许你表达「在这个范围里你随便挑一个时间」,由系统把启动点摊到整段窗口里。绝大多数「每天跑一次」的任务其实完全不在乎是 0:00 还是 0:37。
时区是第二个坑:夏令时切换那天,本地时间可能少一个小时或重复一个小时,于是「每天 2:30」这一条要么不存在、要么触发两次。工程上的通行做法是内部一律按 UTC 记账,只在展示层换算。
表 1 · 漏跑与重跑,按任务性质选边(这一刀必须自己砍,且要公开声明)
| 任务类型 | 该偏哪边 | 为什么 | 怎么兜住另一边 |
|---|---|---|---|
| 推送 / 发信 / 扣款 / 发券 | 宁可漏 | 副作用对外且不可撤回,重复一次就是真实损失 | 幂等键一路传到支付网关、邮件服务商;配「本次未启动」报警 |
| 日结 / 月结 / 监管报表 | 宁可重 | 漏一次是合规事故,重算一次只是多花机器 | 结果按(任务,周期)覆盖写,重跑天然幂等 |
| 缓存预热 / 索引重建 / 清理 | 都行 | 天然幂等,重跑只费算力 | 并发策略设成「上一次没跑完就跳过本次」 |
| 数据管线的一段 | 宁可重 | 下游按时间分区覆盖写,重跑结果收敛 | 输出路径带调度时间戳,禁止追加写 |
表 2 · 「跑没跑」这份状态存在哪
| 方案 | 一次启动的代价 | 故障面 | 适用 |
|---|---|---|---|
| 自带共识日志 | 1 个 RTT + 1 次 fsync,个位数毫秒 | 自包含,没有外部依赖 | 本章的选择:状态极小,自带最划算 |
| 外挂强一致存储 | 一次远程写 + 该存储的延迟 | 多一个必挂依赖;它挂了你连「跑没跑」都答不出 | 已经有 etcd / ZK 的团队 |
| 只抢锁、不记状态 | 最便宜 | 接班者无法回答「上一次跑了吗」 | 不要这么做 |
表 3 · 你到底该用哪一档
| 处境 | 选择 | 代价 / 前提 |
|---|---|---|
| 单机、任务可以漏 | 系统自带 cron | 机器挂了就全停;必须另配「没跑」报警 |
| 已在 Kubernetes 上 | CronJob | 官方只承诺「大约一次」;concurrencyPolicy 与 startingDeadlineSeconds 必须显式配 |
| 多步、有依赖、要重试 | 工作流引擎(Airflow / Argo / Temporal) | 多一套系统要运维,但换来可重放与可观测的中间态 |
| 跨数据中心、任务上万 | 自建分布式 cron | 共识、消解逻辑、惊群摊平全要自己扛 |
这一章的价值不在于「教你造一个 cron」,而在于它把一个人人都以为已经解决了的老问题重新撕开,并且给出了一个可以照抄的诚实答案:承认拿不到恰好一次,公开声明你选了哪一边。今天所有主流调度器给出的,都是同一个结论的不同说法。
大厂实证
too many missed start times ... or check clock skew。漏跑不是理论风险,是要写进常量里的日常。kubernetes/kubernetes · pkg/controller/cronjob/utils.go面试里怎么用:被问到「设计一个分布式定时任务系统」,评分点几乎就是本章的目录——谁发起(选主)、跑没跑记在哪(状态与共识)、崩溃窗口怎么消解(两个同步点 + 可推算的实例名)、语义选哪一边并公开声明(漏 vs 重)、惊群怎么摊平、以及边界在哪(不追踪长任务)。只要你主动说出「恰好一次做不到」,就已经赢了一半。
① cron 跨出一台机器,就变成一个有状态、需要共识的服务;单机 cron 的可靠性等于那台机器,且失败是静默的。
② 核心结构:多副本 + Paxos 选主,只有 leader 发起;follower 不发起但必须跟住状态,随时接班。
③ 核心机制:两个同步点——启动前写「即将启动」、启动后写「已启动」;崩在两者之间就是那个说不清的窗口。
④ 消解靠可推算的实例名(任务名 + 调度时间),接班者拿它去问数据中心调度器;问不出答案才落到默认策略。
⑤ 跨系统没有原子提交,所以拿不到恰好一次。Google 这套选择宁可漏跑、不可重跑——但这是取舍不是真理,对账类任务该选反过来。
⑥ 状态极小(每任务一条「上次启动时间」),所以自带共识日志最划算:一次启动 ≈ 1 RTT + 1 fsync,个位数毫秒。
⑦ 边界要守住:cron 只负责发起,不追踪长任务;需要依赖与重试就上工作流引擎。
⑧ 惊群要治:允许「窗口内随机落点」,别让所有任务挤在 0 0 * * *;时间一律按 UTC 记。
⑨ 为「没发生」配报警——选了「宁可漏」,就必须把沉默变回信号。Kubernetes 官方那句「大约一次」,是同一个结论的另一种写法。