一条 CI 流水线跑 40 分钟:单元测试 5 分钟、集成测试 20 分钟、构建镜像 10 分钟、部署到预发 5 分钟。结果是"没人等它"——提交后转去干别的,两小时后才回来看,而失败早已发生,改动已经叠了五个。
CI 的问题几乎从不是"跑得不够多",而是该快的地方不快、该慢的地方没慢。这个判据的好处是把"要不要加检查"变成"什么时候加",而不是"加不加"。绝大多数检查都值得存在,只是不该都在同一个时刻跑。
而分层的关键判据是"这个检查失败时,我希望多快知道"。
1. 四层的分工
| 层 | 触发 | 目标耗时 |
|---|---|---|
| L1 提交前 | pre-commit / push | < 1 分钟 |
| L2 PR 检查 | 每次 push | < 10 分钟 |
| L3 合并后 | merge 到主干 | < 30 分钟 |
| L4 定时/发布前 | 每天或发布 | 小时级 |
第一项的意义在于"根本不进 CI":格式、静态检查、单文件单测这类可以本地跑完第一项的成本几乎为零:pre-commit 钩子只跑"改动文件"的检查,几秒钟完成。而它的收益是"不产生一个失败的 CI 记录"——那会给团队一种"CI 常红"的印象。
,节省的是队列与等待。
这个阈值有行为学依据:人在等待中会"顺手做点别的",而切换回来后要花时间恢复上下文。所以 10 分钟不是技术指标,而是注意力指标。
第二项的目标是"10 分钟内给人反馈",因为超过 10 分钟,人就会切换任务,于是反馈的价值急剧下降。
2. 什么该放到哪一层
| 检查 | 层 | 理由 |
|---|---|---|
| 格式/lint | L1 | 秒级,能本地跑 |
| 单元测试 | L2 | 分钟级,反馈价值高 |
| 集成测试 | L3 | 需要容器,分钟到十几分钟 |
| 端到端 | L4 | 慢且不稳定 |
| 镜像构建 | L3 | 可与集成测试并行 |
| 部署预发 | L3/L4 | 依赖前序 |
第四项(E2E)最容易被放进 L2,而它是"最慢最不稳定"的一类第二项(集成测试)的定位要清楚:它测的是"组件之间接得上",而不是"业务对不对"。所以"用真实数据库 + 真实消息"的集成测试应当下沉,而"用内存替身"的可以留在 L2。
:一次网络抖动就让所有 PR 变红,于是团队开始"重跑"而不是"修问题"。
3. 并行是提速的主要手段
| 手段 | 效果 |
|---|---|
| 测试分片 | 线性接近 N 倍 |
| 任务并行(测试/构建/扫描) | 取最长者 |
| 缓存依赖与构建产物 | 省固定开销 |
| 只跑受影响的测试 | 大仓最有效 |
第三项(缓存依赖与产物)是"最容易被忽略的免费提升":依赖下载常占 2–5 分钟且几乎不变,而没缓存时每次 PR 都在等它。
第一项最直接:把测试按文件或包分成 4 片,20 分钟变 6 分钟。代价是要合并报告与处理"跨片依赖"。
第四项在"大仓"上收益最大,但需要"依赖图":改了 A 模块,只跑依赖 A 的模块的测试。第四项在 monorepo 里几乎是必需:一次只改一个包的 20 行代码,却跑全仓 40 分钟测试,很快会让人放弃 PR 前自测。
风险是"漏跑"(依赖图不准时),所以要定期跑全量校验。
// 分片 + 并行 + 缓存的最小结构(示意)
jobs:
unit:
strategy: { matrix: { shard: [1,2,3,4] } }
steps:
- uses: actions/cache@v4 # 依赖缓存,省 2–4 分钟
with: { path: ~/.m2, key: m2-${{ hashFiles('**/pom.xml') }} }
- run: ./mvnw test -Dshard=${{ matrix.shard }}/4
build: { needs: [], runs-on: ..., steps: [ ... ] } # 与 unit 并行
4. 失败的"信息质量"
| 失败表现 | 后果 |
|---|---|
| 只有"exit 1" | 要本地复现 |
| 有日志但无上下文 | 要翻很久 |
| 有失败的用例名与差异 | 可直接修 |
| 有"如何复现"的命令 | 最好 |
第三项(有失败用例名与差异)已经能省一半时间,但**"差异"要具体到"expected/got"**,而不是"断言失败"。前者可立刻判断"是我改错了还是期望过时了"。
第四项是"流水线质量"的分水岭:一条失败信息如果能直接贴到终端复现,修它的成本降一个量级。
这也是"CI 脚本应当可本地运行"的原因:同一段脚本在本地和 CI 都能跑,失败时不需要"猜 CI 环境"。
// 好的失败输出(四段)
FAILED: tests/order_test.py::test_refund_partial
expected 400, got 500
request: POST /api/refund {"order":"O-1","amount":50}
repro: make test-one T=tests/order_test.py::test_refund_partial
// 缺最后一行 → 接手的人要自己找命令
分层的另一个副产品是"失败定位变快":当一条流水线只做一类检查时,红了就知道是哪类问题。而"一条流水线做所有事"时,红了一片还要逐段看日志。
// 单流水线 vs 分层流水线的失败信息
单条:FAILED job build(跑了 lint/test/e2e/deploy)
分层:FAILED job e2e(前 3 层已绿)→ 直接查端到端
// 收益:把"哪一层出问题"从排查变成已知
5. 缓存与"新鲜度"的取舍
| 缓存对象 | 风险 |
|---|---|
| 依赖(m2/npm) | 低(有锁文件) |
| 构建产物 | 中(可能用旧产物) |
| 测试结果 | 高(跳过该跑的) |
| 容器镜像层 | 低(内容寻址) |
第三项最危险:"跳过未变化的测试"在依赖图不准时会漏跑第二项(构建产物缓存)的风险在"跨分支":A 分支的产物被 B 分支复用,而 B 的源码其实不同(缓存键没含源码哈希时)。这类问题表现为"本地改了但 CI 用的是旧包"。
,而漏跑的后果是"合并后才发现"。
第一项是"必缓存":依赖下载常占 2–5 分钟,而它几乎不变。缓存键应当包含锁文件哈希。
还有一类"隐性耗时"容易被算漏:队列等待。它不在"流水线时长"里,而在"从提交到开始跑"之间——而它常常比执行本身更久(尤其是共享 runner 池)。
// 从提交到结果的四段(都要看)
1 排队等待 :0–25 分钟(runner 不足时)
2 检出与缓存:1–3 分钟
3 执行 :5–20 分钟
4 结果通知 :秒级
// 只优化第 3 段时,可能整体只快 20%
6. 排错:五种"CI 拖慢交付"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 没人等结果 | 反馈 > 10 分钟 | 把慢检查下沉 |
| 频繁"重跑" | 不稳定测试 | 隔离或标记 flaky |
| 队列排队很久 | 并发额度不足 | 分片或加 runner |
| 缓存没生效 | 键设计错 | 用锁文件哈希 |
| 同一失败反复出现 | 无 flaky 管理 | 记录并修复 |
第四项(缓存键设计错)的典型是"用了分支名做键":同一分支每次 push 都会命中同一个键,于是缓存永不更新,而"改了依赖却没生效"。正确键应当含锁文件哈希。
第二项的判断很实际:如果同一条检查经常"重跑就过",那它不是"检查",而是噪声。处理方式是"标记 flaky 并单独跟踪",而不是无限重跑。
最后一条"演进"路径:分层不是一次设计完的,而是"随着痛点下沉"。常见顺序是"先加 lint(L1)→ 再把集成测试搬到合并后(L3)→ 最后才做分片与依赖图"。倒过来做(先上分片)通常是过度投入。
// 一个可参考的演进顺序(按投入产出)
1 加 pre-commit(收益立竿见影,成本一天)
2 把 E2E 从 PR 移出(减少 flaky 噪声)
3 加依赖缓存(省 2–5 分钟/次)
4 测试分片(需要改造,收益取决于基数)
5 受影响测试(需要依赖图,最后做)
7. 边界:什么情况不必分层
| 场景 | 为什么 | 做法 |
|---|---|---|
| 小项目(构建 < 5 分钟) | 分层无收益 | 一条流水线 |
| 单人项目 | 无等待成本 | 不必优化 |
| 已有托管平台优化 | 平台负责 | 跟随 |
| 发布频率极低 | 等待不痛 | 不必 |
判断口径一句话:"从提交到知道结果"要多久,而这个等待有没有人在真的等。最后一条与"团队习惯"有关的边界:分层的前提是"有人会在 10 分钟内看结果"。如果团队习惯"下班前统一看",那提速的收益主要不在"等人",而在"减少合并冲突"——收益仍在,但优先级不同。
有人在等(10 分钟内会看),就该分层提速;没人等(一天发一次),慢一点无妨。
8. 自测
- 四层分工的判据是什么?为什么"10 分钟"是一个关键阈值?
- 哪一类检查最容易被错放进 L2,为什么它是"最慢最不稳定"的?
- 并行提速的四种手段里,哪一种对大仓收益最大?它的风险是什么?
- 为什么"CI 脚本应当可本地运行"?好的失败输出应包含哪四段?
- 四种缓存对象里,哪一种风险最高?为什么依赖缓存"必缓存"?
- 判断"是否需要分层"的那个问题是什么?