专业书籍精读 · SRE · 第 22 章
Site Reliability Engineering · Ch 22 · Mike Ulrich · Google · 2016
你一定见过这种新闻:某个 App「崩了」上了热搜,几个小时都回不来。奇怪的是事后公布的原因往往小得离谱——改了一行配置、多加了几台机器、开了一个没什么人用的新功能。SRE 书第 22 章讲的就是这件事:一个小故障,是怎么把整个系统一层一层拖下水的。
想想大停电。用电高峰,一条高压线路过载跳闸——它原本输送的电流不会凭空消失,会自动转到旁边几条线上。旁边那几条本来也接近满载,多接一份就更容易跳;跳了之后电流再往下一批转……几分钟内,一座城市黑掉。
最反直觉的是:把最初跳闸的那条线修好,城市也不会自己亮起来。此刻全城的空调、冰箱、电梯都停着,你一合闸它们同时启动,冲击比平时大得多,会立刻再把线路顶掉。电力行业为此有一套专门流程,叫黑启动:先点亮一小片,稳住,再点下一片。
软件系统的塌方是同一个剧本。一台服务器挂了,它的活自动分给剩下的同伴;同伴本来就忙,多接一份就更慢;一慢,用户开始反复刷新,请求量不降反增;自动巡检发现有几台「不吭声」,判定它们坏了,杀掉重启——于是活着的机器更少、每台更忙。
看出来了吗:每一步的结果,都成了下一步的原因。故障不是被外力推着走的,它自己会长。这是这一章唯一真正要你记住的东西。
三台小发动机在推:
平时:给每件活标上「最晚到什么时候还有意义」,过了就扔;别让队排太长;把要紧的和不要紧的分开,忙时先砍不要紧的。出事时:先别急着加机器(新机器要预热,很可能更糟),最有效的一招常常也是最难下手的那招——把流量整个掐掉,让系统喘匀,再一小份一小份放回来。跟给城市送电一模一样。
代价也很诚实:掐流量意味着你要主动制造一段「谁都用不了」的时间,很多团队到了那一刻不敢按下这个按钮,结果把几十分钟拖成了几十小时。
故障本身不可怕,可怕的是失败会繁殖。一旦这个循环转起来,修好最初的原因也没用——你必须亲手把它掐停,然后像给城市送电那样,一片一片地把系统重新点亮。
想进到具体机制、数字和示意图? → 切到精读版
级联失效(cascading failure)不是「一次大故障」,而是一连串小故障,每一次都让下一次更容易发生。书里的定义只有一句——由正反馈驱动、随时间不断长大的失效——但推论极其反直觉:回路一旦转起来,把最初的触发原因去掉,系统并不会自己回来。因为此刻的负载已经不由外部流量决定,而由系统自身的重试、排队、冷缓存和健康检查决定。于是本章教的是两件事:设计期怎么把回路的每一段掐断(队列、截止时间、依赖方向、启动路径),以及回路已经转起来时,运维手上那几个动作分别在打断哪一段。
本章出自 SRE 书 Part III「实践」,作者 Mike Ulrich。它是第 21 章「处理过载」的下半场:上一章讲单个服务怎么自保(脱载、降级、配额、客户端节流),本章讲自保失败之后,失效怎么在服务之间传播,以及整片系统塌了之后怎么救。往后接第 23 章「管理关键状态」——那一章讲的共识系统,恰恰是本章最忌讳的那种「所有人都依赖它」的枢纽。对应现实里的每一次「某某崩了」上热搜:AWS Kinesis 2020、Roblox 2021,以及各家因为一次例行发布或一次扩容而引发的雪崩。
先说一句反直觉的:级联失效几乎从不由「坏事」触发。触发它的通常是一件例行公事——一次发布、一次扩容、一次机房排空、一次跨过了某条看不见的线的自然增长。
算一笔账就懂了。5 个任务各跑在 80% CPU 上,看着还有 20% 余量。现在挂掉 1 个(一次滚动更新、一台机器坏了,什么都行),它的负载被均分到剩下 4 个身上,每个变成 80% × 5 / 4 = 100%——只死了 1 个,剩下的就全部顶在天花板上。接着延迟涨、客户端超时重试、请求量再涨一截;健康检查收不到及时回应,集群管理器判定又有 2 个「坏了」,杀掉重建;剩下 2 个要扛 80% × 5 / 2 = 200%。
关键性质出现了:就算把最初挂掉的那个修好放回来,它一上线就会被 200% 的负载瞬间打死。系统不会自己走回左边那张图——推着它的已经不是用户流量,而是重试洪水、积压队列和一堆空缓存。
80% 的利用率不是「还有两成余量」,而是「只经得起死一个」。这两条性质决定了它必须单独成章:一是非线性——利用率从 50% 走到 80% 是线性变差,越过某条线之后是断崖;二是自持——撤掉触发条件不足以恢复,你必须主动打断回路。本章要解决的就是:怎么让回路根本转不起来,以及转起来之后怎么掐停它。
本章的定义很短:由正反馈驱动、随时间不断长大的失效。最值钱的是「正反馈」三个字,它给了你一个可以随身带走的判据——审设计时问一句:
会,就有回路。上面那笔账里至少藏了三条:失败 → 负载转移 → 更多失败;变慢 → 重试 → 更多负载 → 更慢;进程被杀 → 重启后缓存空 → 后端请求量暴增 → 后端更慢。普通故障是被外力推着走的,级联失效是自己在长。这也解释了它为什么总在深夜一次例行发布之后爆发,而不是双十一零点——流量高峰你准备好了,例行发布你没有。
服务器被推倒的直接原因通常是某种资源见底。本章列了四条主路,洞见在于它们不是四条平行的路,而是一张互相点火的网:
所以「哪个资源先见底」往往不重要,重要的是任何一个见底都会把其余几个一起拖下水。这也是级联失效的现场总是一团乱麻的原因:OOM、超时、健康检查失败同时发生,很难说谁是因谁是果。
大多数「一请求一线程」的服务器,都在线程池前面挂一个队列。先破除一个直觉:如果到达率和处理时间都稳定,队列根本不该有东西。队列只在到达率超过处理能力时才增长——而那恰恰是你最不希望它增长的时刻。
队列的三笔代价都很实:占内存(每个排队请求都带着自己的上下文)、直接推高延迟(排队时间是白等的),以及最阴的一笔——制造僵尸请求:队首那些等了 8 秒的请求,用户早走了,你却还要老老实实把它算完。
本章给的方向是把队列做小、尽早拒:队列短,服务器过载时会更早开始拒绝,快速失败反而更健康;队列长虽能吸收突发,代价是延迟被推高、僵尸请求成堆。极端一点的服务甚至几乎不排队,宁可直接失败让上游换一个实例。
具体怎么做,业界最经典的答案来自 Facebook:用 CoDel(controlled delay)的变体给排队时间设上限——如果队列在最近 N 毫秒里一直没被清空过(说明存在一条常驻的队伍),就把允许排队的时间压到很小的 M 毫秒;只要最近清空过,就允许排久一点。人话:允许短暂的突发排队,但绝不允许队伍长期站着不走。再叠上 adaptive LIFO——平时 FIFO 公平服务,队列一开始堆积就切成 LIFO。(出处见下面「大厂实证」。)
队列的病根,其实是「没人知道这个请求什么时候就没意义了」。本章的解药是把截止时间当一等公民,三条动作:
这里还藏着一个必须点名的陷阱:双峰延迟(bimodal latency)——平均值会骗你。算笔账:一个前端有 10 台 × 100 线程 = 1000 个并发槽,正常每请求 100 ms,轻松扛住 1000 QPS。现在后端挂了四分之一,打到坏实例上的请求要死等 10 s 超时。落在这条慢路上的只有 10%、也就是 100 QPS——但每个都占着线程 10 秒,100 × 10 = 1000,正好把全部线程占满。10% 的流量吃掉了 100% 的并发能力。而此刻平均延迟是 0.9 × 0.1 + 0.1 × 10 ≈ 1.1 s,仪表盘上只显示「有点慢」。所以看百分位,别看均值——这是第 1 章那把尺子在这里的回声。
服务器刚起来的那几分钟和稳态是两种生物:连接还没建立、JIT 还没热、类还没加载完,最要命的是缓存是空的。稳态下缓存命中率 90%,10,000 QPS 进来只有 1,000 QPS 落到后端,后端正是按这个量规划的容量。缓存一空,命中率变 0,后端要面对完整的 10,000 QPS——整整 10 倍。
这条推论把两个最本能的救火动作变成了危险动作:重启会丢掉缓存;扩容拉起的新实例也是冷的,还得先去后端把自己填满——偏偏后端此刻已经在喘。对策是过量配置、主动预热、以及慢慢放量:一次放一小批,热了再放下一批。AWS 恢复 Kinesis 时每小时只加几百台服务器,正是这个道理。
本章有一条近乎纪律的建议:调用永远向下走——避免同层互调,更要避免依赖成环。跨机房的前端互相代理请求、A 调 B 而 B 又回头调 A,这类结构平时看不出问题,一旦某一侧变慢就是一个完美的正反馈环。
最容易被忽略的一种环是监控依赖被监控的对象:出事时你用来定位问题的遥测、配置、服务发现,若本身跑在正着火的那套基础设施上,你就会在最需要看清的时刻失明。Roblox 2021 年那次 73 小时故障就是活标本——排障用的可观测系统依赖 Consul,而 Consul 正是出事的那一个。
本章列出的常见触发条件读起来有点不适:它们几乎全是好事。进程更新与新版本发布、有计划的变更(排空、下线、维护)、自然增长跨过某条线、请求结构变化(同样 QPS 但更贵)、资源限额调整、机器故障。所以别指望「不做坏事」能躲开级联失效——要在设计上让它塌不下去。
测试方法最重要的只有一句:压到失败为止,然后继续往上压。只测到目标容量等于没测——你要看的是过载之后的行为,尤其是负载降回正常时它能不能自己恢复(很多服务不能,那正是级联失效的定义)。此外要测大客户(它们的重试行为常常和别人不一样),也要测非关键后端——把它干掉,确认关键路径真的不受影响,而不是「我以为不受影响」。
表 1 · 队列策略:短队列不是保守,是防线
| 策略 | 过载时的行为 | 代价 | 适用 |
|---|---|---|---|
| 几乎不排队 | 线程满就立刻拒绝,上游换一个实例重试 | 对突发毫无缓冲,正常抖动也会产生错误 | 有健康的上游重试与多实例可选的在线服务 |
| 小队列 + 早拒 | 吸收秒级突发,超过就快速失败 | 需要上游能正确处理拒绝,否则错误直接见用户 | 大多数在线服务的默认选择 |
| 大队列 | 吞下一切,延迟一路涨 | 最危险:占内存、制造大批僵尸请求、把故障时间拉得极长 | 离线 / 批处理,且必须配积压上限与旁路 |
| LIFO + 排队超时 (CoDel 式) | 正常 FIFO;一旦出现常驻队列就切 LIFO,并把排队时间压到毫秒级 | 老请求可能永远排不到;参数要按服务实测 | 请求之间可乱序、且 goodput 比公平更重要时 |
表 2 · 四类资源耗尽:症状、点火路径、防线
| 资源 | 典型症状 | 它会点着谁 | 防线 |
|---|---|---|---|
| CPU | 全部请求一起变慢,队列变长 | 错过截止时间 → 重试 → 更多 CPU | 脱载、按 CPU 定容量(上一章)、缩短队列 |
| 内存 | 容器被 OOM 杀掉;GC 频率飙升 | 经 GC 变成 CPU 压力;缓存命中率下降 → 打爆后端 | 限制在途请求数与队列长度,而不是只调堆大小 |
| 线程 | 新请求直接报错;健康检查没人应答 | 被集群管理器判死 → 自动砍掉自己的容量 | 给健康检查留独立资源;服务健康检查与进程健康检查分开 |
| 文件描述符 | 连接建不起来 | 同样表现为健康检查失败,与线程耗尽难以区分 | 连接复用;海量客户端经代理汇聚;把上限监控起来 |
表 3 · 现场七招:这一招在打断回路的哪一段
| 动作 | 见效速度 | 风险 | 什么时候用 |
|---|---|---|---|
| 加资源 / 扩容 | 慢(分钟级) | 新实例是冷的,可能反而给后端加压 | 确认瓶颈是纯容量、且缓存不敏感时 |
| 停掉健康检查 导致的自杀 | 快 | 同时失去自动清除真坏实例的能力,属于最后手段 | 确认实例是「忙但活着」被误杀时 |
| 重启服务 | 快 | 丢缓存;若病根未除,起来就再被打死 | GC 死亡螺旋、死锁、大量无截止时间的在途请求 |
| 掐流量(大红按钮) | 最快,也最彻底 | 主动制造一段全量不可用;恢复时必须慢慢放 | 回路已自持、其它招都无效时的标准解 |
| 进入降级模式 | 快 | 降级路径平时不跑,很可能早就坏了 | 存在天然「便宜版答案」的读路径 |
| 停掉批处理负载 | 快 | 几乎没有——这通常是最划算的一招 | 过载中混有可延后的离线任务时 |
| 清掉坏流量 | 中 | 要先定位,误伤会波及正常用户 | 存在死亡查询、异常客户端或攻击流量时 |
表 4 · 两种健康检查混用,等于让自动化替你砍容量
| 类型 | 问的是什么 | 谁在消费它 | 混用会怎样 |
|---|---|---|---|
| 进程健康检查 | 进程还活着吗 | 集群管理器(Borg / Kubernetes)——不健康就杀掉重建 | 把「忙但活着」当成「死了」,在最缺容量的时刻主动减容量 |
| 服务健康检查 | 还能好好干活吗 | 负载均衡器——不健康就把流量挪走 | 若全部实例同时判不健康,流量无处可去,等于自我 DDoS |
这一章十年后仍是每一份严肃复盘的骨架,因为它把「雪崩」从形容词变成了四段可执行的工程:切断回路(截止时间、取消传播、队列上限)、别让重试相乘(退避、抖动、重试预算)、把启动路径当一等公民(冷缓存、预热、慢放量)、依赖只向下(不成环、监控不依赖被监控者)。面试被问「服务雪崩怎么防」,这四段就是好答案的骨架,而不是「加机器 + 加重试」。
3 小时 19 分至 4 小时 25 分,连 Google 自家的 G Suite 与 YouTube 一并受影响。触发条件是一次例行的维护自动化——正好落在本章列的「有计划的变更」这一格里。Google Cloud Status Dashboard, Incident 19009, 2019 ↗p99、看并发槽占用。① 定义只有一句:由正反馈驱动、随时间不断长大的失效。判据是问「这次失败的结果,会不会变成下次失败的原因」。
② 最反直觉的推论:撤掉触发原因不足以恢复。回路自持后推动系统的是重试、积压和冷缓存,不再是用户流量。
③ 利用率 80% 不等于「还有两成余量」:5 个任务各 80%,死 1 个剩下的就顶到 100%,再死 2 个要扛 200%——越过线就是断崖。
④ 资源耗尽的四条路(CPU / 内存 / 线程 / 文件描述符)互相点火:内存压力经 GC 变成 CPU 压力,线程耗尽经健康检查变成被自动化杀掉。
⑤ 队列不是缓冲区,是延迟放大器:稳态下它本该是空的。做小队列、尽早拒;过载时 FIFO 优先服务的恰是最没价值的僵尸请求。
⑥ 截止时间是最便宜的解药:出队时先检查是否过期;传播绝对截止时间而不是每跳各设超时(四层各 1 秒 = 4 秒预算);取消也要往下传。
⑦ 平均延迟骗人:10% 的请求慢到 10 秒就能占满全部 1000 个线程,而均值只有约 1.1 秒。看百分位。
⑧ 冷缓存是重启与扩容的隐藏代价:命中率 90% 意味着空缓存时后端要挨 10 倍冲击。对策是过量配置、预热、慢慢放量。
⑨ 依赖只向下、别成环;尤其别让监控与服务发现依赖正在着火的那套东西——你会在最需要看清时失明。
⑩ 触发条件几乎全是「好事」(发布、扩容、排空、自然增长),所以要压到失败之后继续压,并验证负载回落后能否自愈;现场七招里,掐流量再慢慢放是回路自持时的标准解。