TT Lab
开始
学习 学习路径 课程

CI/CD 流水线

缓存的成败全在于键怎么设计

在 TT Lab 中继续学习

一句话总结

缓存必须遵守的规则只有一条:清掉缓存之后,也必须得到同样的产物。 缓存只是一个缩短时间的装置,一旦它改变了结果,它就不再是缓存,而是隐藏的输入。让这条规则得以遵守的,就是缓存键的设计。

为什么需要它

引入缓存的动机永远是速度。但如果缓存键设计得马虎,两头都会输。键太细,什么都对不上,缓存形同虚设;键太松,就会出现以陈旧内容通过的构建。后者要糟糕得多。因为本该失败的东西成功了,而这个失败会在缓存过期的几周之后,由毫不相干的人的改动引爆。

GitLab 文档把这一特性写得非常明确:“缓存是一种优化,并不保证总是有效。每个作业可能仍然需要重新生成已缓存的文件。”也就是说,必须是没有缓存也行的结构。如果作业以缓存的存在为前提,那它就不是缓存,而是依赖。

键里放什么

键是用字符串写出的“可以重用这份内容的条件”。所以凡是影响结果的东西,都必须放进去。

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 每个仓库默认 10GB,会清除超过 7 天未使用的条目,需要空间时,会从最后访问时间最早的开始清除。所以如果把键按提交拆得很细,缓存之间会互相挤占,命中率反而下降。

在现场相遇的样子

参考

下一项实验要做什么

亲手用 shell 构建缓存管理脚本。把锁文件哈希、工具版本、操作系统拼接起来构成缓存键,并编写用这个键保存和取出的脚本。然后把锁文件改一行,确认键会自动改变;通过前缀回退取到旧内容后,构造反例,看看直接放行会发生什么。最后加上两样东西:清空缓存后重新运行、对比产物哈希是否相同的检查,以及统计并报告命中率的步骤。