专业书籍精读 · 持续交付 · 第 7 章
Continuous Delivery · Ch 7 · Jez Humble & David Farley · 2010
你手机里那个 App,背后的程序员平均一天要往共用的代码库里交好几次「作业」。《持续交付》第 7 章讲的是:作业刚交上来的那几分钟里,该做一次什么样的检查。这道检查叫提交阶段,是上一章那条流水线的第一关,也是唯一一关每个程序员每天都要亲自面对。
把它想成医院的分诊台。人一进急诊,护士先花五分钟量体温、血压、心跳——不拍片、不动刀,只干一件事:用最快最便宜的手段,把明显有问题的挑出来。真正贵的检查排在后面,而且只留给通过分诊的人。
反直觉的地方在这儿:分诊台的价值不在于查得全,而在于快到你愿意站在那儿等结果。一个查得特别全但要等两小时的分诊台,等于没有。
这道检查有三种失败方式,都要命。太慢:所有检查堆在一起要跑两个钟头,于是没人等——你交完就去干别的,两小时后报告红了,你早忘了刚才改过什么。没用:报告只丢一句「失败了」,不说哪儿错、怎么重现,大家慢慢学会无视它。会说谎:有些检查时灵时不灵,同一份代码这次红下次绿;几轮之后,团队对红灯的第一反应从「快去修」变成「再跑一次试试」——这道关自此作废。
第一,给它一个时间预算:十分钟。这个数不是拍脑袋来的——它大致是人愿意站在原地等结果的极限。超过了,人的行为就变了:不等结果继续往下干,几个人的改动叠在一起,红灯归谁都查不清。所以十分钟是一条设计约束,不是一个性能指标。
第二,只做又快又准的检查。什么最慢?连数据库、点界面、等一等再看结果。这一章大半篇幅在教你怎么把这三样赶出去:给被测的那小块代码换上临时替身——假的数据库、说停就停的假时钟——于是它能在千分之一秒里跑完,一口气跑几千个也不过几秒。
第三,过关了就把成品封箱贴签存起来。这一箱之后一路往下走,谁都不许再重做一箱;检查报告也一并留档,全组随时能看。
难点其实不在检查,在被检查的代码。一小块代码要能装上替身单独测,它就得和外面的东西分得开。分不开的老代码,你会发现根本写不出快测试——这也是这一章藏着的一句话:测试难写,多半不是测试的问题,是设计在报警。
当然它有代价:这道关跑得快,正是因为它没碰真实的数据库、真实的网络,所以「绿了」只代表没有明显砸锅,不代表能上线——那要靠后面又慢又贵的关卡。
提交阶段是流水线的第一道关:十分钟内给出一个可信的红或绿。它不求查得全,只求快到你肯等、准到你肯信;查得全那部分,留给后面。
想看具体该放什么、十分钟怎么算出来的、替身怎么选? → 切到精读版
提交阶段(commit stage)是部署流水线的第一道关:每次提交自动触发,几分钟内编译、跑提交测试、做静态分析、打包出制品,然后给出一个红或绿的判决。它是整条流水线上唯一一道每个开发者每天都要亲自面对的关卡,所以这一章真正的主题不是「测什么」,而是怎么让这道关快到人肯等、准到人肯信——十分钟不是性能指标,是一条行为约束:一旦超过,开发者就不再等结果,持续集成的纪律会连锁崩塌。
new 出来——这样测试时才换得成替身。本章在 Part II「部署流水线」里紧跟 Ch5:Ch5 画出了从提交到生产的整条传送带,本章放大它的第一格。往上,它继承 Ch3 持续集成的纪律(每天并回主干、红了停线)和 Ch4 测试策略的四象限(提交阶段承接「支持开发的技术面测试」那一格);往下,Ch8 接手自动化验收测试。现实里它对应的是你每天打交道的东西:GitHub Actions 里那串 PR check、GitLab CI 的第一个 stage、Gerrit / Phabricator 的 pre-submit、Google 的 TAP、Meta 的 Sandcastle——你每天等的那几分钟转圈,讲的就是这一章。
Ch5 已经说清「越靠前的关卡越要快」,但没说这一关具体该长什么样。而这一关有个特殊身份:它是开发者与整条流水线唯一的日常接口。后面那些验收测试、容量测试、生产发布,多数开发者一周也未必看一眼;提交阶段是他们一天要面对好几次的东西。所以它一旦体验糟糕,被绕过的不是这一关,而是整套持续交付纪律。
糟糕有三种具体形态,本章逐个点名:
不解决会退回 Ch1 那个旧世界:缺陷在代码库里躺上几周才被发现,写它的人早已忘了上下文,定位成本翻好几倍;或者反过来,团队被一套跑两小时还老抖的检查拖死,最后集体决定「先绕过去」。
定义很干净。输入:版本控制里的一次提交。做的事:编译 → 跑提交测试 → 做静态分析 → 把可部署的制品打包出来(必要时还包括建库表、生成安装包)。出口只有两个:要么红——立刻通知全组,按 Ch3 的纪律停线先修;要么绿——把制品、报告、元数据一起存进制品库,成为一个候选版本,继续往验收阶段走。
三样输出里最容易被忽略的是元数据:这个制品来自哪次提交、跑过哪些测试、结果如何。它是后面各阶段「晋级(promotion)」判断的依据,也是出事后一路查回去的绳子。
本章反复回到同一个数:提交阶段应当在十分钟内跑完(Ch3 的持续集成纪律给的也是这个数)。为什么是十分钟而不是二十?因为这个阈值管的不是机器,是人的行为:
这也解释了本章为什么拒绝把「所有测试」都塞进来:它不是要证明这版是对的,而是用最便宜的手段把明显是错的挡在门外。
一个纯内存的单元测试通常在毫秒级;一个每次都要连数据库、建表、清表的测试,单条轻松到50~100毫秒;一个通过真实界面点击的端到端测试,单条几秒到几十秒。乘以数量,差距就是数量级:
于是本章给出一串很具体的写法约束,全都服务于同一个目的——把每条测试的单价压到毫秒级:
sleep——本章把这类硬扛的做法列为最后手段。上面这些约束的共同前提是:被测的那块代码,得能把它的协作者换掉。这就是依赖注入的用武之地——协作者从构造函数或参数传进来,测试时就能塞一个替身进去。
本章对替身也留了一句提醒,这句今天更值钱:替身用过头,测试就被焊死在实现上。stub 只是喂返回值,测的仍是「输出对不对」;mock 却在断言「你必须按这个顺序调这几个方法」——你一重构内部实现(哪怕行为没变),这类测试就成片变红。测试是为了让你敢改代码;反过来让你不敢改,它就站到了自己的反面。
编译失败、提交测试失败,毫无争议地红。有争议的是静态分析:覆盖率跌破阈值、重复代码超标、圈复杂度飙升、新增警告——这些要不要让构建失败?
本章的立场是可以,而且往往应该——把团队认可的阈值编码进构建自动执行,好过写在 wiki 上没人看。但有个重要限定:这些指标是趋势指示器,不是目标本身。覆盖率尤其危险:它只告诉你哪些代码没被执行过,不告诉你被执行的那部分测得好不好——把所有断言删掉,覆盖率纹丝不动。所以它适合用来发现明显空白(某个新模块 0 覆盖),不适合当 KPI 追高。
绿灯之后的产物必须被妥善收好,这正是 Ch5「只构建一次」的落地点。制品不进版本控制——它是从源码派生出来的东西,塞进 Git 只会让仓库爆炸;用专门的制品库存放,用修订号命名,任何时候都能从生产上的一个包一路查回那次提交。报告(测试结果、覆盖率、静态分析)同样是一等公民产物,要放在全员点得开的地方——让分析结果可见本身就有价值,哪怕暂时不用它卡构建。
最后一条常被跳过,却最有组织意味:提交阶段属于开发团队,不属于「构建团队」。改动代码的人才知道该加什么检查、红灯意味着什么;交给一个不写业务代码的小组维护,结果必然是脚本跟不上代码,开发者也不再觉得红灯与自己有关。与之配套的是把构建脚本当一等公民代码:进版本控制、有人重构、有人评审。人数很多的团队可以设一个轮值的构建管家盯红灯、催修复、养脚本——注意是轮值,为的是分担而不是外包责任。
表 1 · 一项检查该放提交阶段,还是往后推?
| 检查 | 典型耗时 | 能拦住什么 | 放哪 | 代价 / 理由 |
|---|---|---|---|---|
| 编译 · 打包 | 秒 ~ 1 分钟 | 连编都编不过的改动 | 提交阶段 | 无争议,最便宜的一道 |
| 单元测试 | 单条毫秒级 | 业务逻辑回归 | 提交阶段 | 要求代码可注入替身;老系统改造成本高 |
| 静态分析 | 秒 ~ 分钟 | 可疑写法、复杂度与重复度失控 | 提交阶段 | 阈值定太严会天天误报,团队会学会无视 |
| 少量冒烟级集成检查 | 数十秒 | 组件接不上、配置写错 | 提交阶段(严格限量) | 最容易越界膨胀的一类,要主动设上限 |
| 数据库集成测试 | 单条 50~100 毫秒 | SQL / 映射 / 迁移脚本的错 | 次级阶段或验收阶段 | 放进提交阶段就是拿预算换保真度 |
| 端到端 / 界面测试 | 单条数秒起 | 整条业务流程走不通 | 验收阶段(Ch8) | 慢且脆,进提交阶段必然拖垮它 |
| 性能 / 容量测试 | 小时级 | 吞吐与延迟退化 | 后段独立阶段 | 需要基线与专用环境 |
表 2 · 什么信号让构建变红,什么只警告
| 信号 | 建议 | 理由 | 风险 |
|---|---|---|---|
| 编译失败 / 测试失败 | 硬红 | 行为已被证伪,没有商量余地 | —— |
| 不稳定测试 | 隔离并限期修 | 留着它会让全队学会无视红灯 | 隔离区变成垃圾场,缺陷从此漏网 |
| 覆盖率跌破阈值 | 可红,但阈值由团队定、只防倒退 | 能发现「整块新代码没测」这类空白 | 当 KPI 追高 → 写出无断言的假测试 |
| 重复度 / 圈复杂度超标 | 先可见,稳定后再卡 | 指标是趋势指示器,不是目标 | 一上来就卡,会逼出规避写法 |
| 新增编译器警告 | 推荐硬红(存量另设基线) | 警告不卡就会无限累积 | 存量太多时一刀切会瘫痪团队 |
表 3 · 提交测试怎么对付数据库
| 方案 | 单条耗时 | 保真度 | 放在哪 | 代价 |
|---|---|---|---|---|
| 不碰数据库(纯逻辑 + 替身) | 微秒 ~ 毫秒 | 低:测不到 SQL 与映射 | 提交阶段主力 | 要求持久化与业务逻辑分层清楚 |
| 内存数据库 | ~1 毫秒 | 中:方言与真实库有差异 | 提交阶段(谨慎) | 差异处会给你假绿灯 |
| 容器起一个真实库 | 数十毫秒(另加启动开销) | 高 | 次级 / 验收阶段 | 2010 年昂贵,今天便宜——但预算约束没变 |
| 共享的公用测试库 | 不定 | 中 | 不推荐 | 多人互相污染,失败依赖执行顺序 |
表 4 · 测试替身怎么选
| 替身 | 它干什么 | 适合 | 代价 |
|---|---|---|---|
| stub | 被问到就返回预设值 | 喂输入:时钟、配置、只读查询 | 几乎没有,首选 |
| mock | 断言「被调用过、调了几次、什么参数」 | 验证副作用:扣款、发信、发消息 | 把测试焊在实现上,重构即成片变红 |
| fake | 有真实行为的轻量实现(内存仓储) | 需要状态的场景,写起来最自然 | 自己也是代码,要维护、也会与真品漂移 |
| 真实对象 | 不替 | 纯计算、无 I/O 的协作者 | 一旦带 I/O,速度与稳定性立刻塌 |
表 5 · 提交阶段超过十分钟了,怎么办
| 办法 | 效果 | 代价 / 前提 |
|---|---|---|
| 并行分片到多台机器 | 近线性缩短,最直接 | 要求测试之间无共享状态;花机器钱 |
| 把慢测试挪到次级阶段 | 提交阶段立刻回到预算内 | 反馈变慢,挪过去的那些容易没人管 |
| 拆分应用与流水线 | 各组件独立跑,范围变小 | 引入跨组件版本组合问题(Ch13) |
| 按影响范围选测试 | 只跑与本次改动相关的测试 | 需要依赖图 / 构建系统支撑,选漏就是放行缺陷 |
| 删掉重复与低价值测试 | 常有惊人收益 | 要有人真的看数据,而不是凭感觉删 |
| 直接放宽到 30 分钟 | 看起来省事 | 人不再等结果,整套 CI 纪律随之瓦解 |
五张表背后是同一条判据:提交阶段的每一秒都要用「它平均能提前多久发现问题」来买。一项检查如果既慢又只能发现罕见问题,它就不该待在这道关里——但也别把它丢掉,往后放一格。
形态换了,逻辑一模一样。GitHub Actions 里挡在合并按钮前的那串 required check、GitLab CI 的第一个 stage、Gerrit 的 pre-submit verify、Bazel 的 small 测试与远端缓存、Google 的 TAP 与 Meta 的 Sandcastle——都是提交阶段的工程化实现。制品库(Nexus、Artifactory、容器镜像仓库)的存在理由就是本章说的「输出制品 + 元数据」;merge queue(合并队列)则补上了本书没展开的一环:多人并发提交时,逐个把改动放在合并后的状态上再跑一遍,避免两个各自绿灯的改动合到一起变红。
面试和架构评审里这几乎是标准考题。被问到「你们 CI 怎么做的」,能拿分的答法不是报工具名,而是四个事实:提交阶段几分钟?什么会让它变红?不稳定测试怎么处理、隔离区里现在有多少个?红了之后谁负责、平均多久修绿?
① 提交阶段是流水线第一关,也是唯一一关每个开发者每天都要面对——它的体验决定整套持续交付纪律能不能活下来。
② 一个输入(一次提交),两个出口:红灯停线,或绿灯输出制品 + 报告 + 元数据进制品库,成为候选版本。
③ 十分钟是行为约束不是性能指标:超过它,人就不等结果了,红灯归属与批量大小同时失控。
④ 时间预算的算术:单测毫秒级、碰数据库百毫秒级、走界面秒级——只要让每条测试碰一次真库,五千条就吃掉大半个预算。
⑤ 提交测试的写法约束:避开界面、避开数据库、避开异步、伪造时间、最小化状态,前提是用依赖注入把协作者换成替身。
⑥ 替身要克制:stub 测输出、mock 测副作用;mock 用过头会把测试焊在实现上,重构即成片变红。
⑦ 什么该红:编译与测试失败必红;静态分析阈值可以红,但覆盖率是指示器不是目标——它只说明哪些代码没被执行过。
⑧ 不稳定测试是这道关的头号杀手:不可信的红灯等于没有红灯;Google 公开承认其约 1/7 的测试有不同程度的不稳定。
⑨ 这道关属于开发团队,不属于构建团队;构建脚本是一等公民代码,超大团队用轮值的构建管家。
⑩ 撑不住了的正解是并行、拆分、往后挪、按影响选测(Meta 只跑三分之一仍抓住 99.9% 回归),而不是把预算从十分钟放宽到三十分钟。