专业书籍精读 · 持续交付 · 第 13 章
Continuous Delivery · Ch 13 · Jez Humble & David Farley · 2010
你手机里的 App 差不多每周都能更新一版。可写它的不是一个人,是几十上百号人同时在改同一堆代码。《持续交付》第 13 章问的就是:一群人一直在动手术,怎么让这套东西每天都处在「随时能发出去」的状态?答案有两半——大改造怎么在不停工的前提下做完,以及代码大到一个人构建不动时,怎么切开、切开之后又怎么拼回去。
想象你要给一家天天照常营业的餐厅换掉整个后厨。不能在门口贴张「装修中,歇业三个月」——客人早跑光了。这一章讲的就是这两件事:怎么一边营业一边换厨房;以及厨房大到一个班组转不开时,怎么拆成几个独立的小档口,拆完又怎么保证一桌菜还能配齐、还能一起上。
老办法是闭关:拉一支队伍单独去改,改三个月再合回来。听着挺合理,实际是场灾难——这三个月里别人也没闲着,等你回来,两边的改动早就对不上了。光「把两份对不上的东西并回一份」就要熬好几个通宵,而且并完谁也不敢拍胸脯说还能跑。分开得越久,合回去越痛,而且不是多一倍的痛,是好几倍。
既然「整个拿走再装回来」这么痛,那就别拿走。做法是先在老厨房前面砌一道传菜窗口:前厅从此只对着窗口点菜,不关心窗口后面到底是谁在做菜。窗口砌好之后,你就可以在它后面不慌不忙地搭新厨房——这期间餐厅照常营业,老厨房照常出菜。等新厨房能出全部的菜了,把窗口接过去;跑一阵子确认没问题,再把老厨房拆掉。
妙就妙在:整个过程里,这家店没有哪一天是「装修中」的。每走完一小步,第二天都能照常开门。
厨房太大转不开时,就拆成几个自负盈亏的小档口,各自备料、各自出餐。好处很实在:改一个档口的菜单,不用惊动全店。
代价是没人管一桌菜配不配得齐了。更麻烦的是「螺丝对不上」:A 档口的菜谱写着要用「张师傅酱料 2.0」,B 档口写着要 1.0,可后厨架子上只能摆一瓶。这就是程序员天天在骂的依赖地狱。这一章给的规矩很朴素:菜谱上必须写死是哪一版酱料,绝不能写「用张师傅家最新的那瓶」——否则今天做得出的菜,明天就莫名其妙做不出来了,而你什么都没改。然后另设一个传菜台,专职核对「这几个档口、这几个版本,凑在一起是不是真能出一桌完整的菜」。
拆成小档口以后,整个后厨忙活的总时间通常是变多、而不是变少的——你换到的只是「改一个档口不用惊动全店」,多出来的那份核对成本总得有人付。所以这一章反复劝:能不拆就先别拆,等真的转不开了再拆。
要让一套天天有人改的软件随时都能发出去:大改造别闭关,先在原地砌一道「传菜窗口」慢慢换;代码大到转不开才拆档口,而且拆完必须写死每个零件的版本,再加一道专门核对「这几版凑一起行不行」的关卡。
想进到具体机制、记号和示意图? → 切到精读版
你以为把应用拆成组件是为了「架构优雅」或者「方便复用」,这一章给的首要理由现实得多:为了让构建和反馈重新回到十分钟以内。但它同时把代价摆在桌上——拆开的那一刻,你就丢掉了持续交付最值钱的那个性质:一次提交、一次全量验证。于是本章的下半场全在教你怎么用依赖图 + 制品库 + 一条集成流水线,把这个性质重新买回来。
2.4.1 的版本号约定,三段依次表示不兼容改动 / 新增功能 / 修补。本章属 Part III「交付生态」,排在第 11 章基础设施、第 12 章数据之后,是全书把「随时可发布」这条主张往代码结构里推的一章。上承第 3 章(持续集成:主干必须一直可构建)与第 7 章(提交阶段的十分钟预算),下启第 14 章的版本控制与分支策略。对应现实里的问题:单体仓库还是多仓库、Maven / Gradle / npm 的版本怎么钉、微服务之间的版本组合到底谁来验。
持续交付的全部主张压成一句就是「主干随时可发布」。到第 13 章,这句话撞上了两堵墙。
第一堵墙是大改造。换掉一套 ORM、替换支付网关、把自研认证换成 OAuth——这类活动辄两三周甚至几个月,期间代码长时间处在半拉子状态。传统做法是拉一条特性分支闭关,回头再合。可合并的痛苦随分支存活时间超线性增长:存活一周的分支合起来是十几分钟的事;存活三个月的分支,合起来是几个人熬几天,而且合完没人敢保证还能跑。这等于把第 1 章骂过的「发布日地狱」原样搬回到了代码层。
第二堵墙是构建时长。第 7 章给提交阶段定的预算是十分钟——因为一旦超过十分钟,人就会开始「攒一批再提交」,持续集成随之名存实亡。可代码库是会长大的:一个几十万行、几千个测试的单体,全量构建从八分钟涨到四十分钟往往只需要一两年。到那时开发者一天只能拿到三五次可信反馈,第 3 章那套纪律就地失效。
不解决会怎样?两条路通向同一个终点:主干不再随时可发布——要么因为长期分支永远停在「快好了」,要么因为没人愿意等的构建让所有人都学会了绕开流水线。
本章开篇给的不是拆分方案,而是三条不动结构就能保住可发布性的做法。
① 未完成的功能先藏起来。代码可以合进主干、可以跟着发布出去,但入口不接上——对用户而言它就是一段永远跑不到的代码。这样「功能没做完」就不再是「不能发布」的理由。
② 一切改动增量化。把一次大改拆成一串小改,判据很硬:任意一步做完之后停下来发布,系统都不该出事。做不到这一点的拆法,就还不算真正拆开。
③ 抽象分支(branch by abstraction)。这是本章最实用的一招,名字出自 Paul Hammant,本书作者 Jez Humble 后来还专门撰文讲过它。
要在不拉版本库分支的前提下替换掉一个大部件,走五步:① 在待替换的部分之上建一层抽象;② 把系统其余部分改成只依赖这层抽象(这一步往往最费工,也最有价值);③ 在抽象层之下写新实现,与旧实现并存;④ 把抽象层指向新实现,必要时按用户或流量灰度切换;⑤ 确认无误后删掉旧实现——脚手架用完就该拆,很多时候连抽象层本身也一并删掉。
反直觉的地方在于:「分支」这个动作被从版本库挪进了代码。版本库里始终只有一条主干,所有人每天都在集成;两套实现的并存关系改由抽象层承载,而抽象层是可编译、可测试、可发布的——不像版本库里的长期分支,它的「合并风险」被摊薄进了每一次提交。
代价也得说清楚:过渡期里代码库同时养着两套实现,读起来更绕、测试要跑两遍;而且抽象层若只覆盖了旧实现的一部分用法,剩下那些调用点就成了绕过抽象层的暗道,整个方案随即失效。
依赖分两种:构建期依赖(编译时就得有,比如一个工具库)和运行期依赖(跑起来才需要,比如数据库驱动)。麻烦几乎都出在版本上。
书里点名的病叫 依赖地狱(dependency hell)——在 Windows 上叫 DLL hell,在 Java 世界叫 JAR hell。最典型的形态是菱形依赖:你的应用同时用了组件 A 和组件 B,A 要求某个库的 1.0,B 要求同一个库的 2.0,而运行时的类加载器(或 DLL 加载器)只能装一份。两个版本 API 不兼容时基本无解,只能有一方让步——要么降级、要么改代码适配、要么想办法做隔离加载。
对策是把库管起来。书给了两条都能接受的路:把二进制直接签进版本库的 lib/ 目录(简单、构建绝对可重复,代价是版本库变胖、升级全靠手工),或者用声明式依赖 + 制品库(Maven / Ivy 那一套,升级方便,代价是引入了版本库之外的状态)。但无论走哪条,本章有一条不容商量的规矩:版本必须钉死,绝不用「取最新」这类动态版本。理由不是洁癖——动态版本让构建不可重复:同一份代码今天绿、明天红,而你什么都没改,排查成本高得离谱。
书对「组件」的定义大意是:应用内部一块规模不小、有明确 API、理论上可以整个换成另一套实现的代码结构。它比一个类大,又比一个独立部署的服务轻。
值得拆的理由,本章列得很克制:构建 / 测试太慢;不同部分的变更速率差异很大(一年不动的报表模块和一周三改的下单模块,没必要每次一起构建);确实要在多个产品之间复用;团队大到需要清晰的所有权边界;某部分需要独立部署或独立伸缩。书明确反对为了「架构好看」而拆,态度是:先用一条流水线撑着,撑不住了再拆,而且一次只拆出真正必要的那几块。
拆完之后,流水线的形状变了:每个组件一条自己的流水线,产出带版本号的二进制进制品库;下游组件的流水线从制品库取上游制品来构建;最后由一条集成流水线选定一组具体版本(比如 A 的 12 号、B 的 7 号、C 的 3 号),装配起来跑验收测试。这里有一条硬约束:依赖图必须无环。
触发策略是个真问题:上游一绿就触发所有下游,几十个组件的图会被一次改动引爆成一场构建风暴;可要是完全不自动触发,破坏性改动就会一直藏到有人手工升级的那天才炸——而那时改动早已堆了几十个,谁也说不清是哪个弄坏的。
本章对上面那个两难给出的答案叫 谨慎的乐观(cautious optimism):别把「钉死」和「跟最新」当成二选一,而是给依赖图的每一条边三种状态。
诚实地补一句:这套机制很少有开箱即用的实现。2010 年基本得靠构建工具自己搭,今天也主要靠 CI 平台上的自定义逻辑拼出来——它更像一个该被记住的设计理念,而不是一个能直接打开的开关。
循环依赖(A 依赖 B、B 又回头依赖 A)被书称为最难缠的依赖问题。多数构建工具会直接拒绝;真要临时救场,只能靠构建阶梯(build ladder)——先用旧版 A 构建出 B,再用这个 B 构建出新版 A,一级一级踩上去。但书说得很直白:这是权宜之计,正确做法是把环重构掉,通常是把双方都依赖的那部分抽成第三个组件。
制品库则是第 5 章「只构建一次」原则在组件化世界里的落点。它该做的事:存住每次构建产出的二进制、按版本可检索、记住每个制品对应哪一次提交,并且在制品从测试环境晋级到生产时只打标、不重新构建。反过来,二进制不该签进版本控制——它可以由源码重新生成,而版本库不是为大文件设计的。
表 1 · 做一次大改造:三条路怎么选
| 长期特性分支 | 抽象分支 | 隐藏功能 / 开关 | |
|---|---|---|---|
| 适用 | 改动会破坏主干、且团队接受停摆 | 替换一个已有大部件(换 ORM、换网关) | 新增一块尚未完成的功能 |
| 主干可发布性 | 分支上不可发布,主干与它渐行渐远 | 每一步之后都可发布 | 始终可发布(入口不接上) |
| 合并风险 | 随存活时间超线性增长;三个月的分支合起来常要几人几天 | 摊薄进每次提交,接近于零 | 无 |
| 代价 | 合并地狱、集成问题一次性爆发 | 过渡期两套实现并存,抽象层要覆盖全部调用点,用完要拆 | 开关会积累成技术债,要有清理纪律 |
表 2 · 库依赖怎么管
| 做法 | 好处 | 代价 | |
|---|---|---|---|
| 二进制签进版本库 | 把 JAR / DLL 放进 lib/ 一起提交 | 构建绝对可重复,签出即能构建,无外部依赖 | 版本库变胖;升级全靠手工;不适合依赖多的项目 |
| 声明式依赖 + 制品库 | Maven / Ivy 声明坐标,从 Nexus / Artifactory 取 | 升级方便、传递依赖自动解析 | 引入版本库之外的状态;制品库挂了就构建不了 |
| 钉死版本 | 写 1.2.3 | 构建可重复——同一份源码永远得到同一个结果 | 安全补丁与上游改进滞后,需要主动升级机制 |
| 动态版本 | 写 LATEST / ^1.2 之类 | 自动跟上游,省心 | 本章明确反对:构建不可重复,代码没改也会今天绿明天红 |
表 3 · 多组件的版本策略(本章的灵魂表)
| 全量一起构建 | 静态钉死 | 流动 | 谨慎的乐观 | |
|---|---|---|---|---|
| 怎么工作 | 所有组件都取主干最新源码,一次构建全部 | 每条依赖边写死版本,人工升级 | 每条边永远取上游最新的绿版本 | 默认流动,出事的边自动退回并冻结 |
| 集成延迟 | 零 | 大——可能几周才升一次 | 零 | 接近零 |
| 破坏性改动何时暴露 | 提交当时 | 升级的那一天,且已堆积几十个改动 | 提交当天 | 提交当天,且不阻塞其他人 |
| 构建成本 | 最高——每次都全量,靠增量与远端缓存才撑得住 | 低 | 中,可能被上游改动引发构建风暴 | 中 |
| 典型现场 | Google / Meta / 微软的单体仓库 | npm / Maven 生态 + lockfile | 组件少、团队互信度高的内部系统 | 理念领先,少有现成工具,多靠自研 |
表 4 · 该不该拆成组件
| 信号 | 该拆吗 | 理由 / 代价 |
|---|---|---|
| 提交阶段超过十分钟 | 该 | 本章给的首要理由:反馈慢会直接毁掉持续集成的纪律 |
| 不同部分变更速率差异大 | 该 | 一年不动的模块没必要跟着一周三改的模块一起构建 |
| 要在多个产品间复用 | 该 | 复用需要稳定的 API 与独立的版本线 |
| 某部分要独立部署 / 独立伸缩 | 该 | 这是通往微服务的那条路,代价是分布式的全部麻烦 |
| 「架构看起来更清晰」 | 不该 | 书明确反对;模块化可以靠包与接口,不必付出多流水线的代价 |
| 团队只有几个人 | 不该 | 拆五个组件 = 五套流水线与版本协调,收益远抵不上开销 |
今天吵得最凶的 单体仓库(monorepo)vs 多仓库之争,本质就是表 3 的现代版:一端是「所有组件都取最新源码、一起构建、一起验证」,另一端是「每条依赖边钉死版本、各自演进」。这一章在 2010 年就把两端的代价算清楚了,只是当时还没有这两个词。
三处该更新的地方也很清楚。其一,Bazel / Gradle 的增量构建与远端缓存把「构建太慢所以要拆组件」这个首要理由部分抽掉了——今天你完全可能守着一个巨大的仓库,同时拿到十分钟内的反馈。其二,lockfile 生态(package-lock.json、Gemfile.lock、go.sum)是「钉死版本」的工业化答案,而 Dependabot / Renovate 这类自动升级机器人是「流动」的工业化答案,两者合起来其实相当接近本章的「谨慎的乐观」。其三,微服务把组件推到了「独立部署单元」,本章的依赖图问题于是原样变成了服务契约问题——消费者驱动的契约测试,干的正是集成流水线那份活。
20 亿行代码、86 TB 内容、每天约 4 万次提交,靠自研的 Piper 与统一版本管理支撑;他们给出的首要理由之一正是「简化依赖管理」——所有人都用同一个版本,菱形依赖从源头消失。R. Potvin & J. Levenberg《Why Google Stores Billions of Lines of Code in a Single Repository》, CACM 2016 ↗300 GB、350 万个文件,每天约 8,421 个 PR 与 1,760 次正式构建——大到必须专门造一个虚拟文件系统(GVFS)才能让 Git 撑住。「一起构建」不是免费的。Brian Harry「The largest Git repo on the planet」, Microsoft DevBlogs 2017 ↗LATEST / ^1.2 看着省心,实际让构建不可重复。但反过来全部钉死也有代价——安全补丁滞后。现代答案是 lockfile 保证可重复 + 自动升级机器人保证不落后,这是 2010 年的书里还没有的组合。① 一句话:本章把「主干随时可发布」这条主张推进到代码结构——先教你不拆结构也能做大改造,再教你不得不拆时怎么把「一次提交、一次全量验证」买回来。
② 两堵墙:大改造(长期分支的合并痛苦随时间超线性增长)与构建时长(超过十分钟,持续集成就名存实亡)。
③ 不动结构的三招:藏起未完成功能、一切改动增量化、抽象分支。
④ 抽象分支五步:建抽象层 → 其余代码只依赖它 → 层下写新实现 → 切过去 → 删旧实现与脚手架。本质是把「分支」从版本库挪进代码。
⑤ 依赖地狱的典型形状是菱形依赖:两条路径要求同一个库的不同版本,而运行时只能装一份。铁律是钉死版本,绝不用动态版本——否则构建不可重复。
⑥ 组件=规模不小、有明确 API、理论上可整体替换的代码结构。该拆的理由是构建慢、变更速率差异大、复用、独立部署;「架构好看」不是理由,能不拆就先别拆。
⑦ 拆完的形状:每组件一条流水线 → 制品库 → 一条集成流水线验版本组合;依赖图必须无环,循环依赖只能靠构建阶梯临时救场、正解是重构掉。
⑧ 谨慎的乐观:依赖边分静态 / 流动 / 守卫三态,守卫态在上游弄红下游时自动退回上一个已知良好版本——把二选一变成会自愈的开关。
⑨ 制品库是「只构建一次」在组件世界的落点:存二进制、可按版本检索、能追溯到提交、晋级只打标不重建;二进制不签进版本控制。
⑩ 现代映射:单体仓库 vs 多仓库之争就是表 3 的今日版;lockfile + 自动升级机器人 ≈ 谨慎的乐观;微服务把依赖图问题变成了服务契约问题。