文章目录
前言
很多团队优化 CI 时,先想到的是加机器、拆任务、并行执行。真正容易被低估的,反而是缓存。依赖重复下载、工具重复初始化、构建结果重复生成,这些步骤单看一次不算夸张,但放到每天几十次甚至上百次流水线里,时间损耗会非常明显。GitHub Actions 的缓存机制,本质上是在减少这种重复劳动,让同一类工作不要在每次运行里都从零开始。
但缓存不是配上就会自动变快。很多项目明明开了缓存,效果却不稳定,问题通常不在缓存功能本身,而在缓存键设计、恢复策略、作用范围和清理成本没有想清楚。GitHub Actions 的缓存恢复不是单一匹配,它会先找精确 key,再做前缀匹配和 restore-keys 匹配;当前分支找不到时,还会继续尝试默认分支中可访问的缓存。把这些边界理顺之后,缓存才会真正成为生产力。

一、缓存真正解决的,不是某一步变快,而是整条流水线少做重复劳动
理解 GitHub Actions 缓存,不能只停留在把某个目录存起来,下次再取回来这一层。缓存真正解决的是,同一类输入反复触发同一类准备动作的问题。对前端项目来说,最典型的是 npm、Yarn、pnpm 这类依赖缓存。对 Java、Python、.NET 项目来说,则可能是 Maven、Gradle、pip、NuGet 这类工具链缓存。只要这些内容在多次工作流之间可以稳定复用,就值得进入缓存体系。
这里有一个很重要的判断标准。缓存适合放那些重建成本高、内容相对稳定、复用价值明确的目录,而不是把所有中间文件一股脑塞进去。因为缓存一旦设计不当,不仅不会提速,还会带来上传下载时间、存储占用和误命中风险。缓存从来不是越多越好,而是越准越好。
二、缓存键设计,决定了缓存有没有真正价值
缓存策略的核心不是 path,而是 key。一个缓存能不能被准确复用,取决于 key 是否真正表达了这份缓存的适用条件。现在这套机制的匹配顺序很清楚,先找精确 key,再找 key 的前缀匹配,如果还找不到,就按 restore-keys 继续找。只有精确匹配,才算真正意义上的命中。只命中前缀时,虽然也能恢复部分缓存,但它更像是一种有条件复用。
这也是为什么缓存键不能写得太宽,也不能写得太碎。太宽,比如直接写一个固定字符串,不同分支、不同依赖状态都去争同一份缓存,结果就是命中率看着很高,实际却可能把不适用的内容恢复回来。太碎,比如直接把提交 SHA 塞进去,每次提交都生成新缓存,等于主动放弃复用。更合理的思路,是把真正影响构建结果的因素放进 key,比如操作系统、锁文件哈希、工具链版本。这样既能保证复用,又能在依赖变化时自然失效。
对 Node.js 项目来说,一个常见且稳定的做法,就是把 runner.os 和 package-lock.json 的哈希组合进 key。这样只有依赖文件变了,或者运行环境变了,才会创建新缓存。Monorepo 场景下,再通过 cache-dependency-path 指向多个锁文件,就能让缓存粒度更贴近项目结构,而不是让整个仓库共用一份过于粗糙的缓存。setup-node 底层仍然依赖缓存机制,但默认缓存的是 npm、Yarn、pnpm 的全局包管理器数据,而不是 node_modules 目录本身,这一点在实际使用中非常关键。
三、代码怎么落地,往往比原理更重要
对大多数项目来说,缓存优化最值得先做的,不是中间产物,而是依赖缓存。原因很现实,依赖通常更稳定,命中收益也更容易衡量。尤其在 Node.js 工作流里,直接使用 setup-node 自带的缓存能力,通常比手写一套 node_modules 缓存更稳。下面这段配置就很适合作为一个起点,它保留了依赖哈希、平台隔离和 restore-keys 的基本思路,足够清晰,也足够实用。
yaml
name: Node.js CI with Smart Caching
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 生成基于package-lock.json的缓存键
- name: Generate cache key
id: cache-key
run: echo "key=$(sha256sum package-lock.json | cut -d' ' -f1)" >> $GITHUB_OUTPUT
# 使用智能缓存
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: node-modules-${{ runner.os }}-${{ steps.cache-key.outputs.key }}
restore-keys: |
node-modules-${{ runner.os }}-
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run tests
run: npm test
这段配置真正有价值的地方,不只是能跑,而是它体现了一套比较稳的思路。缓存路径没有直接指向 node_modules,而是落在 ~/.npm。这样可以减少平台差异和目录结构带来的不确定性。缓存键基于 package-lock.json 的 SHA256 值生成,意味着依赖没变时尽量复用,依赖一变就自动切换到新缓存。再往下加一层 restore-keys,可以让当前精确 key 找不到时,优先回退到同一平台上的旧缓存,避免完全从零开始。
如果项目规模继续变大,再去扩展到构建工具缓存、框架缓存、编译输出缓存会更合适。这类缓存收益通常更高,但要求也更严格。只要构建过程会受到环境变量、平台架构、工具参数影响,这类缓存就必须把相关条件纳入 key,否则命中了也不一定能放心复用。
四、缓存也有成本,不能只盯着命中率
很多文章讲缓存优化,只盯着命中率和时间缩短,很少谈缓存本身的代价。实际上,缓存也是一种资源管理问题。当前规则下,超过 7 天没有被访问的缓存会被清理;仓库默认缓存总量是 10 GB,达到上限之后,会按照最后访问时间逐步淘汰旧缓存。缓存如果切得太碎、建得太频繁,很容易出现刚存进去又被淘汰的情况,最后进入高频创建、高频删除的状态。
所以缓存策略不能只问命中率高不高,还要问这份缓存值不值得长期保留,占的空间是否合理,上传和恢复这份缓存本身要花多少时间。一个很大的缓存,如果恢复速度并没有比重新生成快多少,那它的意义就不大。缓存应该优先服务高成本、可稳定复用的内容,而不是为了追求表面上的命中数字,把所有东西都推进去。
安全边界也要提前想清楚。缓存可以被当前分支读取,在 pull request 场景下还可能读取默认分支或基线分支的缓存。因此,任何包含 token、认证信息、私有凭据的目录,都不应该进入缓存路径。缓存适合放的是可重复获取但获取成本高的内容,不适合放不能泄露的内容。
总结
GitHub Actions 的缓存机制,真正优化的不是某一步命令,而是整条流水线里的重复劳动。它最有价值的地方,不在于一次构建能快几秒,而在于把原本每天都在重复发生的低价值动作尽量压缩,让反馈循环更短,让构建结果更稳定。
如果把缓存优化收成一句话,思路其实很明确。先从依赖缓存开始,把 key 设计对,再逐步扩展到构建工具和中间产物;先确认这份缓存是否稳定可复用,再考虑它能不能带来提速;先看清缓存的空间、淘汰和安全边界,再决定它值不值得长期保留。这样做出来的缓存,才是真正服务生产效率的工程策略。