读前:这一章要回答什么
一份无法消除的复杂性,该由模块的作者在内部消化,还是交给模块的使用者去处理?
第 4 章说模块要深,第 5~7 章讲了几种把模块做深的办法。第 8 章很短,却给出了一个贯穿全书的取向:当复杂性躲不掉、又和模块的功能相关时,让模块的作者多吃点苦。理由很朴素:一个模块的使用者通常比作者多得多,作者辛苦一次,换来所有使用者省事。
it is more important for a module to have a simple interface than a simple implementation.— 第 8 章开头
这一章用两个例子说明这个取向(编辑器文本类、配置参数),最后用"别做过头"给它划了边界。它也为第 10 章埋了伏笔:随手抛异常,是最常见的"把复杂性往上推"。
- 本章开头:作者描述了开发者最常见的两种"踢皮球"(punt)的方式。读的时候对照你最近写的代码:有没有因为拿不准,而抛了一个异常或者加了一个配置项?
- 8.2 节:作者在暴露一个配置参数之前会先问自己一个问题。把这个问题原样记下来,它是本章最实用的工具。也注意作者举的那个"配置参数确实合理"的例子。
- 8.3 节:往下拉在三个条件下最有道理。读完回头想第 6 章的退格键方法:为什么它看起来是往下拉,作者却说它只是信息泄漏?
核心观点
踢皮球的两种方式
拿不准怎么处理,就抛异常给调用方;拿不准用什么策略,就加个配置参数让管理员去定。短期省事,长期放大复杂性。
配置参数是在往上推
它常常是一个不完整的解决方案:使用者往往不知道该填多少,填了的值也会随环境变化而过时。
使用者比作者多
复杂性留在模块里,只有作者一个人要面对;推给使用者,每个调用方、每个部署都要面对一遍。
简单的接口比简单的实现重要
为了让接口简单,实现变复杂是值得的。
暴露参数前先问
使用者(或上层模块)能不能定出比我们在这里定的更好的值?不能,就自己算;能,也尽量给合理的默认值。
别做过头
只有和模块已有功能密切相关、能让别处变简单、能让接口变简单的复杂性,才值得往下拉。
心智模型:同一份复杂性,付一次还是付 N 次
假设有一份躲不掉的复杂性(比如"缓冲多大、什么时候落盘")。它要么在模块里被解决一次,要么在每个使用者那里各解决一次:
右边的代价不只是"多写几遍":每个使用者对这份复杂性的理解都不一样,有人填对了参数,有人照抄了别人的值,有人干脆没处理异常。作者的说法是,往上推会放大复杂性,让许多人去面对本来只需一个人面对的问题。
Most modules have more users than developers, so it is better for the developers to suffer than the users.— 第 8 章开头
两个例子
编辑器文本类(8.1 节)。这是第 6、7 章用过的例子换个角度看:按行的接口让文本类实现简单,却让界面代码到处拆行、并行;按字符的接口让文本类内部要处理拆行并行,界面代码却简单了。复杂性从"每个界面操作"移到了"文本类内部一处",系统整体更简单。
配置参数(8.2 节)。这是本章的重点,下面用一个存储引擎的预写日志写入器来看。很多系统的配置项是这样长出来的:每遇到一个拿不准的取值,就加一个参数。
struct WalOptions {
size_t bufferBytes; // 写缓冲大小
uint32_t flushIntervalUs; // 最长多久必须刷一次
uint32_t maxBatchRecords; // 组提交一批最多几条
uint32_t fsyncEveryNFlushes; // 每刷几次 fsync 一次
size_t preallocBytes; // 预分配文件大小
bool useODirect;
};
WalWriter wal("db.wal", WalOptions{1 << 20, 2000, 128, 4, 64 << 20, false});
使用者真正关心的只有一件事:"崩溃时最多丢多少数据"。为此却得先弄懂六个底层参数和它们的相互影响。实际上大多数部署会从别处复制一组数字,然后再也没人改过,哪怕硬件早就换了。
enum class Durability {
EveryCommit, // 每次提交都落盘才返回:最安全,最慢
Batched, // 最多丢失最近约 10ms 的提交(默认)
OsBuffered, // 交给操作系统决定何时落盘:最快,崩溃可能丢较多数据
};
// 缓冲大小、批量、预分配由写入器根据观测到的写入速率自动调整。
explicit WalWriter(const std::string& path, Durability d = Durability::Batched);
WalWriter wal("db.wal");
拆开看每个参数:缓冲、批量、预分配,使用者不比写入器更清楚该取多少,写入器看得到实际写入速率,可以自己算,还能随负载变化自动调整;持久性要求则是业务决定,只有使用者知道,所以保留,并给一个合理的默认值。代价是写入器的实现变复杂了,这正是本章说值得付的代价。
这个改法就是 8.2 节那个问题的直接应用:使用者能不能定出比我们在这里定的更好的值?书中的正面例子是网络协议的重传间隔:与其让人配置,不如根据成功请求的响应时间自己算(训练场里有一道题就是这个);反面的合理例子是请求优先级:哪个请求更紧急,只有使用者知道。
把这个问题用在几个常见参数上练一练。注意同一个参数在不同系统里答案可能不同,关键是谁掌握了做决定所需的信息:
| 参数 | 谁掌握决定所需的信息 | 倾向的处理 |
|---|---|---|
| 哈希表初始容量 | 模块:它看得到实际元素数,可以自动扩容 | 删掉 |
| 读缓冲大小 | 模块:一个对绝大多数访问模式都够好的值,或者根据访问模式自适应 | 删掉,或内部常量 |
| 后台线程数 | 大多由模块决定(核数、积压程度);容器里的 CPU 配额有时只有部署方清楚 | 自动计算,留可选覆盖 |
| 写入是否每次落盘 | 使用者:丢几条数据能不能接受是业务决定 | 保留,写清代价,给默认值 |
| 请求优先级 | 使用者:只有使用者自己知道哪个请求有人在等(书中的例子) | 保留,默认"普通" |
| 租户存储配额 | 使用者:合同和计费决定的 | 保留(往往是必填) |
抛异常也是同样的道理。拿不准"键不存在""缓存满了"该怎么办,就抛给调用方,看起来很负责,实际上是让每个调用方都写一遍同样的处理代码。第 10 章会专门讨论怎样让异常少一些。
OSTEP 的多级反馈队列(MLFQ)一章讲到"每隔多久把所有任务提回最高优先级"这个时间参数时,引用了 Ousterhout 的一个说法:这类值像巫毒常数(voo-doo constants),似乎要靠某种黑魔法才能设对;OSTEP 的作者还把"尽量避免巫毒常数"称为"Ousterhout 定律"。OSTEP 也坦言这很难做到,结果往往是一份满是默认值的配置文件,通常没人改,只能祈祷默认值够用;它提到越来越多的系统转向自动方法(比如机器学习)。对照本章:作者给出的出路正是"让模块自己算",而那份没人改的配置文件,就是 8.2 节说的"容易过时"。
边界与反方
极端地说,把整个应用都塞进一个类,复杂性就全"在下面"了,这显然荒谬。作者给的判断条件是:被拉下去的复杂性和这个类已有的功能密切相关;拉下去能让别处的代码变简单;而且能让这个类的接口变简单。书中的反例是第 6 章的退格键方法:把界面知识放进文本类,看似往下拉,实际上既没让上层简单多少,又和文本类的核心功能无关,结果只是信息泄漏。目标始终是整个系统的复杂性最低。
作者并没有说"永远不要配置项"。主张配置的人有一个对的理由:底层代码有时确实不知道最佳策略,而使用者更了解自己的领域(比如哪些请求更紧急)。这时暴露参数是对的,但要配上合理的默认值,让大多数人只在特殊情况下才需要碰它。作者反对的是把配置当成回避决定的借口,以至于有的系统长出了几百个参数。
"让模块自己算"不是免费的:自适应逻辑更难预测、更难调试,出问题时运维人员会问"它为什么现在选了这个值"。实践中常见的做法是:自动计算为主,把算出来的值通过指标或日志暴露出来,再留一个罕用的覆盖开关(并写清楚什么时候才该用)。这和作者的立场并不矛盾,那个开关只在例外情况下才需要碰,大多数使用者仍然不用知道它的存在。
以下不在书里,来自书中给出的读者讨论组。有读者说,一大批带默认值的配置参数很有用,不用重新编译就能做实验;同理,接口可以很长,只要把主要用法放在前面、微调用的方法放在后面。Ousterhout 回答说这听起来合理(2023-04):只要这些额外的东西引入的方式让大多数程序员不必知道它们,对实际的复杂性就没有多大影响。
这和 8.2 节并不矛盾,但语气比书里宽。两处合起来的判据是:参数的存在不是问题,问题是有没有人被迫去了解并设置它。默认值要真的能用,而且模块自己能算出来的值不该变成参数。
练一练
下面三道题来自训练场:一道判断某个参数该不该暴露,一道识别"踢皮球"式的配置,一道在多个"往下拉"提议里挑出真正合理的那个。
再加两道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
minikv 的作者把好几个"拿不准"的决定交给了使用者:启动时有一串调优参数,README 里有一整节教你怎么调,代码里还有专门检查参数组合是否合法的逻辑。这个任务要你逐个用 8.2 节的问题检验这些参数:哪些可以由存储自己算出来或者干脆用一个内部常量,哪些确实只有使用者知道(那就保留,写清楚代价,并给默认值)。
任务 m04:把 minikv 推给使用者的配置和决定拉回模块内部。详见 任务页。
cd project/minikv && make test自测题
小结与读后一句话
- 躲不掉、又与模块功能相关的复杂性,通常应该由模块内部消化:使用者比作者多。
- 简单的接口比简单的实现更重要。
- 抛异常、加配置项是最常见的"踢皮球",会把一个人的问题放大成所有人的问题。
- 暴露参数前先问:使用者能不能定出更好的值?能,也要给默认值;不能,就自己算。
- 别做过头:只拉那些和已有功能密切相关、能让别处和接口都变简单的复杂性。
第 9 章把视角从"上下"转向"左右":给定两段功能,应该放在一起还是分开?它会重新审视第 4 章提过的"方法越短越好",也会给出三个新的红旗。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。