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

三个结论:

  1. 缓存 + 分片 + 并行让流水线快 3 倍,月成本降 67%。
  2. 90% 命中率已经接近最优(7.2 vs 6.5 分钟):最后 10% 的命中率要处理缓存失效、冷启动、工具链升级,投入产出比很低。
  3. 开发者等待时间的价值远高于机时:每次省 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 标签,层缓存不可复现

三条硬规则:

  1. 缓存必须可复现:同一个 key,任何时候重算都应得到相同结果。用了 latest、git clone 默认分支、或未固定版本的工具链,都会破坏这一点。
  2. 缓存失败要能回退到重算:缓存服务挂了,构建应该变慢而不是失败。
  3. 缓存不可信:命中缓存后仍要跑最小验证(如产物校验和/冒烟测试)。

⚠️ 缓存投毒:如果缓存 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 性能测试与负载模型。