官方文档把「缓存依赖」包装成一句「用 setup-node/setup-python 即可」,但跑过大型 monorepo 的人都知道,这句「即可」背后藏着多少暗坑。今天把 GitHub Actions 内建包管理器缓存的底裤扒光:为什么锁文件只改一行,整个 vendor 就要重新下载?为什么并行 job 一多就报「Cache already exists」?以及怎样在 10 GB 硬上限下把命中率从 30% 拉到 90%。
一、setup-* 的「一刀切」失效策略
官方动作把缓存 key 简化为:
key: ${{ runner.os }}-${{ node-version }}-${{ hashFiles('**/package-lock.json') }}
看起来合理,实则只认「锁文件是否变化」。一旦你把某个子依赖手动升到 patch 版本,即使 99% 包没变,key 也会整体失效,触发整包重拉。更糟的是,setup-* 默认把缓存目录打成一个 tar 上传到 GitHub 的 Blob 存储;解压时单线程,Linux 下实测 1.2 GB 的 node_modules 需要 55 秒,几乎把编译机的时间吃光。
二、actions/cache 的三宗罪
-
并发写竞争
多 job 同时写同一 key 时,后端没有合并或锁机制,直接抛「Cache already exists」失败。CI 日志里看似绿色,其实缓存没写进去,下次继续冷启动。 -
key 爆炸
512 字符上限看似够长,但 monorepo 里若把hashFiles('**/go.sum','**/package-lock.json','**/pdm.lock')全拼进去,很容易超限。一旦超限,action 直接失败,连回退机会都不给。 -
分支隔离导致命中率骤降
PR 只能访问 base 分支缓存,feature-a 和 feature-c 互为「同级分支」无法共享。结果每个 PR 都要重跑「下载→解压」全套,缓存形同虚设。
三、可落地的三副解药
1. 分块缓存:把「大 tar」拆成「小切片」
- name: 缓存 npm 全局缓存
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-cache-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-cache-${{ runner.os }}-
- name: 缓存 node_modules(增量)
uses: actions/cache@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/patch') }}
restore-keys: |
nm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
先缓存「下载缓存」(~/.npm),再缓存「安装产物」(node_modules)。锁文件变化时,下载缓存大概率仍可命中,只剩解压一步;整体提速 40% 以上。
2. 命名空间:给每个子项目一把专属钥匙
key: ${{ matrix.project }}-${{ runner.os }}-${{ hashFiles(format('{0}/package-lock.json', matrix.project)) }}
把 monorepo 按子目录拆成矩阵,避免「改一行,全仓库失效」。key 长度也随目录缩短,降低 512 字符撞线风险。
3. 并发写串行化:让写操作排队
- name: 串行写缓存
if: github.ref == 'refs/heads/main'
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }}
只在 main 分支写缓存,PR 只读不写,彻底规避竞争。配合 concurrency: group: cache-write 可把写操作串行化,牺牲一点时效换稳定性。
四、隐藏成本:10 GB 上限与 7 天回收
GitHub 默认给每个仓库 10 GB 缓存池,7 天未访问自动回收。听起来宽裕,但 Java/Go/Node 三语混建时,一个 commit 就能产生 3 GB 缓存;多分支并行下,池子瞬间被挤爆。缓存被逐出时,action 不会告警,下次只能冷启动,CI 耗时翻倍却找不到原因。建议每周跑一次定时任务,用 gh cache list --limit 100 把老 key 清掉,把池子留给最新版本。
五、小结
GitHub Actions 的包管理器缓存不是「开箱即用」的银弹,而是「看上去免费、实则暗中标价」的陷阱。记住三句话:
- 锁文件一动就全量重下,不是 bug,是官方设计。
- 并发写同一 key 必失败,先装工具再缓存是套路。
- 10 GB 池子很小,定期清缓存比写新 feature 更值钱。
把这三件事写进团队规范,CI 耗时能从 9 分钟压到 3 分钟,省下的是真金白银的 runner 账单。
资料来源
[1] GitHub Docs:缓存依赖项以加快工作流程,2025-04-03
[2] PHP 中文网:GitHub Actions 缓存 Composer 依赖实战,2025-11-06