专业书籍精读 · SRE · 第 20 章
Site Reliability Engineering · Ch 20 · Google · 2016
你在 App 里点一下,请求飞进某个机房。机房里跑这个服务的可能有上千台机器,程序一模一样——请求该交给哪一台?上一章讲的是「请求进哪个机房」,这一章讲进了机房之后:在一堆长得一样的机器里挑一台,凭什么挑。
超市结账,你当然找最短的那队。但假如有个收银员的机器坏了——每个顾客一秒钟就被打回来「付款失败,下一位」——他那队永远最短。于是所有人都往他那儿涌,谁也没结上账。
这就是机房里每天发生的真事:按「谁手上活儿最少」挑机器,坏掉那台看起来永远最闲。
一人一个轮着来,听着最公平。两个问题。
一是活儿轻重差很远。有的顾客只买一瓶水,有的推来满满一车。数「个数」不等于数「工作量」。
二是收银员快慢差很远。同一个机房里的机器有新有旧,旁边还有别的程序在抢资源。同样十个顾客,有人十分钟搞定,有人半小时。
结果总有一队排得特别长。而你配人手得按最挤那一队的水位来配——等于白养一批闲人。
第一招,别跟所有人打招呼。上千个调用方乘上千台机器,光「保持联络」就是几百万条线,两边都得掏内存、还得定时问一句「你还活着吗」。所以每个调用方只认识其中几十台。挑的时候还得讲究:随机乱挑会有的机器被很多人认识、有的没人理;按规则分配才能让每台被认识的次数一样多。
第二招,让机器自己举牌。别在外面猜它忙不忙,让它每次回话时顺便报一句自己有多忙——搭在回复里,不额外跑一趟。谁报得闲就多给谁。这是三种做法里最管用的。
第三招,要下班的人先挂牌子。一台机器要重启、要下线,别直接关灯走人——手上那几个顾客就砸了。正确做法是先挂一块「本通道不再接新客,已在队里的照常结完」的牌子,等队排空再走。书里管这块牌子叫跛脚鸭。没有它,一次例行重启就是一批用户当场看到报错。
粗的事在门口就办完了(进哪个机房);进了门只剩一件事:在同质的一堆机器里挑一台,而依据必须来自机器自己,不能靠外面猜。
诚实的代价:机器自报的忙碌程度,说的是刚刚过去那一小会儿的情况——负载突然抽一下,调用方手里的信息总是慢半拍。
机房内的负载均衡是调用方自己做的决定:先把要联络的机器缩到一小撮,再听机器自己报忙碌程度来分活,最后让要退场的机器先挂「不接新客」的牌子。最反直觉的一条是——「手上活儿最少」不代表最闲,也可能是坏了。
想进到具体机制、算法和示意图? → 切到精读版
你以为机房内的负载均衡是「把请求发给活儿最少的那台」,其实「活儿最少」本身是个被污染的信号——一台秒回错误的坏后端(backend)在这个口径下永远排第一。本章讲客户端侧负载均衡(client-side load balancing)的三件套:子集化(subsetting)把连接数从乘法压回加法、后端自报负载的加权轮询(weighted round robin)取代从外面猜、跛脚鸭(lame duck)状态让退场不产生用户可见的错误。
本章是第 III 部分「实践」里负载均衡二连的下半:Ch19 管流量进机房之前(DNS、任播、VIP),Ch20 管进了机房之后——在一堆同质后端里挑一台;紧接着 Ch21「处理过载」收拾分不掉的那部分。现实对应:gRPC、Finagle 这类 RPC 客户端库里的 LB 策略,以及服务网格里 sidecar 做的那件事。
理想状态一句话就能写:每个后端承担等量负载、谁都不过载。本章开门见山说这在真实机房做不到,并把原因摆成三条——这三条同时也是「简单轮询」失效的全部理由:
不解决会怎样?两笔账。
容量账。本章报告的实测量级:用简单轮询时最忙任务的 CPU 大约是最闲的两倍。容量必须按最忙那台配——等于为整个服务多买一倍余量,而 p99 也被那一台单独决定。
连接账。若每个客户端都连每个后端,连接数是乘法:一万客户端 × 一千后端 = 一千万条连接。一条 RPC 连接在两端各要数 KB 到数十 KB 内存,还要周期性健康检查的 CPU;按每端 10 KB 保守估,光连接本身就是百 GB 量级内存,纯属自费。
Ch19 那层是设备替你决定,这章反过来:没有中间人,选哪台后端由客户端的 RPC 库自己算。因为机房内的服务间调用量远大于外部流量,每一跳再塞一层代理,既加一个往返又多一个会挂的东西。代价:这个决定只基于它自己看得见的东西——它不知道另外五十个客户端往同一台后端发了多少。这条局限是本章所有妥协的共同根源。
后端在客户端眼里有三种状态:健康、拒绝连接、跛脚鸭。前两种好懂。最糙的那道闸叫流量控制(flow control):客户端记着自己对每台后端的活跃请求数,超过上限就当它不健康、暂时不发——挡住的是「已经堵住了」的后端。
跛脚鸭挡的是另一件事:计划内的退场。后端被通知要停止时若直接退出,手上那些在途请求当场变成用户可见的错误。跛脚鸭把这一步拆开:后端广播给所有活跃客户端「别再发给我,我还在把手上的做完」→ 客户端停发新请求 → 等在途清空 → 才退出。
量级感:1000 个任务的服务滚动更新一次就是 1000 次退场。不做跛脚鸭,每次例行发布都在往用户身上倒错误,而这批错误百分之百是自己造的。它要求客户端能收到并尊重通知,所以这是 RPC 框架层的能力,脚本补不上。
子集该多大?本章给的量级是20–100 台。太大压不下连接账;太小则方差大,且后端不可用时你无处可去——子集 10 台、其中 2 台在重启,你就损失了 20% 可用容量,而全局其实富余得很。难的是怎么挑这一小撮。
随机子集:各客户端自己随机挑。问题是连接分布很不均——有的后端被很多人挑中,有的几乎没人连。本章的对照设置是 300 客户端、300 后端、子集 30,偏斜相当明显;靠纯随机把偏斜压到可接受,子集得取到后端总数的约四分之三,等于放弃了子集化本身。
确定性子集:把客户端按编号切成一轮轮,每轮共用一个以轮号为种子的洗牌结果,再按客户端在本轮的序号切出连续一段:
每轮的子集数 = 后端总数 / 子集大小 轮号 = 客户端编号 / 每轮的子集数 (取整) 以轮号作随机种子 → 洗牌后端列表 子集序号 = 客户端编号 % 每轮的子集数 子集 = 洗好的列表里,从「子集序号 × 子集大小」起的一段
性质很漂亮:每台后端被连的次数完全相同,偏斜归零;客户端重启后算出同一个子集,不引起连接抖动。代价很实在:所有客户端对后端列表顺序与自己的编号必须有一致认识;列表一变洗牌结果就变,子集成员可能大面积换人——这正是它后来被质疑的地方。
简单轮询:子集内按顺序轮着发。失效原因就是上面三条,实测最忙任务约为最闲的两倍。唯一的好处是零信号、零状态、零配置。
最少负载轮询(least-loaded round robin):客户端按自己记的活跃请求数,发给活跃数最少的那台,并列时轮询。方向对——慢后端的活跃数会自然堆高,于是被自动少发。但它有两个硬伤,第一个是本章最该背下来的一句:
一台坏掉、每个请求都毫秒级返回错误的后端,活跃请求数永远是 0——在这个口径下它是最闲的,于是它吸走全部新请求。
修法朴素,一点不优雅:把近期错误在一段时间内当作活跃请求计入,它的「活跃数」就不再是 0。更普适的一条是:任何只看「在途量」的负载信号,都必须让失败单独记账,否则失败会变成吸引力。
第二个硬伤前面提过:活跃请求数只是客户端自己那一份——同一台后端可能正被另外五十个客户端喂得很饱,在你眼里却闲着。
加权轮询(weighted round robin):让后端自己报——把它量到的利用率、查询速率、错误率搭在 RPC 响应里回传;客户端对每台后端维护一个滑动平均,据此算权重、按权重分派。
为什么它能赢?信号来自被测者本人(利用率是后端自己量的,比外面数活跃请求准)、不额外花一趟探测(搭响应的车,边际成本近零),并且顺带把「机器不同质」吃掉了——被邻居抢了 CPU 的机器会自己报出高利用率,客户端不必知道为什么。代价:报的是刚过去那一小段的平均,负载突变时信息永远滞后;错误率怎么折算进权重是个要调的旋钮,调猛了一次抖动就把一台后端踢空。
表 1 · 三种负载均衡策略:信号从哪来、挡得住什么、代价是什么
| 策略 | 信号来源 | 能识别慢后端 | 能识别坏后端 | 主要代价 / 失效形态 |
|---|---|---|---|---|
| 简单轮询 | 无 | 不能 | 不能 | 最忙任务 CPU 约为最闲的两倍,容量得按最忙那台配 |
| 最少负载轮询 | 客户端自己的活跃请求数 | 能 | 反向——会被它吸引 | 秒回错误的后端活跃数为 0,成为首选;且只看得见自己那一份 |
| 加权轮询 | 后端自报利用率 / qps / 错误率 | 能 | 能(把错误率算进权重) | 信号滞后一小段窗口;错误率的折算系数需要调 |
表 2 · 子集怎么挑:三种做法的代价
| 随机子集 | 确定性子集 | 随机 + 交会哈希 | |
|---|---|---|---|
| 连接分布 | 偏斜明显 | 完全均匀 | 客户端数越多越接近均匀 |
| 增删一台后端 | 无所谓 | 洗牌结果变,子集可能大面积换人 | 每个客户端名单最多变 1 项 |
| 要不要全局协调 | 不要 | 要:客户端编号 + 后端列表顺序都得一致 | 不要 |
| 适用 | 子集能开得很大时 | 后端集合稳定、编号好分配时 | 后端频繁扩缩容时 |
收束:三件事各解一个维度,谁都替不了谁——子集化解「连谁」、LB 策略解「发给谁」、跛脚鸭解「怎么走得干净」。只能留一件就留跛脚鸭:前两件影响成本与 p99,它影响错误率——用户直接看见的就是那个。
这一章的价值在于把「负载均衡」从一个名词拆成三个互不相干的问题:连谁、发给谁、怎么走。混着谈就会得出「用最少连接数就行了」这种在真实后端上会放大故障的结论。面试最好用的三个追问也在这里:简单轮询为什么不够、「最少负载」为什么会奔向坏节点、后端自报凭什么比客户端观测准。
weighted_round_robin,理由写在提案开头:gRPC「没有任何一等的加权轮询支持」,靠给 round_robin 塞重复地址来模拟需要「一大堆机械」。权重由后端自报的利用率、qps 与错误率算出:weight = qps / (utilization + eps/qps × error_utilization_penalty)——连「错误率必须单独折算进权重」这条补丁都进了公式,正是本章「死鱼陷阱」的修法。配套的 A51 规定后端把指标「在对应 RPC 结束时附在 trailing metadata 里」。gRFC A58「weighted_round_robin LB policy」↗ · gRFC A51「Custom Backend Metrics」↗O(1) 算法——「随机选 N 台(默认 2 台)可用后端,取其中活跃请求数最少的那个」,即二选一(P2C),理由是这样「几乎和 O(N) 全扫一样好」,还「对羊群行为有抵抗力」。本章说「用最少负载」,Envoy 补上「但别真去扫全部」。Envoy 架构文档「Load Balancers」↗① 机房内的负载均衡是客户端侧的:选哪台后端由调用方的 RPC 库算,没有中间设备。代价是它只看得见自己那一份流量——本章所有妥协都源于这条。
② 理想(人人等量、无人过载)做不到,三个原因也正是简单轮询失效的理由:请求成本不等价、机器不同质、子集小方差大。实测:最忙任务 CPU 约为最闲的两倍,容量只能按它配。
③ 连接是乘法:一万客户端 × 一千后端 = 一千万条连接,按每端 10 KB 估就是百 GB 量级内存,所以必须子集化,量级 20–100 台。随机挑偏斜明显(要取到约四分之三才够均匀,等于没做);确定性子集用「轮号当种子洗牌 + 按序号切段」让每台被连次数完全相同,代价是要全局一致的编号与列表顺序,列表一变就大面积换人。
④ 全章最反直觉的一句:秒回错误的坏后端,活跃请求数永远是 0,所以在「最少负载」口径下最有吸引力。修法是把近期错误当作活跃请求计入一段时间。推广开来——任何只看在途量的信号都必须让失败单独记账,否则失败会变成吸引力。
⑤ 加权轮询是赢家:后端把利用率 / qps / 错误率搭在 RPC 响应里回传,客户端按滑动平均算权重。信号来自被测者本人、不额外探测,还顺带吃掉「机器不同质」;代价是滞后一个窗口。
⑥ 跛脚鸭把退场拆两步:先广播「别再发给我」、等在途清空、再退出。1000 个任务滚动更新一次就是 1000 次退场——不做它,每次例行发布都在给用户倒错误。它必须是框架能力。
⑦ 落地与修正:gRPC 把加权轮询做成一等策略(weighted_round_robin + ORCA),错误率折算进了权重公式;Envoy 承认没人真做 O(N) 全扫,改用二选一;确定性子集被换成随机子集 + 交会哈希。