专业书籍精读 · SRE · 第 25 章
Site Reliability Engineering · Ch 25 · Google · 2016
你手机里的「猜你喜欢」、月底的账单、搜索结果的排序,都不是你点开的那一刻才算出来的。半夜里有一条流水线,正把海量原始记录加工成这些成品。Google SRE 这本书的第 25 章讲的就是:这条流水线为什么老出事,以及该怎么改。
把它想成一家中央厨房:每天凌晨三点闹钟一响,全体开工,把当天的食材洗、切、炒、装盒,天亮前送出去。听着井井有条,可规模一大,麻烦就全来了。
一口锅拖住全场。活儿分给一百个厨师,九十九个十分钟做完,剩下那个分到的是一整头牛,要三小时。因为下一道工序必须等所有人都好,整条线就干等这一个——而且再招一百个厨师也没用,问题不是人手少,是活儿分得不均。
开工瞬间挤爆仓库。闹钟一响,一千个厨师同时冲向仓库,把门口堵死。不光自己进不去,隔壁餐厅也被堵在了外面。
偶尔撞车。楼上还有另一条流水线,一个每三小时开工、一个每四小时。大多数时候错开,但每隔十二小时会撞在同一刻,那一次准出事——事后去查,两条线单看都很正常。
看不出死活。远远望着后厨灯亮着、有人影在动,你根本分不清是在炒菜还是在发呆。等你知道出事了,通常是外面的客人先发现菜没来。
换个活法:流水线不再定点开工收工,而是一直开着。活儿一到就有人接,中间摆一张派工台,记着每件活儿归谁、做到哪一步。这样高峰被摊平了,仓库不再被瞬间挤爆;也终于看得出「活儿到底卡在哪一步」。
还有个细节很妙。厨师领活儿时,顺手领一块号码牌。假如他中途失联了半分钟,派工台以为他不行了,把这道菜改派给了别人——等他回过神端着菜要交,号码牌已经作废,派工台不收。这样同一道菜不会被端上桌两次。派工台自己也在好几个厂区各放一份,一个厂区停电,另一份接着派工。
代价也得说清楚:这套「一直开着 + 号码牌」比「一个闹钟加一段脚本」复杂得多;如果活儿本来就不大、每天跑一次也够用,老办法反而更省心。
数据流水线的敌人往往不是数据太多,而是「按点开工」这件事本身——它必然带来空转的谷、挤爆的峰,和看不出死活的中间态。规模大到一定程度,就该让流水线一直开着,并给每件活儿发一块号码牌。
想进到具体机制、示意图和选型对比表? → 切到精读版
你以为数据管线(pipeline)的敌人是数据量,其实是「按点开工」这件事本身。本章说:用定时器触发的周期性管线(periodic pipeline)在小规模下又简单又好用,可一旦数据变大、阶段变多、还挤在共享集群上跑,它会沿着四个可预测的方向烂掉——卡住的分片、启动瞬间的惊群、多条管线周期错拍撞出的尖峰、以及「跑到一半根本看不出死活」。Google 的答案不是把批作业调优到极致,而是换范式:把管线改造成一直在跑、且有正确性保证的任务系统。
本章作者 Dan Dennison,属《SRE》全书 Part III「实践」中围绕数据可靠性的一组。上承 Ch24《分布式周期任务调度》——那章讲怎么把 cron 本身在分布式环境下做对;本章讲的是更扎心的一层:就算 cron 做对了,「周期」这个范式本身仍然会在规模下失效。下启 Ch26《数据完整性》。现实对应:MapReduce / Hadoop / Spark 批作业、Airflow / Luigi / Argo 这类 DAG 调度器,以及 Flink / Beam / Dataflow 这类持续处理引擎。
经典的管线模式朴素得像一句话:读进来、变换、写出去(也就是 ETL)。MapReduce 之后这个模式获得了横向扩展能力,于是成了默认范式——写个批作业,配个 cron,每天凌晨跑一次。
给个具体的锚:一条给推荐系统算特征的管线,每天跑一次、输入约 10 TB 日志、串了 8 个阶段,跑在和在线服务共用的集群上。数据小的时候它无懈可击。可当规模、深度、共用集群同时上来,账单就开始还了:交付时间从「凌晨 4 点前一定好」滑成「说不准」,下游报表和模型训练全跟着漂;管线的启动峰值还会把共享存储打穿,殃及同集群里跟它毫无关系的在线服务。
不解决的代价不是「慢一点」,而是你再也无法对数据的新鲜度做出任何承诺——而承诺(SLO)正是这本书前四章的全部内容。
单阶段管线(读→变换→写)很稳。但真实业务要多步:清洗 → 关联 → 聚合 → 排序 → 打分 → 导出……于是它变成链式多阶段管线,而每两个阶段之间是一道关卡(barrier):上一阶段的所有分片全部完成,下一阶段才能开始。
这就是脆弱性的根。深度为 8,意味着 8 次「全员齐步走」;链上任何一块拖后腿,整条链就停在那儿——管线总时长等于「每阶段中最慢那一块」之和,跟平均值没关系。「盯尾部别盯平均」在这里升级成了架构层面的硬约束:深度越大,被尾部咬到的次数越多。
一个阶段的活儿被切成很多块(chunk)分给 worker,理想情况下每块差不多大。现实是数据倾斜:按用户 ID 切,某个超级大号一个人占了一整块;按国家切,美国那块是冰岛那块的上千倍。
后果就是本章说的 hanging chunk(卡住的块):七个分片十几分钟跑完,剩下那个跑了三小时;绝大多数 worker 在空转,关卡在等那一块。最常见的误判是「加机器」——倾斜是数据分布问题,不是容量问题;机器翻倍,那一块还是三小时。管用的只有三类:重新分片(让切分依据匹配数据分布,如热键加盐、大块二次切分)、冗余执行(对落后的块同时再跑一份,谁先完算谁的——这正是 MapReduce 论文里的 backup tasks),以及换范式(不再要求全员齐步走)。
周期管线的资源曲线是一个方波:定时器一响,几千个 worker 同时启动、同时去读同一批输入、同时打向同一个存储服务。瞬时峰值可以是均值的几十倍,而共享集群的容量通常是按均值规划的。
结果是一次自伤式的拒绝服务(本章称之为 thundering herd,惊群):管线把共享存储或网络打穿,先害了自己(超时、重试、雪崩),再害了同集群里跟它毫无关系的服务;而且它周期性复发——昨天刚调好的容量,今天数据涨 20% 就又炸一次。基本对策是给启动加抖动(jitter)与限速爬坡,别让所有 worker 在同一秒进场;但要认清这只是削平峰顶,方波本身还在。
更阴的是这个。共享集群上不止你一条管线。管线 A 每 3 小时跑一次,管线 B 每 4 小时跑一次,各自都在容量之内、各自都健康。但每 12 小时它们会撞在同一刻——那一刻的叠加负载谁也没规划过。
本章把这种模式叫摩尔纹负载(moiré load pattern),取自两张网格叠放时浮出的第三种纹路:平时看不见,撞上就爆,而且周期长、难复现、难归因——你翻日志会看到「上周二 03:00 那次莫名其妙的超时」,两周后又来一次,中间一切正常。它真正可怕的地方在于不是任何一条管线的错:每条单看都完全健康。这是典型的「只有站在集群层面才能看见」的故障,也是把管线丢进共享集群的隐性代价。
周期管线跑到一半时你手里有什么信号?进程活着、CPU 在动——这些统统无法区分「正在干活」和「卡在某个 hanging chunk 上」。等你发现不对,往往是下游先发现数据没来。
本章的判断是:周期范式天生难监控,因为每次运行都是一次性的,没有「正常速率」这个参照系。想做好只能自己造指标:每阶段完成率随时间的斜率、本次运行相对历史同期的偏差,以及最关键的数据新鲜度(data freshness,产出落后当前时刻多久)——这才是用户真正在乎、也真正该拿来定 SLO 的那个量。
Google 的解法是一套叫 Workflow 的内部系统(未开源),核心一句话:不再按点开工。管线常驻运行,活儿到了就有 worker 接。本章用 MVC 类比它的结构:
阶段化执行依然存在(每阶段的产出是下阶段的输入),但它变成了 Task Master 里的一条条任务记录,而不再是一次批作业的起止边界。峰谷因此被抹平,延迟从「等到下一次开工」变成「几乎连续」。
常驻运行带来一个绕不开的问题:worker 只是「看起来死了」怎么办?它可能只是网络卡了 30 秒。如果 Task Master 已把它的活儿改派给别人,而它随后复活并提交结果,同一份产出就会被写两次——这就是脑裂(split-brain)。
Workflow 的做法是协作锁 + 唯一 token:每块活儿派发时带一个唯一序号(token);worker 提交时必须出示它,Task Master 校验它是否仍是当前有效的那个——被改派掉的僵尸 worker 拿的是过期 token,提交直接拒收。所有产出必须经 Task Master 提交,没有旁路可走。
这套东西的精神和数据库的乐观锁一模一样:不必阻止故障发生,只需保证过期的写入进不来。配合「每阶段产出在 Task Master 里有唯一归属」,管线就能在任意 worker 崩溃、重启、假死时仍然产出恰好一份正确结果。
Task Master 成了唯一真相,也就成了唯一的单点。本章的收尾正是这个:用分布式共识把 Task Master 自己复制到多个集群,选一个为主、其余为备,某个集群整体故障时自动切换。书里给的实操建议很朴素——至少在两个集群各放一份,再加第三方做仲裁(避免只有两副本时的平票)。这也正是本书 Ch23《管理关键状态》的直接应用:关键状态请交给现成的共识实现,别自己用心跳加超时凑一个。
表 1 · 周期批处理 vs 持续处理:换范式换掉了什么
| 周期管线(定时器 + 批作业) | 持续管线(Workflow / 流式) | |
|---|---|---|
| 产出延迟 | 一个周期起步(小时 ~ 天级) | 接近连续(秒 ~ 分钟级) |
| 资源曲线 | 方波:谷里空转、峰上惊群 | 平直,利用率高 |
| 容量规划 | 必须按峰值买,其余时间闲置 | 按均值买即可 |
| 可监控性 | 差:一次性运行,没有稳态基线可比 | 好:有连续的速率与积压(backlog)指标 |
| 正确性成本 | 低:出错整批重跑就行 | 高:要 token / 幂等 / 关卡一整套机制 |
| 运维复杂度 | 低:一个定时器 + 一个作业 | 高:常驻服务、状态、选主全都要养 |
| 适用 | 数据量稳、深度浅、每天一次够用 | 数据大、深度深、对新鲜度有 SLO |
表 2 · 周期管线的四种病:症状 → 根因 → 该做什么 / 别做什么
| 病 | 症状 | 根因 | 该做什么 | 别做什么 |
|---|---|---|---|---|
| Hanging chunk | 绝大多数 worker 空转,关卡卡在一块上 | 数据倾斜,切分依据与分布不匹配 | 重新分片(热键加盐、二次切分);对落后块冗余执行 | 加机器——倾斜不是容量问题 |
| 惊群 | 启动瞬间共享存储 / 网络超时,殃及邻居 | 方波负载,峰值远高于均值 | 启动抖动 + 限速爬坡;规模够大就换持续范式 | 只调大超时和重试——会放大雪崩 |
| 摩尔纹 | 罕见、周期性、无法复现的巨峰 | 多条管线的周期偶尔对齐 | 在集群层面看总负载;错开周期或做全局准入 | 逐条管线去查——单看每条都健康 |
| 监控盲区 | 「还在跑」,其实早已卡死 | 一次性运行,没有稳态基线 | 以数据新鲜度与阶段完成速率做 SLI | 拿「进程存活 / CPU 忙」当健康信号 |
表 3 · Task Master(权威状态)该部署成什么形态
| 形态 | 能挡住什么 / 挡不住什么 | 适用 |
|---|---|---|
| 单集群单实例 | 最简单;进程或集群一挂,管线全停 | 实验性 / 可随时重跑的低价值管线 |
| 单集群 + 热备 | 挡住进程崩溃,挡不住整集群故障 | 一般业务管线 |
| ≥2 集群 + 仲裁(共识选主) | 抗整集群故障;代价是跨区延迟、更复杂的运维与更长的选主时间 | 对连续性有硬要求的核心管线 |
这一章值得单独读,是因为它把「批处理管线为什么难养」讲成了可预测的四种失效模式,而不是玄学。今天你用 Airflow / Luigi / Argo / Dagster 编排 DAG,本质仍是周期范式,这四种病一个不落——最经典的 Airflow 事故就是几百个 DAG 被排到同一分钟触发、把元数据库和执行器一起打爆,教科书级的惊群。而 Flink / Beam / Google Dataflow / Kafka Streams 是持续范式的开源化身,Beam 的模型直接来自 Google 的 MillWheel 与 Dataflow 两篇论文。
更值得记住的是机制的同构:Workflow 的「Task Master + 唯一 token」,与 Flink 的「JobManager + checkpoint barrier」、Kafka 事务生产者的「producer epoch 挡住僵尸生产者」是同一种思想——把权威状态收拢到一处,再用一个会过期的凭据把掉队者挡在门外。
① 管线的敌人不是数据量,是「按点开工」这个范式本身;规模一大,它的失效方式是可预测的。
② 结构上的根:真实管线是链式多阶段,阶段之间是「全员齐步走」的关卡;总时长 = 每阶段最慢那块之和,跟平均值无关。
③ Hanging chunk:倾斜导致某一块拖住全场、其余 worker 空转;加机器治不好,要重新分片或对落后块冗余执行(backup task)。
④ 惊群:定时器一响几千 worker 同时进场,峰值远高于均值,打穿共享存储还殃及邻居;抖动与限速只能削峰,方波仍在。
⑤ 摩尔纹负载:两条各自健康的管线周期偶尔对齐(3 小时与 4 小时每 12 小时撞一次),撞出没人规划过的巨峰;难复现、难归因,且不是任何单条管线的错。
⑥ 监控盲区:一次性运行没有稳态基线,「进程活着」区分不了干活与卡死;该测数据新鲜度与阶段完成速率。
⑦ Google Workflow 换范式:常驻运行,Task Master 持有唯一权威状态(Model)、workers 无状态(Controller)、配置是 View。
⑧ 正确性靠协作锁 + 唯一 token:产出必经 Task Master 提交并校验 token,僵尸 worker 的过期提交被拒——同乐观锁,也同 Kafka 的 producer epoch。
⑨ 权威状态本身是单点,用分布式共识跨集群选主保连续性;书里的建议是至少两集群 + 一个仲裁方。落地映射:Airflow / Argo / Dagster 属周期范式(四种病照得),Flink / Beam / Dataflow 属持续范式。