专业书籍精读 · SRE · 第 21 章
Site Reliability Engineering · Ch 21 · Alejandro Forero Cuervo · Google · 2016
你抢过演唱会门票、挂过专家号、双十一零点下过单——那一瞬间服务器上发生的事,就是这一章的主题:来的人比能招待的多。Google 的 SRE 书第 21 章不讲怎么把系统做得更快,它问一个更现实的问题:当它已经忙不过来了,该怎么办?
把服务想成一家只有 20 张桌子的餐厅,今天门口来了 200 人。最糟的做法是全放进来:所有人挤在店里,服务员疲于奔命,点单乱成一团,两小时后一桌菜都没上齐——200 个人全都没吃上饭。餐厅并没有"接待了 200 人",它只是把原本能好好服务的那 20 桌也搞砸了。
系统的本能就是来者不拒。请求进来先排队,队越排越长,每个人等得越久;等久了,客户端以为失败了,又按了一次刷新——同一批人于是变成两批、三批的请求砸回来。压力开始自己喂自己,几十秒内就能从"有点慢"滑到"完全没响应"。
真正可怕的不是慢,是有效产出掉到零:机器满负荷转着,算力全花在那些早就没人等的结果上,没有一个用户拿到东西。
四招,本质上都是体面地少干点活:
这套东西把"拒绝"变成了一种正经能力,而不是失败的标志。承认容量有限、主动少接一点,系统反而能在流量暴涨时把产出稳稳停在自己的上限上,而不是在某个点整体塌下去。代价也很诚实:拒绝本身也要花人手(门口挂牌那个人也得站着),而"简餐菜单"平时几乎不用,真到那天很可能已经端不出来了。
过载时最贵的错误,是什么都想接下来。与其硬撑到全盘崩掉、谁都没拿到结果,不如早一点、有选择地对一部分人说不——好保住剩下那部分人是真的拿到了东西。
想进到具体机制、公式和示意图? → 切到精读版
过载的关键从来不是"扛不扛得住",而是扛不住的那一刻会发生什么。本章的主张有点反直觉:一个健康的服务必须有能力主动拒绝一部分请求——因为无差别地全盘接收,结局不是"每个人都慢一点",而是有效吞吐(goodput)塌到接近零。围绕这一点,它给出四个可分别落地的动作:容量别用 QPS 度量、用资源度量;服务端要能降级和脱载;请求要预先分重要性;客户端要自适应节流,并给重试上三道闸。
本章出自 SRE 书 Part III「实践」,作者 Alejandro Forero Cuervo。它紧接前面两章负载均衡——那两章讲怎么把负载摊匀以避免过载,本章讲摊匀之后仍然过载了怎么办;下启第 22 章「应对级联失效」:本章是单个服务的自保,下一章是自保失败之后整片系统的塌方。对应现实里的每一次秒杀、每一次热点事件、每一次下游变慢引发的雪崩。
先说一句反直觉的:服务真正的危险区间不在"慢",而在慢之后那几十秒。
看看不设防时会发生什么。一个额定约 10k QPS 的服务被推到 15k:请求先排队,队一长,响应时间从 50 ms 涨到几秒;在途请求堆在内存里,GC 压力上升、又反过来吃掉 CPU;客户端设了 1 秒超时,等不到就重试——原本 15k 的真实需求,变成 30k、45k 的到达率。而重试打进来时,队列里那些早已超时、没人再等的请求还在被认真处理。到这一步,CPU 打满,算力全花在没人要的结果上,goodput 掉到接近 0。
这不是"性能不够",是结构性崩塌:负载开始自己喂自己。所以本章要解决的是——当负载超过容量,怎么让产出停在容量线上,而不是掉下悬崖。它把这件事拆成两半:服务端要能"主动少干",客户端要肯"主动少发"。两边缺一不可,因为被拒的请求如果立刻原样打回来,服务端还是得花 CPU 去拒绝它。
最常见的容量说法是"这个服务能扛 X QPS"。本章直接说这是个坏尺子:不同请求的成本可能差出一两个数量级——一次按主键查 3 条记录的读,和一次要扫 500 个分片的聚合,都算"1 个 query",吃掉的算力完全不是一回事;而且流量组成会随功能上线、客户结构变化而漂移,昨天量出来的上限今天就不作数了。
Google 的做法是直接用资源消耗定容量,绝大多数场景下就盯 CPU。两个理由:一是在带垃圾回收(GC)的运行时里,内存压力会自然翻译成 CPU 消耗(内存紧张 → GC 更频繁 → 吃掉更多 CPU),盯住 CPU 就顺带盯住了内存;二是其余资源可以按比例配足,让它们几乎不可能先于 CPU 耗尽。CPU 还有个关键性质——它是可压缩资源,不够时请求只是变慢,不会像内存那样一到上限就把进程打死,这就给脱载机制留出了反应的时间窗口。
具体信号用的是执行器负载均值(executor load average):进程里活跃线程数的指数衰减平均值,与可用 CPU 数一比,就得到一个连续的"我现在有多满"的读数。
过载时服务端有两条路,性质完全不同。优雅降级是把答案做便宜:搜索只查内存里的一小部分索引而不是完整的磁盘索引、排序换成更简单的算法、个性化推荐退回静态热榜。请求仍然被服务了,只是质量降了一档。负载脱落则是干脆不服务:直接返回错误(HTTP 里通常是 503),把资源留给还能好好服务的那部分请求。
书里对降级给了一条相当克制的警告,值得单独记住:降级路径平时几乎不被执行,所以它极可能已经是坏的。真到过载那天你切过去,才发现那段代码早就跟主路径脱了节、甚至根本跑不通。对策是:模式尽量少、逻辑尽量简单,并且定期真的走一遍。
一个共享服务的容量要分给很多客户,所以先有每客户配额——按资源速率(比如每秒能用多少 CPU 秒)而不是按 QPS 来分。配额之和通常故意超过总容量,赌的是大家不会同时用满;一旦真的同时用满,超配额的客户先被拒。
但这里有个绕不过去的问题:拒绝不是免费的。一个疯狂重试的客户,能把服务端的 CPU 全花在"生成拒绝响应"上。所以本章最漂亮的一招落在客户端:自适应节流(adaptive throttling)。每个客户端任务自己记两个数(最近 2 分钟的滑动窗口):requests(应用层发起了多少请求)与 accepts(后端真正接受了多少)。正常时两者贴得很近;后端一开始拒绝,accepts 就落在后面。客户端据此在本地按概率丢掉一部分请求,压根不发出去:
白话讲:发出去的比被接受的多出太多时,就按超出的比例先自己拒掉一部分。K 通常取 2,意思是"允许我发的量是后端接受量的两倍"——K 越小越激进、越早开始自我压制,K 越大越宽松;分母加 1 只是为了避免除零。
两个设计细节才是精髓。一是这个概率永远到不了 1,客户端始终会漏一点请求出去——这是它探测"后端恢复没有"的唯一途径,accepts 一回升,节流就自动松开,不需要任何人去手工解除。二是被本地拒掉的请求照样计入 requests,所以客户端不会自欺欺人地以为压力已经消失。
脱载要丢一部分请求,那丢谁?本章的答案是:别在过载那一刻现想,提前给请求分级。Google 的 RPC 系统里有四个等级,从高到低:
CRITICAL_PLUS:出问题会造成严重的、用户可见的影响。CRITICAL:生产任务发出请求的默认级别,会有用户可见影响但没那么严重。服务的容量规划要能扛住这两级的总量。SHEDDABLE_PLUS:可以接受部分不可用;批处理任务的默认级别,因为它们几分钟、几小时后重试都行。SHEDDABLE:可以接受经常性的部分不可用、偶尔完全不可用。两个机制让它真正好用。第一,重要性沿调用链自动传播:一个服务收到 CRITICAL 的请求,它为完成这个请求而发出的下游请求默认也是 CRITICAL——不需要每一层各自去猜。第二,配额按重要性分别记账:同一个客户在 CRITICAL 上的配额和在 SHEDDABLE 上的配额是两本账,避免了"用低价值流量把高价值的额度吃光"。脱载时,任务永远从最低重要性往上砍。
过载时最危险的正反馈来自重试。本章讲了一个必须内化的算术:每层各重试 3 次、三层调用链叠起来,最底层收到的请求量就是 3³ = 27 倍——而它本来就是因为撑不住才失败的。你以为重试在提高成功率,实际上是在给一台已经着火的机器浇油。
三道闸把它压回去:
还有一条判断规则:过载是局部的还是普遍的,决定了该不该换个地方重试。如果只有少数任务过载,立刻换一个健康任务重试是对的;如果整个数据中心大面积过载,重试只是把问题搬个位置,正确做法是让错误一路上抛给调用方,让它去决定降级还是放弃。
最后一个容易漏掉的点:光是维持连接就要花钱。健康检查、保活、连接的建立与拆除,在客户端数量极大时可能压过真正的请求处理。典型场景是一个批处理作业起了成千上万个 worker,每个都去连同一个后端——请求量并不大,连接开销却先把后端拖垮了。对策是让这类海量客户端经由一小组代理任务汇聚连接,把面向后端的连接数从"worker 数"降到"代理数"。
表 1 · 用什么信号判断「我过载了」
| 信号 | 优点 | 代价 / 失效场景 | 谁在用 |
|---|---|---|---|
| QPS / 请求数 | 直观、易采集、好沟通 | 请求成本一有差异就完全失真;流量组成一变就得重测 | 同质化的简单服务、粗粒度限流 |
| CPU 利用率 | 覆盖面广;GC 让内存压力也反映为 CPU;CPU 可压缩,留有反应时间 | IO 密集型服务上失效——瓶颈在下游数据库时 CPU 还闲着 | 本章(Google)的默认选择 |
| 并发数 / 队列长度 | 直接对应排队,反应快 | 阈值难定;下游变慢时并发自然上涨,容易误判成自身过载 | Netflix concurrency-limits |
| 响应延迟 | 最贴近用户感受;对下游变慢敏感 | 是滞后指标,看见它涨的时候队通常已经排了一会儿 | Netflix 按端点设目标延迟 |
表 2 · 过载时服务端的四种反应
| 做法 | 用户看到什么 | 代价 | 什么时候用 |
|---|---|---|---|
| 硬扛(不设防) | 先是很慢,然后全线超时 | goodput 塌到 ~0,还会顺着调用链传染出去 | 永远不该是默认行为 |
| 优雅降级 | 结果质量下降(数据更少 / 排序更糙) | 降级路径平时不跑、极易腐坏;也让线上排障变复杂 | 存在天然「便宜版答案」的读路径 |
| 负载脱落 | 一部分人拿到明确、快速的错误,其余人正常 | 拒绝本身也耗 CPU;被拒那部分人体验差 | 通用兜底,越往外层做越便宜 |
| 自动扩容 | 短暂变慢后恢复 | 分钟级滞后,救不了秒级尖峰;新实例还要预热,可能反而加重下游 | 应对可预期的趋势性增长 |
表 3 · 重试的三道闸各挡住什么
| 闸 | 规则 | 挡住什么 | 局限 |
|---|---|---|---|
| 每请求上限 | 同一请求最多 3 次尝试 | 单个请求在本层无限磨 | 挡不住多层相乘 |
| 每客户端预算 | 重试数 / 请求数 ≤ 10% | 把整体请求量放大锁在约 1.1 倍 | 要求客户端诚实统计;不受控的第三方客户端不认这套 |
| 「别重试」标记 | 后端明确告知过载,上游立即停止重试 | 跨层的 3ⁿ 乘法 | 要求整条链路的框架都认这个语义 |
这一章在十年后仍是限流、熔断、降级这类设计的通用底本,是因为它把过载处理拆成了四个可以分别落地的动作:度量什么、服务端怎么少干、请求怎么分级、客户端怎么少发。今天你在 Envoy / Istio 里看到的自适应并发过滤器,在 Hystrix / Sentinel 里看到的熔断与降级,在各家 SDK 里看到的重试 token bucket,本质上都是这套东西的再实现。面试里"服务过载了怎么办"的标准好答案,也正是这四层——而不是"加机器"。
5:06am PDT 暂停了对元数据服务的请求,重试活动才降下来、负载才得以缓解——反证了本章第三道闸的意义:重试必须能被主动掐断。AWS《Summary of the Amazon DynamoDB Service Disruption》, 2015 ↗① 一句话:过载的关键不是"扛不扛得住",而是"扛不住时能不能只掉一部分"——无差别硬扛的结局是 goodput 塌到接近零。
② 容量别用 QPS 度量,请求成本差异太大。用资源度量,Google 默认盯 CPU(GC 让内存压力也表现为 CPU;CPU 可压缩,留有反应时间),信号是执行器负载均值。
③ 服务端两条路:优雅降级(答案更便宜)与负载脱落(干脆拒绝)。降级路径平时不跑、极易腐坏,所以模式要少、要简单、要定期演练。
④ 每客户配额按资源速率分配、总和可以超发;但拒绝本身也要花 CPU,所以必须配上客户端节流才闭环。
⑤ 自适应节流:客户端记 2 分钟窗口的 requests 与 accepts,按 max(0,(requests−K×accepts)/(requests+1)) 在本地概率性拒绝,K 常取 2;概率永远到不了 1,好漏一点出去探测恢复。
⑥ 重要性四级(CRITICAL_PLUS / CRITICAL / SHEDDABLE_PLUS / SHEDDABLE),沿 RPC 调用链自动传播、按级分别记配额;容量规划要覆盖前两级;脱载从最低级往上砍。
⑦ 重试三道闸:每请求 3 次上限、每客户端重试占比 ≤10%(把放大锁在约 1.1 倍)、后端返回"过载别重试"让信号能上传——否则三层各试 3 次就是 27 倍。
⑧ 只有局部过载才值得换个任务重试;整个数据中心过载时,重试只是搬运问题,该把错误一路上抛。
⑨ 连接本身也是负载:海量批处理 worker 直连后端时,连接开销可能压过请求处理,用一小组代理汇聚。
⑩ 落地实证:Amazon 主张每一层都脱载、把重试预算做成令牌桶;Netflix 把优先级脱载做进 Envoy 并对 IO 密集服务改用延迟信号;Stripe 为关键 API 预留 20% 容量。