GitHub Actions 缓存不命中怎么办?pnpm/npm/Maven 实测对比和 7 个坑

🔑 关键词:GitHub Actions,缓存不命中,pnpm,CI 加速,actions/cache

📖 摘要:从一次 214 次 workflow 白跑的经历出发,讲清 GitHub Actions 缓存不命中的常见原因,对比 setup-node 内置缓存和 actions/cache,给出 pnpm/npm/Maven/Playwright/Docker 的具体参数和清理方法。

先说我踩的坑:缓存 key 写错,CI 白跑 200 次

图片

去年我帮一个朋友看他们公司的前端仓库,GitHub Actions 每次跑 3 分 42 秒,12 个 job 里 6 个在装依赖。npm ci 花了 1 分 58 秒,pnpm install 花了 2 分 18 秒。他们 yaml 里写着 actions/cache@v3,key 是 Linux-node-cache-${{ hashFiles('yarn.lock') }},但项目 2023 年就换成 pnpm 了,仓库里根本没有 yarn.lock。每次日志都打印“Cache not found for input keys: Linux-node-cache-”,然后老老实实重新下载。我数了一下,那个月跑了 214 次 workflow,等于白等 7 个多小时。GitHub 缓存不是无限仓库:官方文档写每个仓库总共 10GB,超过 7 天没人读取就自动删除。所以你不是把东西丢进去就永远在,它更像便利店货架,卖不掉就下架。

正确顺序和参数:setup-node 内置缓存比 actions/cache 省心

图片

如果你只用 npm,优先用 actions/setup-node@v4 的 cache 参数,别自己写 actions/cache。它内部帮你拼 key,key 大概长这样:Linux-npm-。但 pnpm 有个坑:必须先把 pnpm 装上,再让 setup-node 找 pnpm。顺序反了会报“Unable to locate executable file: pnpm”。我现在的模板是:

- uses: pnpm/action-setup@v4
  with:
    version: 9.12.3
- uses: actions/setup-node@v4
  with:
    node-version: 20.18.1
    cache: 'pnpm'
    cache-dependency-path: '**/pnpm-lock.yaml'
- run: pnpm install --frozen-lockfile

图片

这个顺序我至少给 9 个仓库改过,从原来 2 分 18 秒降到 34 秒左右。注意 cache-dependency-path 一定要写,monorepo 里 lockfile 可能在 apps/web/pnpm-lock.yaml,不写就找不到。Maven 用 actions/setup-java@v4 的 cache: 'maven',它缓存 ~/.m2/repository,key 默认跟 pom.xml 哈希走。Gradle 用 cache: 'gradle',缓存 ~/.gradle/caches 和 ~/.gradle/wrapper。我实测一个 Java 仓库,Maven 从 4 分 02 秒降到 1 分 27 秒;npm 从 1 分 46 秒降到 52 秒。但这不是绝对,runner 磁盘 IO 和网络波动能把 34 秒打回 1 分 10 秒。

我测了 npm、pnpm、Maven:缓存什么比怎么缓存更重要

图片

很多人一上来就缓存 node_modules,这是坑。node_modules 里有平台相关二进制,比如 sharp、esbuild、canvas,ubuntu-latest 从 22.04 换到 24.04 后,glibc 版本一变,恢复出来的二进制直接跑不起来。pnpm 更麻烦,它的 node_modules 里全是符号链接,tar 打包再解压可能断链。正确做法是缓存包管理器的全局 store:pnpm store path 一般在 ~/.local/share/pnpm/store/v3,Windows 在 %LOCALAPPDATA%/pnpm/store;npm 在 ~/.npm;yarn 在 ~/.cache/yarn 或 ~/.yarn/berry/cache。这样 install 阶段只做链接和校验,不重复下载。key 建议用 ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }},restore-keys 用 ${{ runner.os }}-pnpm-。restore-keys 别写太宽,否则旧 store 可能把 10GB 池子塞满,新缓存反而写不进去。

图片

缓存 Playwright 和 Docker 层:省时间,但别把池子挤爆

前端项目现在跑端到端测试,Playwright 浏览器一装就是 150-300MB。我会缓存 ~/.cache/ms-playwright,key 写 ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }},因为浏览器版本跟 Playwright 版本绑定,lock 变了 key 就变。Docker 构建层用 docker/build-push-action@v6,cache-from: type=gha,scope=myapp,cache-to: type=gha,mode=max,scope=myapp。mode=max 会把所有中间层都缓存,体积很容易上 2-3GB。如果你的仓库还有 pnpm store 和 Playwright 缓存,10GB 池子很快见底。我一般给 Docker 单独 scope,并且每周用 gh cache delete --all 清一次,或者 gh cache delete 删指定缓存。注意 gh cache delete --all 会删掉当前仓库所有 Actions 缓存,别在发布前手滑。

图片

我的独立观点:缓存命中率不是 KPI,CI 的 p95 时间才是

GitHub Actions 缓存最迷惑人的地方是“命中率”看起来很高,但 CI 还是慢。因为恢复一个 2GB 的缓存要 40 秒,解压又要 30 秒,最后只省了 20 秒下载。我现在的判断标准很简单:看 p95 耗时,不看单次最快。一个仓库如果没缓存稳定 2 分 30 秒,有缓存后在 40 秒到 3 分 10 秒之间跳,我宁愿关掉缓存。缓存是加速器,不是正确性保证。生产构建必须 pnpm install --frozen-lockfile,不能因为缓存里有旧包就跳过安装。还有 fork PR 的缓存作用域和默认分支不一样,别把 secrets 或私有 registry token 塞进缓存路径。最后一句:先修 key,再换 v4,最后才考虑缓存 node_modules。顺序错了,GitHub 不会报错,只会让你在日志里找“Cache not found”。

🏷️ 标签: