写出能在生产环境活下来的代码
这一章讲三件事: 代码怎么防住可预期的故障;出了故障怎么看清现场; 以及怎么在不改代码的前提下控制系统的行为。 读完你会有一份「代码上线前的自查清单」——原书自己说,这是全书行文最紧凑的一章。
1. 这一章讲什么
第 02 章教你安全地改别人的代码;这一章回到你自己的代码。区别在环境:在学校里,代码跑一次就交;在生产环境(真实用户正在使用的运行环境)里,代码要一直活着。原书的开场判词是:暴露在真实世界里时,代码会做出奇怪的反应——用户行为不可预测,网络不可靠,事情总会出错1。
本章在全书链条里的位置:它是「贡献者之角」站点的核心技能。你写的代码能不能让运维者(负责让系统持续运行的工程师,可能就是未来的你自己)活得舒服,直接决定第 08 章的值班之夜有多少个。
2. 顶层全景
生产环境攻击: 用户乱输入 / 网络断 / 磁盘满 / 依赖挂 / 自己人手滑
│
┌─────┼──────────────┬──────────────────┐
│ 保护 │ 诊断 │ 控制
│ 防御式编程 │ 日志(分级/原子) │ 配置(不改代码改行为)
│ 校验输入、不可变、 │ 指标(计数/仪表/ │ 标准格式、默认值、
│ 类型提示、异常纪律 │ 直方图、P99) │ 校验、版本控制
│ 重试:退避+抖动+幂等 │ 调用跟踪(trace ID)│ 工具(CLI、可脚本化)
└────────────────────┴──────────────────┘
图说:三层各答一个问题——故障来了怎么扛(保护)、
扛不住时怎么看清现场(诊断)、不重启不改 码怎么调行为(控制)。
3. 核心原理
3.1 保护的第一层:让错误类型无处藏身
原书把防御式编程(主动预判并拦截错误的编码方式)分成两半:安全的代码利用编译期检查预防故障,有弹性的代码在故障发生时能恢复2。安全那一半的前两件事,共同点是「把错误从运行期挪到更早、更显眼的地方」:
-
避免空值。很多语言里「没有值」的变量默认是一个空值(null/nil/None),程序试图从这个不存在的值里取内容,是最常见的崩溃。三个对策:在方法开头就检查;用空对象模式——比如搜索没找到结果时返回一个空列表而不是空值,调用者可以照常遍历,不需要特判;或者用语言内置的可选类型(它强制你考虑「没有值怎么办」)3。
-
变量尽量不可变。一旦赋值就不能再改的变量,杜绝了「某个我没想到的分支把它改了」这类 bug;很多变量可以声明 成不可变,比你预想的多4。
3.2 用最具体的类型:让非法值立刻报错
-
用最具体的类型。取值只有少数几种的字符串(一串字符组成的文本值),不要随手用「随便装什么都能塞」的裸文本类型接。
-
这类值应该用枚举(把合法值列成清单的类型)来表达——非法值会立刻报错,而不是埋雷5。
-
Python、TypeScript 这类动态语言如今都支持类型提示(在代码里注明变量的类型)和静态类型检查器(不运行代码就检查类型的工具),而且可以逐步加进旧代码6。
3.3 验证输入:一个值钱 15 年的教训
「永远不要相信你的代码接收的输入」7。这句话值得单独展开,因为原书给了一个代价惊人的反面故事。
德米特里大学时在基因组学实验室做网站,生物学家老是把 DNA 序列(一长串 A/C/T/G 字母)存成 Word 文档传上来。
网站解析(读入并理解内容)失败后只说「没有找到匹配的序列」。用户于是提交 bug 报告说搜索功能坏了;团队指责用户不读说明书;德米特里最后没有加格式检查,而是贴了个大图标:「不接受 Word 文档!」。十五年后他重返那个网站,上传一个 Word 文档,依然得到「没有结果」——十几年来这个系统一直在给误导性的结果,因为他当年懒得校验输入8。原书的评语是:不要成为 20 岁的德米特里。
输入校验还有安全的一面:恶意用户会尝试注入代码或 SQL(一种数据库查询语言)来夺取控制权。对策不是自己发明,而是用成熟的安全类库、读 OWASP 十大安全报告(一份业界最常见 Web 安全漏洞清单)9。
3.4 保护的第二层:异常的纪律
出了运行期错误怎么办?现代语言用异常(一种携带出错信息的对象,沿调用链向上传播,直到有人处理)报告错误。原书给了四条纪律:
第一,别用 特殊返回值报错(返回 null、0、−1 之类)。错误条件在方法签名上看不出来,调用者不知道要处理,也记不住哪个值对应哪种故障;异常可以带名字、堆栈跟踪(出错时函数一层层调用的记录,像出错地点的地址)、行号和消息10。
第二,异常要有精确含义。 能用内置异常就不要自造;自造时不要造得太通用——拿到模糊异常的开发者只好让整个程序失败,那是最大的动作11。反例是原书里的 FoundNodeException:用「抛异常」来表达「找到节点了」这种正常情况,直接返回节点就行——异常是报告故障用的,不是控制程序逻辑用的12。
第三,早抛晚捕。 在离出错地点尽可能近的地方抛,让排查的人能立刻定位;处理则尽量晚——在调用链上传播到真正知道该怎么办的那一层。原书的例子是往一个已满的磁盘写数据:数据库的预写日志必须写,文字处理程序的后台保存却可以等——只有最上层知道哪种反应合适,中间层一律向上传播13。最糟糕的违规是「吞掉」异常:catch 住之后什么都不做,程序带着隐藏的失败继续跑14。
第四,处理不了的让它崩溃,但要响亮地崩。 这叫快速失败——遇到设计时没预料到的错误就直接停,避免造成进一步损害,同时把信息亮出来方便调试15。
3.5 保护的第三层:重试的正确姿势与幂等
网络调用失败是常态,「再试一次」往往是正确反应。但裸重试有两个坑,原书各给了对策:
-
立刻连着重试没用。磁盘满了,10 毫秒后大概率还是满的。对策是退避:每次重试前等更久——通常按指数(每轮翻倍的那种增长)拉长,比如等待时长取重试次数的平方——并设上限16。
-
整齐划一的重试会压垮恢复中的服务端。服务端闪断,几百个客户端(发起调用的程序)同时重试、又在同一时刻醒来——原书借网络术语称这为惊群效应。对策是抖动:给退避时间加一点随机量,把重试打散17。
再往深一层是这道题:远程写入时网络断了,这次请求到底成没成功? 你重试,可能把计费请求发两遍——客户被双倍收费;你放弃,可能一笔都没记上18。治本的办法是幂等:一个操作执行多次与执行一次的结果相同。给每个请求配一个由客户端(发起调用的那一方)生成的唯一编号,服务端见到重复编号就直接丢弃——重试从此变得安全19。
拿原书的场景把幂等走一遍:客户购买一项 10 元/月的服务,扣费请求发出后网络超时。
非幂等系统: 扣费请求(10 元) → 超时,不知成败 → 重试 → 再扣 10 元
客户账单:20 元(或客服工单一张)
幂等系统: 扣费请求(id=req-8871, 10 元) → 超时 → 带**同一个** id 重试
服务器:id=req-8871 已处理 → 丢弃 → 客户账单:10 元
(id 是请求唯一编号;10 元、req-8871 是演示用的编造值,机制照实)
配套的小事还有资源(系统里数量有限、要省着用的东西)的释放:文件句柄、网络套接字这类一次性资源用完必须关——操作系统给它们的数量有上限,泄漏的连接会一点点填满连接池20。把清理代码包进 try/finally(异常也要执行的代码块),或用语言自带的自动关闭机制(Python 的 with、Rust 的析构)21。
3.6 诊断之一:日志——程序写的流水账,但要有纪律
保护做得再好也有故障。诊断的第一种仪器是日志,四件事:
-
分级。常用六级:TRACE(逐行细节,开发期用)、DEBUG(排查故障时才有用)、INFO(默认级别,报告「服务已启动」「在端口 5050 上监听」这类正常状态)、WARN(有潜在问题,而且每条 WARN 必须对应一个看到它的人要采取的具体行动,否则降成 INFO)、ERROR(正在发生的错误,要带细节和堆栈)、FATAL(必须立即退出的最后一搏)22。
-
原子性。一条日志把相关信 息写进一行:日志聚合器(把多台机器的日志收集到一处搜索的系统)常把每个新行当成独立消息,折行、交叉的多线程输出会把现场搅成浆糊;堆栈跟踪必须完整落在一条消息里23。
-
性能。字符串拼接在日志被过滤掉时也会执行,性能敏感的循环里可能有毁灭性影响;要用参数化日志(把变量作为参数交给日志框架,确定要输出时才拼接)24。还要知道一个隐蔽的坑:调高日志的级别会改变程序的时序,可能让某个竞争 bug「消失」——日志的级别变了,时序本身就变了25。
-
敏感数据不入日志。密码、访问令牌(登录凭证)、信用卡号、邮箱,一旦写进日志就散布到聚合系统。
-
脱敏(把敏感片段替换成遮罩)只是兜底。基于规则的脱敏配置可以有,但别只依赖它26。
3.7 诊断之二:指标——日志的数值版
三种基本类型,先各记一个例子:
-
计数器——某事件发生了几次,只增不减,比如缓存(把数据留在近处省得反复取)的命中数。
-
仪表——某个时点的读数,可升可降,像汽车的速度表,比如当前队列长度。
-
直方图——按数值范围分桶计数:把每个值归进一段区间、数个数,得到一条分布——每个区间就是一个桶;请求耗时常用它27。
延迟(系统响应一个请求所花的时间)怎么读?有讲究。
通常按百分位——比如「P99」——来报:P99 耗时 2 毫秒,意思是 99% 的请求在 2 毫秒内得到响应——查最慢的尾巴比看平均值有用,因为慢请求恰恰是用户记住的那些28。指标汇入可视化系统(Datadog、Prometheus 这类),配上告警规则和自动扩缩容29。
3.8 诊断之三:调用跟踪
把一次请求的全部下游调用串成一张图。一次上游 API(程序之间约定的调用入口)调用可能在下游触发几百次跨服务调用(这类跨进程调用叫 RPC);调用跟踪给每个请求发一个全程携带 的跟踪 ID,专门的系统用它把散落的记录拼回一张调用图,用于查错、找性能瓶颈、算成本30。
3.9 主走查:一个四行代码的小服务怎么布满仪器
原书拿一个 Python Flask(一个 Web 应用框架)写的键值存储演示了指标怎么落地。服务只有四个操作:设置键值、读取、删除、把整个存储序列化(把内存对象转成可传输的格式)输出。
序列化的目标格式是 JSON(一种带引号的键值文本格式)。原书的做法值得逐个指标走一遍31:
@app.route('/set/<k>/<v>') 设置键值,更新存储
→ statsd.gauge('map_size', len(map)) # 仪表:当前有多少个键
@app.route('/get/<k>') 读键
→ 命中: statsd.incr('key_hit') # 计数器:命中 +1
→ 未命中: statsd.incr('key_miss') # 计数器:未命中 +1
(命中率 = key_hit / (key_hit + key_miss),由这两个计数器算出)
@app.route('/dump') 序列化整个存储并返回
→ with statsd.timer('map_json_encode_time'): # 计时器→直方图
return jsonify(map)
三个选择各有理由。map_size 用仪表而不是「增删计数器相减」——一个读数顶两个计数器,不容易出错。命中/未命中用两个计数器,因为命中率的变化直接影响性能判断。dump 专门计时,因为序列化是 CPU 密集操作,原书提醒它「往往是代码中开销最高的操作」——不量你就永远不知道慢在哪32。除此之外,Web 框架本身会免费附赠每个请求的状态码和耗时,接上可视化系统就行,不用自己写33。原书还开了一张「测量一切」的清单:资源池、缓存、关键数据结构大小、CPU 密集操作、I/O、数据量、异常计数、远程请求34。
3.10 控制:配置与工具
第三层回答:不改代码、不重启(或少重启),怎么改变系统行为?
配置要先选对形态。 常见载体是配置文件(INI/JSON/YAML)、环境变量(随进程启动注入的键值设定)、命令行参数35。原书的第一条告诫针对炫技:配置系统越聪明,bug 越奇怪——「某个在凌晨 3 点被叫起来的运维人员,不应该需要记住 Tcl(一门配置用的老式语言)的写法来改变某个超时的值」;理想形态是单一标准格式的静态配置文件36。动态配置(运行中热加载的配置)收益常常抵不上它带来的复杂程度,正当例外很少,日志的分级算一个:出怪事时把级别调到 DEBUG,不用重启那个正在犯病的进程37。
配置要当 代码管。 一条错误的配置能毁掉整个应用,所以配置要进版本控制、要评审、要校验:程序启动时把(非秘密的)配置全部打出来,加载完立刻校验类型和取值范围——−200 是合法整数,但不是合法端口38。默认值要让程序开箱即用:没配端口就默认用大于 1024 的(小于 1024 的端口需要特权)39。相关的参数要分组写在一起:timeout=10s 优于拆成数值和单位两个字段(配置里的一个个条目)40。最容易被诱惑破坏的规则是:不要手动编辑已经部署出去的配置——一次性修改会被下次部署覆盖,没人知道谁改过什么,相似的机器从此分叉;真在事故中手改了,事后必须回填进版本控制系统(第 02 章讲过它的纪律)41。
最后是工具。 可维护的系统自带运维工具:一次灌入大批数据、重置状态、触发故障转移。运维团队偏爱命令行工具,因为可脚本化、可自动化;如果你的工具带界面,把逻辑抽成共享库,让命令行版也能用42。
工具这层为什么不能省,原书用一场真实事故回答:2017 年 2 月 28 日,一名亚马逊工程师排查计费子系统时执行了一条删除服务端机器的命令,参数「手滑了」——删掉的数量远超预期,引发连锁重启,S3(亚马逊的对象存储服务)瘫痪一个多小时,大半个互联网跟着遭殃。亚马逊事后的整改全部落在工具上:让删除变慢、给容量加最低下限保护、复查所有运维工具的输入校验43。工具的输入校验,就是凌晨 3 点那双手和公司命运之间唯一的垫子。
4. 作者的判断与证据
- 「覆盖率与防御的取舍」:原书主张尽量用编译期检查挡住运行期错误(类型提示、枚举、不可变),这是业界共识性的工程判断,依据是错误暴露得越早修复越便宜,书内未给量化证据。
- Word 文档故事:亲历轶事,支撑「验证输入」;十五年误导性结果是作者自述。
- AWS S3 事故:真实公开事件(2017-02-28),原书引用了亚马逊的官方说明原文,是本章最硬的一条证据。
- 「配置无须新花样」:作者判断,论据是「配置方案越聪明,bug 越奇怪」的经验观察。
- 六级日志的含义表:原书自述日志分级「并没有完全统一的标准」,给出的六级是「常见的」约定,不是规范。
5. 边界与局限
- 原书是 2021 年写的,监控与可观测性领域此后变化很快(例如 OpenTelemetry 的普及),书中点名的具体系统(Datadog、StatsD)是举例,不是推荐清单。
- 单机视角为主:本章的日志/指标/配置都默认一个服务;多服务系统的聚合、抽样、成本问题点到为止(调用跟踪一节只有半页)。
- 安全只给了入口:OWASP 十大是「去读」的指路牌,本书不展开安全工程。
- 动态配置的边界:原书承认有些场景确实需要热加载,但没给出「什么时候值得」的量化判据。
- 幂等一节只讲了「请求唯一 ID」一种实现;分布式系统里还有事务(要么全成、要么全不成的一组操作)、去重窗口等更重的机制,书里没提。
6. 可带走的
- 代码上线前自查三层:保护(错误类型无处藏身)、诊断(现场看得清)、控制(行为可调);
- 返回空列表,别返回 null;能用枚举就别用裸字符串;变量默认不可变;
- 永远校验输入——Word 文档故事告诉你,一个没校验的格式能误导用户十几年;
- 异常四纪律:不用特殊返回值、异常要精确、早抛晚捕、绝不吞异常;
- 重试要退避加抖动;能幂等(请求唯一 ID)就幂等——重试的安全性是设计出来的;
- WARN 日志必须对应一个具体行动,否则降级;日志一行一条,堆栈不许折行;
- 看延迟看 P99 不看平均值;序列化往往是最贵的操作,要给它上计时器;
- 配置用 标准格式、启动时打印并校验、超时和单位写在一起、绝不手改生产配置;
- 给运维同事留 CLI 工具,并像对待产品一样校验它的输入——AWS 那次手滑删的就是输入校验的缺。
7. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 生产环境的攻击 | 第4章 编写可维护的代码 | text/13-ch04.txt:3(搜「真实世界」) |
| 安全+弹性 | 第4章 编写可维护的代码 | text/13-ch04.txt:8(搜「安全的代码」) |
| 空对象模式 | 第4章 编写可维护的代码 | text/13-ch04.txt:34(搜「空对象模式」) |
| 不可变 | 第4章 编写可维护的代码 | text/13-ch04.txt:49(搜「不可变的变量」) |
| 类型提示 | 第4章 编写可维护的代码 | text/13-ch04.txt:57(搜「类型提示」) |
| 验证输入 | 第4章 编写可维护的代码 | text/13-ch04.txt:81(搜「永远不要相信」) |
| Word 文档故事 | 第4章 编写可维护的代码 | text/13-ch04.txt:96(搜「不接受 Word 文档」) · text/13-ch04.txt:129(搜「治疗癌症」) |
| OWASP | 第4章 编写可维护的代码 | text/13-ch04.txt:140(搜「开放式 Web 应用程序安」) |
| 别用特殊返回值 | 第4章 编写可维护的代码 | text/13-ch04.txt:146(搜「特殊的返回值」) |
| 异常要精确 | 第4章 编写可维护的代码 | text/13-ch04.txt:177(搜「精确的异常」) |
| 异常不当流程控制 | 第4章 编写可维护的代码 | text/13-ch04.txt:203(搜「FoundNodeException」) |
| 早抛晚捕 | 第4章 编写可维护的代码 | text/13-ch04.txt:209(搜「早抛晚捕」) · text/13-ch04.txt:217(搜「晚捕」) |
| 吞异常 | 第4章 编写可维护的代码 | text/13-ch04.txt:225(搜「吞下」) |
| 退避 | 第4章 编写可维护的代码 | text/13-ch04.txt:254(搜「退避」) · text/13-ch04.txt:255(搜「指数退避」) |
| 惊群与抖动 | 第4章 编写可维护的代码 | text/13-ch04.txt:260(搜「惊群效应」) · text/13-ch04.txt:262(搜「抖动」) |
| 快速失败 | 第4章 编写可维护的代码 | text/13-ch04.txt:267(搜「快速失败」) |
| 幂等与双倍收费 | 第4章 编写可维护的代码 | text/13-ch04.txt:271(搜「幂等系统」) · text/13-ch04.txt:277(搜「加倍收费」) · text/13-ch04.txt:286(搜「唯一 ID」) |
| 资源释放 | 第4章 编写可维护的代码 | text/13-ch04.txt:297(搜「网络套接字泄露」) |
| 日志六级 | 第4章 编写可维护的代码 | text/13-ch04.txt:158(搜「TRACE」) · text/13-ch04.txt:368(搜「INFO 是默认的日志级别」) · text/13-ch04.txt:15(搜「可操作性」) |
| 原子日志 | 第4章 编写可维护的代码 | text/13-ch04.txt:397(搜「原子日志」) · text/13-ch04.txt:403(搜「折 行」) |
| 日志性能 | 第4章 编写可维护的代码 | text/13-ch04.txt:426(搜「参数化」) · text/13-ch04.txt:469(搜「竞争条件和 bug」) |
| 敏感数据 | 第4章 编写可维护的代码 | text/13-ch04.txt:477(搜「信用卡号码」) |
| 指标三类型 | 第4章 编写可维护的代码 | text/13-ch04.txt:490(搜「计数器、仪表盘和直方图」) |
| P99 | 第4章 编写可维护的代码 | text/13-ch04.txt:502(搜「P99」) |
| StatsD 走查 | 第4章 编写可维护的代码 | text/13-ch04.txt:543(搜「代码清单 4-12」) · text/13-ch04.txt:564(搜「key_hit」) |
| 序列化最贵 | 第4章 编写可维护的代码 | text/13-ch04.txt:617(搜「序列化操作」) |
| 调用跟踪 | 第4章 编写可维护的代码 | text/13-ch04.txt:640(搜「分布式调用跟踪」) · text/13-ch04.txt:647(搜「调用跟踪 ID」) |
| 配置形态 | 第4章 编写可维护的代码 | text/13-ch04.txt:664(搜「INI」) |
| 凌晨 3 点 | 第4章 编写可维护的代码 | text/13-ch04.txt:686(搜「凌晨 3 点」) |
| 动态配置例外 | 第4章 编写可维护的代码 | text/13-ch04.txt:707(搜「动态配置」) |
| 记录并校验配置 | 第4章 编写可维护的代码 | text/13-ch04.txt:727(搜「有效的端口」) |
| 默认端口 1024 | 第4章 编写可维护的代码 | text/13-ch04.txt:734(搜「1024」) |
| 参数分组 | 第4章 编写可维护的代码 | text/13-ch04.txt:745(搜「timeout_duration」) |
| 配置即代码 | 第4章 编写可维护的代码 | text/13-ch04.txt:751(搜「配置即代码」) |
| 不手改配置 | 第4章 编写可维护的代码 | text/13-ch04.txt:770(搜「一次性修」) |
| 工具与 CLI | 第4章 编写可维护的代码 | text/13-ch04.txt:789(搜「命令行界面」) |
| AWS 事故 | 第4章 编写可维护的代码 | text/13-ch04.txt:804(搜「亚马逊让互联网崩溃」) · text/13-ch04.txt:817(搜「手滑了」) |