2594 字
约 8 分钟
15
S06-A18 · CI 的分层:什么该在提交时做、什么该在合并后做

一条 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. 自测

  1. 四层分工的判据是什么?为什么"10 分钟"是一个关键阈值?
  2. 哪一类检查最容易被错放进 L2,为什么它是"最慢最不稳定"的?
  3. 并行提速的四种手段里,哪一种对大仓收益最大?它的风险是什么?
  4. 为什么"CI 脚本应当可本地运行"?好的失败输出应包含哪四段?
  5. 四种缓存对象里,哪一种风险最高?为什么依赖缓存"必缓存"?
  6. 判断"是否需要分层"的那个问题是什么?
S06-A18 · CI 的分层:什么该在提交时做、什么该在合并后做
https://www.clxhxhhr.top/posts/6535/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。