52.1 构建与缓存
一句话
构建优化的目标不是“让某一步更快”,而是缩短关键路径:缓存消除重复工作、分片把串行变并行、远程缓存让第二个人不用重算;而命中率的收益在 90% 之后急剧衰减,别为了最后 1% 投入十倍成本。
关键路径,不是总耗时
一次 CI 的步骤构成有向无环图(DAG):
checkout(0.5) → deps(3) → compile(4) → ┬ unit(6) ┬ → package(1) → deploy
└ integration(5) ┘
总耗时 = 所有步骤之和;流水线耗时 = 最长路径。所以优化的第一步不是“每个步骤都快一点”,而是:哪些步骤能并行、哪些能缓存、哪些能跳过。
| 优化手段 | 作用对象 | 典型收益 |
|---|---|---|
| 依赖缓存 | deps | 3 分钟 → 0(命中时) |
| 编译缓存 | compile | 4 分钟 → 0.4(90% 命中) |
| 测试分片 | unit | 6 分钟 → 1.8(4 片) |
| 测试与集成并行 | unit/integration | 11 → 6 分钟 |
| 远程缓存 | 所有人 | 第二个人不再重算 |
数字:19.5 分钟 → 6.5 分钟
设定:200 次构建/天,runner 单价 $0.008/分钟(示例)。
| 场景 | 关键路径 | 每日机时 | 月成本 |
|---|---|---|---|
| 冷构建(无缓存、串行) | 19.5 min | 3900 min | $936 |
| 依赖缓存 100% + 测试分片 + 串并行 | 6.5 min | 1300 min | $312 |
| 缓存命中 90% | 7.2 min | 1440 min | $346 |
| 缓存命中 50% | 10.0 min | 2000 min | $480 |
三个结论:
- 缓存 + 分片 + 并行让流水线快 3 倍,月成本降 67%。
- 90% 命中率已经接近最优(7.2 vs 6.5 分钟):最后 10% 的命中率要处理缓存失效、冷启动、工具链升级,投入产出比很低。
- 开发者等待时间的价值远高于机时:每次省 12.3 分钟 × 200 次/天 = 2460 分钟/天(约 41 人时),而机时只省 $624/月。
缓存命中率的边际收益
| 命中率 | 关键路径 | 相比上一档节省 |
|---|---|---|
| 0% | 13.5 min | — |
| 50% | 10.0 min | 3.5 min |
| 80% | 7.9 min | 2.1 min |
| 90% | 7.2 min | 0.7 min |
| 99% | 6.6 min | 0.6 min |
收益递减非常明显:从 0 到 50% 省 3.5 分钟,从 90% 到 99% 只省 0.6 分钟。所以缓存策略应该追求“稳定的 80–90%”,而不是“极致的 99%”——后者通常意味着要缓存测试结果、引入更复杂的 key,带来正确性风险。
缓存 key:正确性比命中率重要
| 缓存对象 | key 应该包含 | 常见错误 |
|---|---|---|
| 依赖 | 锁文件哈希 + 包管理器版本 + 平台 | 只按分支名缓存,不同分支互相污染 |
| 编译产物 | 源文件哈希 + 编译器版本 + 编译参数 + 环境变量 | 忽略环境变量,缓存了错误的产物 |
| 测试结果 | 源码 + 测试代码 + 依赖 + 运行环境 | 缓存测试结果但测试依赖外部服务 |
| Docker 层 | 基础镜像 digest + 构建上下文 | 用 latest 标签,层缓存不可复现 |
三条硬规则:
- 缓存必须可复现:同一个 key,任何时候重算都应得到相同结果。用了
latest、git clone默认分支、或未固定版本的工具链,都会破坏这一点。 - 缓存失败要能回退到重算:缓存服务挂了,构建应该变慢而不是失败。
- 缓存不可信:命中缓存后仍要跑最小验证(如产物校验和/冒烟测试)。
⚠️ 缓存投毒:如果缓存 key 能由不可信输入(PR 的代码)影响,攻击者可以让“恶意产物”进入缓存,被主分支复用。修法:PR 构建只读缓存、不写共享缓存;或用独立命名空间。
分片:并行度不是越高越好
测试 6 分钟、4 片:6/4 + 0.3(汇总开销)= 1.8 分钟。但分片有固定成本:
| 分片数 | 理论耗时 | 实际(含调度/汇总) | 说明 |
|---|---|---|---|
| 1 | 6.0 | 6.0 | 无开销 |
| 4 | 1.5 | 1.8 | 推荐 |
| 8 | 0.75 | 1.2 | 调度与上报开销占比上升 |
| 16 | 0.375 | 1.0 | 收益趋平,且需要更多 runner |
分片数 ≈ 可用 runner 数。分片超过 runner 数时,任务在排队,不会更快;分片过细还会让“每个分片的固定开销(启动、拉代码、上报)”占比上升。经验值:单片不少于 1 分钟。
什么时候不该缓存
| 情况 | 判断 |
|---|---|
| 构建本身 < 1 分钟 | 缓存查找与恢复的开销可能大于收益 |
| 命中率 < 30% | 大概率是 key 设计有问题,先修 key |
| 产物巨大(> 几 GB) | 上传/下载比重新构建还慢 |
| 外部依赖不可控(每次都变) | 缓存无意义,先固定版本 |
| 安全敏感产物(签名/密钥) | 缓存会扩大泄露面 |
可运行的关键路径模型
package main
import "fmt"
// stage 是一次构建中的一步:耗时、是否能命中缓存、是否能并行分片。
type stage struct {
name string
minutes float64
cache bool
shards float64 // >1 表示可切片并行
}
type scenario struct {
name string
cacheHit float64 // 依赖/编译缓存的命中率
sharding bool // 测试是否分片
parallel bool // 测试与集成是否并行
}
func criticalPath(stages []stage, sc scenario) float64 {
var deps, compile, test, integ, pack float64
for _, s := range stages {
switch s.name {
case "checkout":
deps += s.minutes
case "deps":
if sc.cacheHit > 0 {
deps += s.minutes * (1 - sc.cacheHit)
} else {
deps += s.minutes
}
case "compile":
compile = s.minutes * (1 - sc.cacheHit)
case "unit":
test = s.minutes
if sc.sharding {
test = s.minutes/4 + 0.3 // 4 片 + 汇总开销
}
case "integration":
integ = s.minutes
case "package":
pack = s.minutes
}
}
tail := test
if sc.parallel && integ > test {
tail = integ
} else if sc.parallel {
tail = test
} else {
tail = test + integ
}
return deps + compile + tail + pack
}
func main() {
stages := []stage{
{"checkout", 0.5, false, 1},
{"deps", 3.0, true, 1},
{"compile", 4.0, true, 1},
{"unit", 6.0, false, 4},
{"integration", 5.0, false, 1},
{"package", 1.0, false, 1},
}
scenarios := []scenario{
{"冷构建(无缓存,串行)", 0, false, false},
{"依赖缓存 100%,测试分片,串并行", 1, true, true},
{"缓存命中 90%", 0.9, true, true},
{"缓存命中 50%", 0.5, true, true},
}
fmt.Printf("%-34s %12s %14s %14s\n", "场景", "关键路径", "200 次/天", "月成本(示例)")
const builds = 200.0
const costPerMin = 0.008 // 2 核 runner 的示例单价
for _, sc := range scenarios {
cp := criticalPath(stages, sc)
fmt.Printf("%-34s %10.1f min %12.0f min %12.0f 美元\n",
sc.name, cp, cp*builds, cp*builds*30*costPerMin)
}
// 开发者等待:200 次/天,每次省下的时间 × 人数
cold := criticalPath(stages, scenarios[0])
warm := criticalPath(stages, scenarios[2])
fmt.Printf("\n每次从 %.1f 分钟降到 %.1f 分钟:200 次/天共省 %.0f 分钟/天(约 %.0f 人时)\n",
cold, warm, (cold-warm)*builds, (cold-warm)*builds/60)
// 缓存命中率的边际收益
fmt.Println("缓存命中率的边际收益(关键路径,分钟):")
for _, h := range []float64{0, 0.5, 0.8, 0.9, 0.99} {
cp := criticalPath(stages, scenario{"", h, true, true})
fmt.Printf(" 命中 %4.0f%% → %.1f min\n", h*100, cp)
}
}
输出:
场景 关键路径 200 次/天 月成本(示例)
冷构建(无缓存,串行) 19.5 min 3900 min 936 美元
依赖缓存 100%,测试分片,串并行 6.5 min 1300 min 312 美元
缓存命中 90% 7.2 min 1440 min 346 美元
缓存命中 50% 10.0 min 2000 min 480 美元
每次从 19.5 分钟降到 7.2 分钟:200 次/天共省 2460 分钟/天(约 41 人时)
缓存命中率的边际收益(关键路径,分钟):
命中 0% → 13.5 min
命中 50% → 10.0 min
命中 80% → 7.9 min
命中 90% → 7.2 min
命中 99% → 6.6 min
参数怎么给
| 你的情况 | 优先做 |
|---|---|
| 流水线 > 20 分钟 | 画关键路径,找最长的那条,先并行、再缓存 |
| 依赖安装 > 3 分钟 | 锁文件 + 依赖缓存(远程) |
| 编译 > 5 分钟 | 编译缓存 + 增量编译 + 只构建受影响模块 |
| 测试 > 8 分钟 | 分片(片数 ≈ runner 数)+ 只跑受影响测试 |
| 团队 > 20 人 | 远程缓存 + 预热(主分支构建后预热缓存) |
| 构建 < 3 分钟 | 不要过度优化,收益小于维护成本 |
| 频繁“缓存导致构建失败” | 先修 key 与失效策略,再谈命中率 |
反模式
| 反模式 | 后果 |
|---|---|
| 只优化单步,不看关键路径 | 优化了 1 分钟的步骤,总时长不变 |
| 缓存 key 只含分支名 | 分支间互相污染,构建出错误产物 |
用 latest 镜像/工具链 | 缓存不可复现,问题无法定位 |
| PR 构建写共享缓存 | 缓存投毒,主分支拿到恶意产物 |
| 分片数远超 runner 数 | 任务排队,耗时不变,成本上升 |
| 追求 99% 命中率 | 维护成本陡增,正确性风险上升 |
| 缓存失败就构建失败 | 缓存从加速器变成单点 |
| 测试缓存结果但依赖外部服务 | 缓存了“当时通过”的结果,实际已失效 |
| 串行执行所有步骤 | 关键路径 = 总耗时,浪费并行能力 |
| 不预热缓存就要求快速反馈 | 冷启动每次都很慢,命中率上不去 |
自检
Q1 冷构建 19.5 分钟,加缓存、分片、并行后 6.5 分钟。为什么不是“每一步都除以 3”?
▸ 答案:因为优化只作用于关键路径上的步骤,而且每个手段只影响特定步骤:依赖缓存把 3 分钟变 0,编译缓存把 4 分钟变 0,分片把测试 6 分钟变 1.8,并行让测试与集成从 11 分钟变 max(1.8, 5)=5。关键路径变成 0.5(checkout) + 0 + 0 + 5(integration) + 1(package) = 6.5。此时瓶颈已经变成集成测试的 5 分钟——再优化编译或依赖已经没用,要继续降必须并行化集成测试或减少它的范围。
Q2 缓存命中率从 90% 提到 99%,关键路径只从 7.2 分钟降到 6.6 分钟。为什么收益这么小?
▸ 答案:因为缓存只影响它覆盖的步骤(依赖 3 分钟 + 编译 4 分钟 = 7 分钟),其余步骤(checkout 0.5 + 集成 5 + package 1 = 6.5 分钟)完全不受影响。命中率 90% 时,缓存步骤的期望耗时是 0.7 分钟;99% 时是 0.07 分钟——只省 0.63 分钟。要再快,必须动关键路径上的“非缓存步骤”(集成测试并行化、部署优化),而不是继续压缓存。
Q3 为什么分片数建议约等于 runner 数,而不是越多越好?
▸ 答案:分片的收益是“并行”,前提是有足够的 runner 同时跑。分片数超过 runner 数时,多出来的分片只能排队,总时长不变;同时每个分片都有固定开销(启动容器、拉代码、装依赖、上报结果),分片越细,固定开销占比越高。经验上单片不少于 1 分钟:6 分钟的测试分 8 片(0.75 分钟/片)时,固定开销可能比测试本身还长。
Q4 缓存 key 里为什么必须包含“工具链版本”和“环境变量”?
▸ 答案:因为缓存的是产物,产物依赖“输入全集”。同一个源码,用不同版本的编译器、不同的 GOFLAGS/NODE_ENV、不同的基础镜像,产出的二进制/构建结果可能不同(优化级别、条件编译、依赖解析)。key 少一项,就会在工具链升级后命中旧缓存,产出“看起来构建成功、实际行为不对”的包——这类问题极难排查,因为 CI 日志显示一切正常。
Q5 什么情况下应该放弃缓存,转而优化构建本身?
▸ 答案:三种情况:① 命中率长期低于 30%——说明输入变化太频繁或 key 设计有问题,先修 key/固定版本;② 构建本身不到 1 分钟——缓存查找、下载、解压的固定开销可能超过收益;③ 产物巨大(几 GB)——上传/下载比重新构建还慢。另外,安全敏感产物(签名、含密钥的包)不适合共享缓存。判断标准始终是:缓存带来的时间节省 > 缓存的维护与风险成本。
跨篇相关:静态检查与 lint 的流水线位置见 52.2 静态检查;依赖与漏洞扫描见 52.3 依赖与漏洞扫描;流水线整体设计与耗时优化见 52.4 流水线设计与耗时优化;不可变制品与镜像仓库见 54.1 不可变制品;单体仓库对缓存的影响见 51.4 单体仓库 vs 多仓库;交付效率指标的度量见 59.1 DORA 四指标;性能测试的负载模型见 34.5 性能测试与负载模型。