专业书籍精读 · SRE · 第 9 章
Site Reliability Engineering · Ch 9 · Max Luebbe · Google · 2016
你手机里那些 App,越更新越大、越更新越慢,这不是错觉。软件有个自然趋势:只会越长越复杂,没人主动把它变简单。Google 这本书的第 9 章讲的就是怎么跟这个趋势对着干。它的立场只有一句:系统之所以不可靠,最大的原因不是机器坏、也不是人多,而是复杂到没人再看得懂它。
把系统想成一栋住了十年、年年加盖的房子。第一年住得挺好;后来隔出一间储藏室,加了一路暗线,留下一个「以后可能用得上」的旧插座,又装了个备用水泵——平时不开。十年后,房子还是那栋房子,但换个灯泡都得先猜:这根线通哪儿?拆这堵墙会塌吗?
软件比房子更容易失控,因为加盖不要钱:加一个功能有人夸,删一段旧代码没人夸、还担着「万一有人在用」的风险。于是所有人都在加,没人在减。
难在三处。第一,每个人都舍不得自己写的东西——那是几个月的心血,删掉像是承认白干。第二,那些「留着以后可能用」的旧路子平时根本不走,真到要走的那天,往往早就坏了,只是没人知道。第三,一次改一百处、攒够了再上线:上线后出了岔子,一百处改动全是嫌疑犯,查起来像大海捞针。
① 分清「这事本来就难」和「我们把它搞难了」。要把网页又快又稳地送到你手机上,这件事本身就难,逃不掉;但为了绕开某个工具的老毛病,外面多裹的三层壳,是自己给自己造的难——这层可以削掉,而且该削。
② 代码是负债,不是资产。每多一行,就多一份要有人读懂、有人测试、可能藏着错的东西。所以在一个已经做完的项目上,最好的改动往往是负数行——删得比加得多。删了也不可怕:版本管理工具替你留着底,真要它还能捡回来。
③ 每次只上一点点。一次上线一百处改动,出事你猜不出是哪一处;一次上线一处,坏了一眼就知道退谁。发得频不是为了快,是为了出事时嫌疑犯少。
2012 年,美国一家做股票交易的公司上线新版本,八台服务器里漏了一台没更新。偏偏那台上还留着一段八九年前就停用、却一直没删的旧代码,被新版本重新唤醒了。四十五分钟,四亿多美元没了。删掉不再运行的东西,不是洁癖,是拆引信。
可靠性的天花板,就是你还能理解多少:分清哪些复杂是这件事本来就有的、哪些是自己加上去的,把后者一层层削掉;把代码当负债而不是功劳,敢删;每次只改一点点,出事才找得到人。诚实说一句代价:做减法几乎总是当下更费劲、更没功劳,收益要等到半年后那次故障才兑现——所以它需要被明确认可,靠个人自觉是撑不住的。
想进到具体判据、对比表和示意图? → 切到精读版
SRE 第 9 章把一句常被当口号的话变成了可执行的工程判据:可靠性的上限由复杂度决定,所以管可靠性在很大程度上就是管复杂度。它给了四条抓手——分清本质复杂度与偶发复杂度、把代码当负债(「负的代码行数」是个好指标)、最小 API 与模块化、小批量发布。最反直觉的一课是:在软件里,「无聊」是褒义词——你要的不是一个有趣的系统,而是一个可预测、你还读得懂的系统。
作者 Max Luebbe(Google 工程师)。本章是 SRE 书 Part II「原则」的收官章:上承第 8 章「发布工程」(怎么安全地把变更送上去),下启 Part III「实践」(监控告警、值班、排障)。它与第 5 章「消除琐务」互为表里——toil 是复杂度在人身上的账单。放到现实里,它对应的是技术债治理、死代码清理、API 评审、旗标下线,以及「这个服务到底该不该再拆一层」的争论。
软件系统天然是动态且不稳定的:只有放在真空里、彻底不再改动的系统才可能完全稳定,可那种系统也不再产生价值。于是团队每天都在往里加东西,而复杂度有个恶性质——它只被加进来,很少被拿出去。原因不在技术,在激励:加功能有人夸,删代码既无功劳、又要承担「万一还有人在用」的风险。
规模会放大这个失衡。Google 2016 年公开的量级是:单一代码仓库超过 20 亿行、每个工作日约 4 万次提交(Potvin & Levenberg, CACM 2016)——在这个量级上没人能靠通读理解系统,不主动做减法,复杂度就是复利增长。
不解决的下场是三笔账:故障率——SRE 书引论给过一个数,约 70% 的线上故障源自对运行中系统的变更,而系统越复杂,一次变更的可预测性越差;响应速度——on-call 建不起心智模型,排障从「推理」退化成「猜」;沉默的风险——那些平时不执行的旧路径与回退逻辑,年复一年没人验证,最终在最糟的时刻被唤醒。
本章开篇就把张力挑明:软件只有不再变化才可能完全稳定,而完全不变的软件没有商业价值。所以 SRE 要的从来不是「别改了」,而是让每次变更带进多少复杂度这件事变得可见、可议价——这个功能买到了什么?它让系统多出几条执行路径、几个必须一起演进的组件?
本章也给探索性工作留了口子:把实验放进沙箱,范围、时间与爆炸半径都受限。可以用不成熟的方式试,但别把试验品留在承重墙上。
「无聊」在生活里是贬义,在软件里是最高褒奖之一:你不希望程序有创造力、有惊喜,你希望它照剧本走、可预测地完成业务目标。任何「有意思」的行为,本质上都是你没预料到的行为。
哪些复杂度该留?本章借用 Fred Brooks 的经典二分:本质复杂度是问题定义里固有、拿不掉的那部分;偶发复杂度是实现方式带来的、可以靠工程努力消掉的那部分。书里的例子很具体:让一个 Web 服务器快速正确地把网页送出去,是本质复杂度;若团队用 Java 写它、结果一半精力花在跟垃圾回收停顿搏斗上,那部分就是偶发复杂度。
由此得出 SRE 的两条职责,一条向前、一条向后:有人要往你负责的系统里塞复杂度时挡在门外(要求提案说清收益,而非默认放行);对已在跑的系统,主动找出偶发复杂度删掉。分寸在于:目标不是功能更少,而是功能不变、实现更无聊。
本章用一个很不客气的标题点出人性障碍:工程师会对自己写的代码产生感情,删掉像是承认几个月白干。本章要求换一套记账方式:代码不是资产,是负债——每一行都要有人读懂、有人测试、有人在凌晨三点被叫醒时理解它,而它的价值只体现在它此刻正在做的事上。既然如此,不再做事的代码只剩负债这一面。
那为什么还不敢删?因为怕以后要用。本章的回答很直接:版本控制让删除可逆——真需要,从历史里捡回来即可;而留在生产二进制里的每一段死代码,都是不可逆的风险。这不是洁癖,它有过极其昂贵的实证(见下文 Knight Capital)。
顺着上一条,本章给出一个刺眼的指标:一个已经做完的项目,理想的变更是负数行。理由朴素得没法反驳——每一行新增或改动的代码都是引入缺陷的机会;项目越小越容易理解、越容易测试、缺陷通常越少。所以「这周写了 3000 行」远不如「这周删了 3000 行还功能不减」值得庆祝。
注意它是指标不是考核项。真正的用法是一次心理校准——提交前先问一句:这个需求,能不能靠删掉些什么来满足?
本章引 Antoine de Saint-Exupéry 的话作为设计准则:完美不是无可再加,而是无可再减。落到 API 上就是:方法越少、参数越少,接口越容易被正确使用,也让你有余力把留下的这几个做到极好——文档、测试、兼容承诺的成本都随接口数量线性上涨。一个暴露 40 个方法的库,你不可能把 40 个都测好、都长期兼容。
模块化是同一原则在系统层面的展开:切成边界清晰、松耦合的部分,改动才能局部化。收益直接兑现在可靠性上——能局部改才能局部回滚。本章还把它延伸到数据格式:Google 用 protocol buffers,正因为它在设计上就照顾前后兼容(老代码能读新数据、新代码能读老数据),让程序与数据格式各自独立演进。
最后一条落回操作层:简单的发布优于复杂的发布。核心论证只有一句——衡量一个改动的影响,比衡量一批改动的影响容易得多。一次上线 100 个互不相关的改动,之后延迟变差了,要弄清是哪一个造成的代价高昂:通常只能二分回滚、逐轮排除。
算个量级:二分定位约需 log₂100 ≈ 7 轮「回滚一半、观察、再切」,每轮从部署到指标稳定常要几十分钟——半天就没了,而这期间用户一直在承受劣化。反过来,一次 1 个改动,嫌疑集合就是 1,回滚是一步操作。本章把这套做法类比成梯度下降:每次只走一小步,看它变好还是变坏,据此决定下一步——步子迈大了,你连自己往哪走都不知道。
表 1 · 本质复杂度 vs 偶发复杂度:怎么判、怎么处置
| 本质复杂度 essential | 偶发复杂度 accidental | |
|---|---|---|
| 来自哪 | 问题定义本身 | 我们选的语言 / 工具 / 历史路径 |
| 判据一问 | 换一套实现,它还在不在?在 | 换一套实现,它会不会消失?会 |
| 书里的例子 | 把网页快速、正确地送到用户手里 | 为对付 GC 停顿而生的那一堆调优与绕行 |
| 该做什么 | 正面承担:文档、测试、抽象把它包好 | 削:删死路径、去包装层、换更合适的工具 |
| 判错的代价 | 当成偶发去「简化」→ 砍掉真实需求,事故换个形式回来 | 当成本质去「承担」→ 背一辈子,且会继续长 |
表 2 · 四个天天遇到的战场:诱惑是什么、简单派怎么做、代价在哪
| 战场 | 诱惑 | 本章倾向的做法 | 诚实的代价 / 何时该反过来 |
|---|---|---|---|
| 加一个旗标 | 「先加个开关,两边都能跑」 | 开关是临时脚手架:上线即定下线期限,灰度完就删分支 | 删除要纪律;灰度期与需要紧急关停的高风险功能,开关值得留——但要有过期日 |
| 做一层通用抽象 | 「以后还会有第二、第三个接入方」 | 先写死,等第二个真出现再抽象;抽象要接口小、实现厚 | 第二个真来时要重构一次;接口一旦对外承诺过,晚抽象代价更高 |
| 写一条 fallback | 「主路径挂了还有备胎,更可靠」 | 力气花在让主路径更可靠;备胎若留就必须常态化演练 | 失去一层理论保护;只读降级、返回缓存这类简单退化仍值得,前提是它天天被走到 |
| 再拆一个服务 | 「拆开就解耦、就能独立发布」 | 先问拆的是本质边界还是组织边界;不能独立回滚的拆分不算解耦 | 它把代码复杂度换成网络与运维复杂度(超时、重试、编排);团队边界清晰、发布节奏确实冲突时,拆是对的 |
表 3 · 发布批量:大批量 vs 小批量(同样的改动总量)
| 一次 50–100 个改动 | 一次 1–5 个改动 | |
|---|---|---|
| 出事时的嫌疑集合 | 50–100 个 | 1–5 个 |
| 定位手段 | 二分回滚 ≈ log₂n ≈ 6–7 轮,每轮几十分钟 | 直接看最后一次,0 轮 |
| 回滚粒度 | 只能整批退,把 99 个无辜改动一起撤掉 | 精确退这一个 |
| 单次发布固定成本 | 摊薄,看着「省事」 | 被放大 10–100 倍,必须靠自动化压到接近零 |
| 前提 | 发布贵、要人工把关时的无奈选择 | 依赖第 8 章的发布工程:可复现构建 + 自动化流水线 |
表 4 · 删代码之前该拿到的证据(本章鼓励删,但删要有据)
| 手段 | 能证明什么 | 盲区 |
|---|---|---|
| 静态引用分析 | 代码库里没人调它 | 反射、配置驱动、跨语言 / 跨仓调用照样看不见 |
| 运行时采样 / 覆盖率 | 观察窗口内它确实没被执行 | 窗口太短会漏掉季度任务、灾难恢复路径这类低频调用 |
| 先关停、再删除 | 关掉一段时间没人喊疼,说明真没人用 | 需要旗标与观察期;出事要能立刻打开 |
| 公告 + 迁移期 | 外部调用方有时间撤离 | 只对你知道的调用方有效——公开接口永远有你不知道的用户 |
这一章的主张,在真实工程史上有正反两面的公开证据:Meta 专门造系统来自动化「删代码」,Amazon 公开劝人别写 fallback,而 Knight Capital 用四亿多美元证明了「留着不跑的旧代码」是什么后果。落到日常能用上的地方——架构评审问「这一层买到了什么」,代码评审问「能不能靠删满足这个需求」,发布评审问「这批改动出事了我怎么归因」。
① 一句话:可靠性的上限由复杂度决定,管可靠性就是管复杂度;SRE 有一半的活是主动做减法。
② 系统只有不再变化才可能完全稳定,但那没有价值——目标不是冻结变更,而是让每次变更带进的复杂度可见、可议价;实验放沙箱,限时限爆炸半径。
③ 在软件里「无聊」是褒义;分清本质复杂度(换实现也还在)与偶发复杂度(实现带来、可消掉),SRE 的两条职责是挡住新的、删掉旧的。
④ 代码是负债不是资产:不再执行的代码只剩负债一面。版本控制让删除可逆,留在生产里的死代码却不可逆——Knight Capital 用 45 分钟、逾 4.6 亿美元证明过。
⑤ 「负的代码行数」是好指标:做完的项目上,最理想的改动是删得比加得多;但它是自省用的方向感,当 KPI 必然被玩坏。
⑥ 最小 API:完美是无可再减;模块化让改动局部化、从而能局部回滚,数据格式同样要为演进设计(Google 用 protocol buffers 的原因)。
⑦ 小批量发布买的是归因能力:100 个改动一起上,出事要二分七轮;一次一个,嫌疑集合是 1。前提是发布本身足够自动、足够便宜(第 8 章)。
⑧ 删要有据:静态分析 + 运行时采样 + 先关停后删除 + 迁移公告;盲删同样是事故源(Hyrum 定律)。