可演进的架构:与复杂性讨价还价
这一章讲三件事: 复杂性是什么、藏在哪、为什么消除不了; 什么样的代码和接口让「明天的修改」变便宜; 以及同一个问题在数据上的重演:schema、迁移与下游兼容。 读完你会有一套「这次改动会不会让系统更难改」的自检法。
1. 这一章讲什么
需求的不确定性是软件项目躲不掉的挑战:客户需求和环境随时间变化,应用必须跟着变。管理者用迭代(一小段一小段地做、每段结束调整方向)开发(第 11 章)来吸收不确定性;而工程师这边的答案,原书一句话:构建可演进的架构,因为复杂性是演进性的敌人1。
本章在全书链条里的位置:第 09 章讲了怎么「做出」设计;本章讲怎么让设计出来的东西活得久——它是第 04 章(版本号承诺)与第 07 章(展开策略)在架构层的会师点。
2. 顶层全景
明天的需求今天不知道
│
├─ 敌人:复杂性(难以理解和修改)
│ 三症状: 高依赖(牵一发动全身) / 高隐蔽(看不出改动的波及) / 高惯性(改不动)
│ 定律: 复杂性无法消除,只能选择放在哪里
├─ 代码层三原则: YAGNI(别造没用的) / 最小惊讶(别埋隐性知识) / 领域封装(把爆炸半径圈小)
├─ 接口层: 兼容性——新旧版本能否共存(Greeter 三次演进)
└─ 数据层: 同样的问题换了个战场——schema / 迁移工具 / 下游(ETL/CDC)兼容
图说:自上而下从「意识」到「纪律」;主走查在 3.3(Greeter 演进)。
3. 核心原理
3.1 认清敌人:复杂性的三个症状
原书借约翰·奥斯特霍特《软件设计的哲学》的定义起步:复杂性是「与系统结构有关的东西」——它使人难以理解和修改系统2。奥斯特霍特给了两个症状,原书加了第三个:
- 高依赖性:代码与别的 API 或行为绑在一起。依赖本身不可避免甚至可取,但每个新连接都让代码更难改;它带来紧耦合(模块之间咬得太死)和变更放大(改一处要跟着改一串)3;
- 高隐蔽性:你无法预测一次改动的副作用。「知道得太多的对象、鼓励副作用的全局状态、过度的间接寻址」都是它的症状——晦涩的代码需要更长的时间学习,也更容易被无意破坏4;
- 高惯性(作者加的):软件保持着旧的使用习惯,难以调头。给实验写的、随时可丢弃的代码惯性低;驱动着十几个关键业务应用的服务惯性高。由此得到一条花力气的判据:高惯性、高变化的系统才值得花大力气简化;低惯性或低变化的系统,复杂一点可以忍5。
然后是本章最重要的一句话,值得单独抄:复杂性不总能被消除,但你可以选择把它放在哪里6。向后兼容的代码用起来简单、实现起来复杂;解耦用的间接层降低依赖、却抬高隐蔽性——每一笔都是交易,想清楚再付。
3.2 代码层三原则
原则一:YAGNI(You Ain't Gonna Need It,你不是真的需要它)。 面对未知需求,工程师爱玩两种把戏:猜未来、或提前造「抽象逃生舱门」——原书直接说别玩,两种都会提高复杂性;保持简单(KISS 原则),等需求真清晰了再加复杂度7。YAGNI 的三大违规现场:过早优化(没人用之前就上缓存、分库、队列——优化没被证明需要,复杂度却永久留下);过度灵活的抽象;MVP(最小可行产品,能拿去换用户反馈的最小功能集)之外「顺手」加的特性8。
抽象的代价有一个具体形状。原书给了一个分布式队列接口:看起来极简,只有 send 和 receive 两个方法9:
interface IDistributedQueue {
void send(String queue, Object message);
Object receive(String queue);
}
一旦底层换成了 Kafka(消息带键)或亚马逊 SQS(消息要确认 ACK),这个接口就逼你二选一:做所有特性的并集(得到一个没有任何实现能完整满足的接口)或所有特性的交集(穷得没法用)。原书的裁决:不如直接用某个具体实现,真要换的时候再重构10。
YAGNI 配两个减压阀:蒙茨法(muntzing,反着问——对手里每样东西问「真的需要吗」,其余全扔)让你的软件保持苗条11;以及「接口填充程序」——真怀疑未来要加压缩或加密时,可以在文件格式头里预留一个编码字段但只实现未压缩,将来加算法时旧代码仍能读旧文件:留门,不盖楼12。
原则二:最小惊讶。 接口的行为要像用户最初预期的那样;惊讶就是复杂性的种子。原书点了两类「隐性知识」违规——凡是调用者必须知道、却没写在接口里的知识13:
- 隐藏的排序需求:「方法 A 必须先于方法 B 调用」却不在类型上体现。原书给的修法很具体:让方法自己调子方法(
pontoonWorples()开头先检查if(!flubberized) flubberize())、合并方法、用构建者模式或类型系统——最不济,把方法改名叫pontoonFlubberizedWorples,把知识写进名字里14; - 隐藏的参数需求:签名声称收
int,实际只接受 1 到 10。修法:用能精确表达约束的类型、JSON 字段用 schema 描述;实在不行写进文档15。
原则三:封装领域知识。 按业务领域(会计、计费、运输)而不是技术层(前端/中间层/后端)来组织代码,让「同一个业务的变化」聚集在同一个模块里。这样的代码天然高内聚、低耦合——相关的代码住在一起,彼此独立,变更的「爆炸半径」(一次改动波及的范围)就小16。原书特意点了技术分层的坑:按层分代码,一项业务逻辑的变化要穿过所有层;不同层的团队之间还要协调17。把业务概念对应到软件上,有一整套方法论叫领域驱动设计(DDD)——原书的建议是了解它,但只在最复杂的情况下全面使用18。
3.3 主走查:一个问候服务的三次演进
兼容性是本章的核心技术。先定义(方向感别丢):向前兼容 = 旧服务能接新客户端的请求;向后兼容 = 新服务不破坏旧客户端19。然后拿原书的 gRPC 例子(用协议缓冲区 Protocol Buffers 定义的服务,字段按序号而不是名字传输)走三次演进20:
第 0 版
message HelloRequest { string name = 1; int32 favorite_number = 2; }
演进一:加必填邮箱 → 向后不兼容 ✗
message HelloRequest { ... required string email = 3; }
走查:旧客户端(不知道 email 字段)调用新服务 → email 缺失
→ 服务端解析失败。
(required = 必须提供;不带值就无法解析)
修法:去掉 required、缺了就跳过 → 恢复向后兼容。
演进二:int32 换成 sint32(支持负数的变体,编码方式不同)→ 双向不兼容 ✗✗
走查:新客户端用 sint32 的编码发 favorite_number → 旧服务解析不了;
旧客户端用 int32 的编码发 → 新服务解析不了。
两个方向全断——类型变更比加字段危险得多。
修 法:不动原字段,新增一个字段。
第三次演进(正确姿势):保留旧字段、新增字段,靠字段序号区分21:
message HelloRequest {
string name = 1;
int32 _deprecated_favorite_number = 2; ← 旧字段,序号不动
sint32 favorite_number = 3; ← 新字段,新序号
}
走查:旧客户端仍填字段 2 → 新服务读它(或忽略,或做转换);
新客户端填字段 3 → 旧服务忽略它(不认识就跳过)。
两个方向都活着。监控字段 2 的使用率,客户端升级完再清理。
这一节的后坐力来自一条行业教训:必填字段「必填项永远存在」——一旦有人没填,你永远不能撤掉处理缺值的代码。原书引 Protocol Buffers v2 主要作者肯顿·瓦尔达的话:required 关键字被证明是「一个可怕的错误」,以至于它在 v3 里被整个移除22。
版本化是兼容性的兜底。 兼容做得再好,终有不兼容的那天(比如一个必填的新字段)。版本化你的 API 意味着引入新版本时旧版本继续服务:请求被 API 网关按版本路由,v2 的流量进 v2.x.x 的实例23。版本化有代价——旧版本要维护、bug 修复要回传——所以原书提醒务实:对外部(你控制不了的)客户端,版本化最重要;服务端和客户端都在自己手里时,内部 API 也许不需要24。
3.4 数据层:同一个故事,更长的余波
代码改完就完事,数据却要一直活着。原书点破关系:API 比持久化数据短命——客户端和服务端都升级完,API 的使命就结束了;数据却要随应用一起演进25。于是同样的问题在数据上重演,配方也类似:
隔离。 共享数据库(多个应用共读 共写一个库)会导致丧失自主性——你必须优先考虑别人怎么用,否则改不了 schema;还有「同一个字段各家理解不一样」的分歧、性能互相拖累、安全边界穿洞26。隔离的库只有一个读写者,其余流量走 RPC——改 schema 时你只需要担心自己的应用27。
显式 schema。 「无 schema」数据存储的流行让人以为结构是负担;原书的立场很硬:无模式不等于没有模式——数据总有一种隐含的模式,只是它躲在读取时才能推断的地方28。代价是原书给的「大杂烩」例,三行真实形状的数据29:
{"name": "Fred", "location": [37.33, -122.03], "enabled": true}
{"name": "Li", "enabled": "false"} ← enabled 是字符串?
{"name": "Willa", "location": "Boston, MA", "enabled": 0} ← 又是数字?
三个月后没人说得清 enabled 是什么类型、location 是坐标还是城市名。对照一条显式 schema(SQL 建表语句),写入时就能挡住畸形数据,数据科学家也终于可以放心解析30。原书同样点了一类反面:「把 JSON 字符串塞进叫 data 的字段」是自取灭亡——显式 schema 的痛苦你一分没少,收益一点没拿31。例外也照实写了:快速试错期、旧数据没价值时,无模式少走弯路——和 YAGNI 是同一条逻辑。
自动化迁移。 手动在数据库上执行 DDL(数据描述语言,如 ALTER TABLE)很容易出错。迁移工具(原书演示 Liquibase)把每一次 schema 变更写成带版本号、可回滚的脚本文件,进版本控制、走评审32:
--changeset criccomini:create-users-table
CREATE TABLE users ( ... );
--rollback DROP TABLE users
--changeset dryaboy:add-email
ALTER TABLE users ADD email VARCHAR(255);
--rollback DROP COLUMN email
三条纪律:工具会在数据库里建自己的元数据(关于数据本身的数据)表——看到 DATABASECHANGELOG 不要惊讶33;schema 迁移与应用部署解耦——别把数据库变更绑进应用发布,变更时机要自己可控34;以及回滚是有限的——「回滚删除的列」会重建那列,但不会复活曾经存在里面的数据,所以删除前先备份整表35。
下游兼容。 数据的「客户端」藏在暗处:ETL(抽取-转换-装载,把生产库数据搬运到分析用数据仓库的管道)依赖你的 schema,删一列可能让整条管道停摆36;CDC(变更数据捕获,把每一次插入/更新/删除变成事件消息的架构)里,「members 表的插入」可能就是一封欢迎邮件的触发器——这些消息是一种隐含的 API,不兼容的 schema 变更直接打断别人的服务37。防护两招:变更前跑兼容性检查(提交时查 DDL、集成环境里验证);以及发布数据产品——把内部 schema 对应转换成一个独立的、面向消费者的对外 schema,内部随便演进,对外只承诺兼容38。
4. 作者的判断与证据
- 「高惯性」是作者自己加的第三个症状:原书明确注明「我们要再加上第三个」——这是作者的延伸,不是奥斯特霍特的原典;引用时别张冠李戴。
- 「无模式是自取灭亡」「显式 schema 优于无模式」:作者立场,论证是数据完整性与隐蔽性的机制推演,加一个三行 JSON 的实例;作者同时诚实列出了例外场景。
- required 的教训:引用 Protocol Buffers v2 主要作者的原话与 v3 移除 required 的事实——本章最硬的外部证据。
- Greeter 三次演进:原书自带的示范代码,主走查完全沿用;兼容性判定是规范性行为,不是作者观点。
- 蒙茨法:来自硅谷工程师 Bob Munz 的轶事方法,原书当实用技巧转述。
5. 边界与局限
- 「不写抽象」有反对派:本章反「为未来抽象」,但泛型、插件机制在框架类软件里是产品本身;YAGNI 的适用面是业务应用,不是所有软件。
- 单体 vs 微服务的路线之争只字未提:本章的「隔离数据库」「领域封装」暗合服务化方向,但没有展开服务拆分的判据。
- 数据层只到仓库为止:流处理、数据湖、湖仓一体等现代形态不在书里;CDC 的典型实现(如 Debezium)也没点名。
- 兼容性判定以 Protocol Buffers 为例——JSON/REST 场景的兼容判定(缺字段、类型宽窄)规则略有不同,原书只有一句话带过。
6. 可带走的
- 复杂性三症状自查:改动要联动几处(依赖)?能否预测波及(隐蔽)?这块代码还在高速变化吗(惯性)?
- 复杂性是预算:不能不花,但要选地方花——「用起来简单」和「实现简单」经常二选一;
- 别赌未来:既不预判需求,也不造抽象逃生舱;留门(接口填充程序),不盖楼;
- 接口不许埋隐性知识:排序需求让方法自己搞定,参数需求让类型表达;
- 按业务领域组织代码,把爆炸半径圈小;技术分层适合小系统,业务一多就乱;
- 兼容性口诀:加可选字段安全,改类型双向断,required 是「永远存在」的债;
- 演进 API 的标准动作:旧字段保留、新字段加序号、监控旧字段用量、择期清理;
- 无模式数据的隐含 schema 是定时炸弹;显式 schema 是写给三个月后的自己的文档;
- schema 变更走迁移工具、进版本控制;迁移与应用部署解耦;回滚救不了被删的数据,删前备份;
- 下游有暗客户端:ETL 管道和 CDC 消息都是你的 API,对外用数据产品隔一层。
7. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 复杂性定义 | 第11章 构建可演进的架构 | text/20-ch11.txt:21(搜「难以理解和修改系统」) |
| 三症状 | 第11章 构建可演进的架构 | text/20-ch11.txt:23(搜「高依赖性和高隐蔽性」) · text/20-ch11.txt:24(搜「高惯性」) |
| 变更放大 | 第11章 构建可演进的架构 | text/20-ch11.txt:28(搜「变更放大」) |
| 高隐蔽症状 | 第11章 构建可演进的架构 | text/20-ch11.txt:33(搜「副作用」) |
| 惯性判据 | 第11章 构建可演进的架构 | text/20-ch11.txt:40(搜「新特点」) |
| 复杂性放哪里 | 第11章 构建可演进的架构 | text/20-ch11.txt:46(搜「把它放在哪里」) |
| 两种把戏与 KISS | 第11章 构建可演进的架构 | text/20-ch11.txt:56(搜「逃生舱门」) · text/20-ch11.txt:58(搜「KISS」) |
| YAGNI | 第11章 构建可演进的架构 | text/20-ch11.txt:63(搜「You ain」) · text/20-ch11.txt:79(搜「过早优化是指」) |
| 队列接口两难 | 第11章 构建可演进的架构 | text/20-ch11.txt:97(搜「IDistributedQueue」) · text/20-ch11.txt:106(搜「特性的并集」) |
| 蒙茨法 | 第11章 构建可演进的架构 | text/20-ch11.txt:114(搜「蒙茨法」) |
| 接口填充程序 | 第11章 构建可演进的架构 | text/20-ch11.txt:125(搜「接口填充程序」) |
| 最小惊讶 | 第11章 构建可演进的架构 | text/20-ch11.txt:133(搜「最小惊讶原则」) · text/20-ch11.txt:141(搜「隐藏的排序需求」) · text/20-ch11.txt:168(搜「隐藏的参数需求」) |
| 排序需求修法 | 第11章 构建可演进的架构 | text/20-ch11.txt:151(搜「pontoonWorples」) · text/20-ch11.txt:163(搜「pontoonFlubberizedWorples」) |
| 领域封装 | 第11章 构建可演进的架构 | text/20-ch11.txt:187(搜「高内聚和低耦合」) · text/20-ch11.txt:188(搜「爆炸半径」) · text/20-ch11.txt:192(搜「前端、中间层和后」) |
| DDD | 第11章 构建可演进的架构 | text/20-ch11.txt:200(搜「domain-driven design」) |
| API 小巧 | 第11章 构建可演进的架构 | text/20-ch11.txt:218(搜「小巧的 API」) · text/20-ch11.txt:219(搜「认知负担」) |
| IDL | 第11章 构建可演进的架构 | text/20-ch11.txt:239(搜「interface definition language」) |
| 兼容定义 | 第11章 构建可演进的架构 | text/20-ch11.txt:259(搜「向前兼容的变更 」) · text/20-ch11.txt:262(搜「向后兼容的变更则恰恰相反」) |
| 主走查:Greeter | 第11章 构建可演进的架构 | text/20-ch11.txt:271(搜「SayHello」) · text/20-ch11.txt:298(搜「required string email」) · text/20-ch11.txt:301(搜「向后不兼容的变更」) |
| sint32 双向断 | 第11章 构建可演进的架构 | text/20-ch11.txt:324(搜「sint32 favorite_number」) · text/20-ch11.txt:327(搜「既不向前兼容也不向后兼容」) |
| 新字段演进 | 第11章 构建可演进的架构 | text/20-ch11.txt:331(搜「增加一个新的字段」) · text/20-ch11.txt:344(搜「废弃字」) |
| required 教训 | 第11章 构建可演进的架构 | text/20-ch11.txt:307(搜「可怕的」) · text/20-ch11.txt:311(搜「必填项永远存在」) |
| API 版本化 | 第11章 构建可演进的架构 | text/20-ch11.txt:361(搜「版本化你的 API」) · text/20-ch11.txt:365(搜「API 网关或服务网格」) |
| 数据库隔离 | 第11章 构建可演进的架构 | text/20-ch11.txt:406(搜「丧失自主性」) |
| 无模式真相 | 第11章 构建可演进的架构 | text/20-ch11.txt:454(搜「无模式并不意味着」) · text/20-ch11.txt:465(搜「大杂烩」) |
| 大杂烩例 | 第11章 构建可演进的架构 | text/20-ch11.txt:476(搜「Willa」) |
| 显式 schema | 第11章 构建可演进的架构 | text/20-ch11.txt:486(搜「CREATE TABLE users」) |
| 自取灭亡 | 第11章 构建可演进的架构 | text/20-ch11.txt:502(搜「自取灭亡」) |
| Liquibase | 第11章 构 建可演进的架构 | text/20-ch11.txt:537(搜「liquibase formatted sql」) · text/20-ch11.txt:553(搜「add-email」) |
| 元数据表 | 第11章 构建可演进的架构 | text/20-ch11.txt:570(搜「DATABASECHANGELOG」) |
| 部署解耦 | 第11章 构建可演进的架构 | text/20-ch11.txt:575(搜「生命周期联系在一起」) |
| 回滚有限 | 第11章 构建可演进的架构 | text/20-ch11.txt:594(搜「不会重新创建那些曾经存储」) |
| ETL | 第11章 构建可演进的架构 | text/20-ch11.txt:619(搜「extract transformation load」) |
| CDC | 第11章 构建可演进的架构 | text/20-ch11.txt:632(搜「change data capture」) · text/20-ch11.txt:635(搜「隐含的 API」) |
| 数据产品 | 第11章 构建可演进的架构 | text/20-ch11.txt:644(搜「显式解耦的数据产」) |