缓存的成败全在于键怎么设计
一句话总结
缓存必须遵守的规则只有一条:清掉缓存之后,也必须得到同样的产物。 缓存只是一个缩短时间的装置,一旦它改变了结果,它就不再是缓存,而是隐藏的输入。让这条规则得以遵守的,就是缓存键的设计。
为什么需要它
引入缓存的动机永远是速度。但如果缓存键设计得马虎,两头都会输。键太细,什么都对不上,缓存形同虚设;键太松,就会出现以陈旧内容通过的构建。后者要糟糕得多。因为本该失败的东西成功了,而这个失败会在缓存过期的几周之后,由毫不相干的人的改动引爆。
GitLab 文档把这一特性写得非常明确:“缓存是一种优化,并不保证总是有效。每个作业可能仍然需要重新生成已缓存的文件。”也就是说,必须是没有缓存也行的结构。如果作业以缓存的存在为前提,那它就不是缓存,而是依赖。
键里放什么
键是用字符串写出的“可以重用这份内容的条件”。所以凡是影响结果的东西,都必须放进去。
- 锁文件的哈希。 依赖一变,键就应当自动改变。靠手动升版本号的键,迟早会被忘记。
- 工具版本。 即使是同一个锁文件,编译器或运行时版本不同,造出的东西也不同。
- 操作系统和架构。 把在 amd64 上构建的原生扩展拿到 arm64 上重用,会悄无声息地坏掉。
- 缓存的用途。 不要把依赖缓存和构建中间产物缓存混在同一个键里。
GitHub Actions 文档中的标准写法,完整体现了这个形态。
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
GitLab 用 cache:key:files 做同样的事。它的作用是生成一个与特定文件内容关联的键。
前缀回退带来的与夺走的
restore-keys 在没有精确匹配的键时,会查找以某个前缀开头的键。官方文档写明,它会按顺序扫描,如果有多个部分匹配,就返回最近创建的缓存。
好处是明显的。锁文件只改了一行,就不必把依赖全部重新下载,而是在旧的基础上只拉取差异。风险也在同一个地方。通过前缀回退取回的内容,不保证与现在的锁文件一致。 所以前缀回退只用于“重新计算后结果也相同的东西”。下载缓存是安全的,而不加检查就重用编译产物则是危险的。只有当键完全匹配时才跳过步骤;如果只是前缀匹配,填入内容后重新走一遍正常流程才是安全的做法。
还有一点。GitHub Actions 的缓存条目一旦创建,内容就不能修改。 文档写明,已有缓存的内容不可更改,要用新的键创建新的缓存。所以“键不动,只改内容”这条路是行不通的。
缓存与产物是不同的东西
GitLab 文档的区分最为简洁。缓存用于从互联网下载的依赖之类的东西,产物(artifact)用于在各阶段之间传递中间结果。 缓存留在 Runner 机器上,产物则保存在服务器上,可以下载。
判断标准只有一个:丢了行不行。 丢了只是多花时间,那是缓存。丢了下一个阶段就根本跑不起来,那是产物。偶尔会看到有流水线把测试报告和覆盖率结果放进缓存,但缓存可能被淘汰,所以失败的那一刻的证据就会消失。
范围与污染
缓存同时也是一条信任边界。如果任何分支都可以保存缓存,那么凡是能往那个分支推送的人,都能影响之后的构建结果。所以各平台把范围收得很窄。
- GitHub Actions:一次运行可以恢复当前分支和默认分支的缓存,PR 运行还可以使用目标分支的缓存。兄弟分支之间不共享,父分支的运行也不能使用子分支创建的缓存。
- GitLab:默认情况下,受保护分支和非受保护分支不共享缓存。
容量限制和淘汰也是要了解的值。GitHub Actions 每个仓库默认 10GB,会清除超过 7 天未使用的条目,需要空间时,会从最后访问时间最早的开始清除。所以如果把键按提交拆得很细,缓存之间会互相挤占,命中率反而下降。
在现场相遇的样子
- 不测量命中率,就连缓存是否在起作用都不知道。只要开始把命中/未命中一行一行记进日志并统计,通常最先暴露的就是“命中率其实比想象的低”。
- 改了锁文件,构建却仍然通过。很可能用的是通过前缀回退取回的旧依赖。
- 清掉缓存之后构建坏了。说明有人把只存在于缓存中的文件当作了结果的一部分来使用。
参考
- GitLab 缓存:https://docs.gitlab.com/ci/caching/
- GitHub Actions 依赖缓存:https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- GitLab 作业产物:https://docs.gitlab.com/ci/jobs/job_artifacts/
- Docker 构建缓存:https://docs.docker.com/build/cache/
下一项实验要做什么
亲手用 shell 构建缓存管理脚本。把锁文件哈希、工具版本、操作系统拼接起来构成缓存键,并编写用这个键保存和取出的脚本。然后把锁文件改一行,确认键会自动改变;通过前缀回退取到旧内容后,构造反例,看看直接放行会发生什么。最后加上两样东西:清空缓存后重新运行、对比产物哈希是否相同的检查,以及统计并报告命中率的步骤。