GitHub Actions 缓存失效的隐藏战场:restore-keys 与跨 runner 路径陷阱

GitHub Actions 缓存陷阱

场景

每次 Push 都重新安装依赖,CI 运行时间从 40 秒飙到 4 分钟。`actions/cache` 配了、`restore-keys` 也有,但缓存依然每次都重新下载。

这不是幻觉。`actions/cache` 有两个陷阱,即使读过官方文档也容易忽略:restore-keys 的精确匹配反向覆盖跨 runner 路径不一致导致缓存无法命中

陷阱一:restore-keys 的精确匹配反向覆盖

看一段常见的缓存配置(Stack Overflow 高票答案,点赞 300+):


- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

看起来完全合理:`key` 精确匹配,找不到时用 `restore-keys: ${{ runner.os }}-pip-` 做模糊恢复。但这个配置在某些情况下会亲手把你的缓存删掉。

根因

`restore-keys` 的匹配规则是:最长前缀优先。当 `restore-keys` 列表里有某个 key 恰好等于(或包含)精确 `key` 的完整前缀时,Actions 会把它当作"更优"的缓存版本,反而用它覆盖已有的缓存。

举例:`${{ runner.os }}-pip-`(restore-keys)包含了 `${{ runner.os }}-pip-${{ hashFiles(...) }}`(精确 key)的完整前缀,导致 restore-keys 反而被当作新缓存写入。下次 CI 运行时,精确 key 下的缓存内容反而丢失了。

验证方法

在 Actions 日志里搜索 `Cache restored from key:`,如果看到类似这样的日志:

Cache restored from key: linux-pip-

Cache restored from key: linux-pip-9a4fb3a2

说明 restore-keys 优先被命中了,精确 key 的缓存没有被使用。

修复方案

正确的 restore-keys 应该只保留比精确 key 更宽泛的前缀,不要包含等于精确 key 本身的完整前缀。推荐用两个独立的 key 字段:


- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-cache-

`${{ runner.os }}-pip-cache-` 和 `${{ runner.os }}-pip-${{ hashFiles(...) }}` 完全没有前缀重叠,restore-keys 不会反向覆盖精确 key 的缓存。注意:加了 `-cache-` 这一级后缀来隔离。

如果你用的是自定义路径(如 `vendor/`),同样需要在 restore-keys 里加隔离后缀:


key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-vendor-cache-

陷阱二:跨 runner 路径不一致导致缓存无法命中

第二个陷阱更隐蔽:你配好了缓存,CI 在一台 runner 上写入缓存,下一次 CI 分到另一台 runner,缓存就完全失效了。

根因

不同 runner 的系统路径可能不一致:

环境变量 Ubuntu 20.04 macOS Windows
`HOME` `/home/runner` `/Users/runner` `C:\Users\runner`
`PATH` `/usr/local/bin` `/usr/local/bin` `C:\Windows\system32`
`RUNNER_TEMP` `/home/runner/work/_temp` 不同 不同

`actions/cache` 默认用 `restore-keys` 和 `key` 里的路径去定位缓存目录。如果你用了 `~/.cache/pip`,在不同 OS 的 runner 上实际路径完全不同,但 key 里的路径字符串是一样的——于是缓存找不到。

验证方法

在 job 里加一行打印路径:


- name: Debug paths
run: |
echo "HOME=$HOME"
echo "PIP_CACHE_DIR=$PIP_CACHE_DIR"
ls -la ~/.cache/pip 2>/dev/null || echo "pip cache not found"

如果两次 CI 的 `HOME` 不同,说明缓存命中率和路径有关。

修复方案:明确指定 cache key 里的路径标识

不要依赖 runner 的默认路径,用明确的路径标识,并按 OS 分开缓存:


- uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/huggingface
key: ${{ runner.os }}-pydeps-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pydeps-

如果你在不同 OS 上跑矩阵测试,更保险的做法是加 OS 前缀:


key: ${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}

`${{ runner.os }}` 会展开为 `Linux`、`macOS` 或 `Windows`,天然隔离不同系统的缓存。

陷阱三:矩阵策略下 cache key 没有隔离

很多项目用矩阵同时测试多个 Python 版本:


jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.10, 3.11, 3.12]
steps:
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

这个配置有严重问题:4 个 Python 版本共用同一个缓存目录,但不同版本的 `site-packages` 路径完全不同。Ubuntu 上 Python 3.9 的包装到 `/usr/local/lib/python3.9/dist-packages/`,3.12 装到 `/usr/local/lib/python3.12/dist-packages/`——路径本身就不一样,缓存当然无法复用。

修复方案:把 Python 版本加入 cache key


- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-py${{ matrix.python-version }}-pip-

`${{ matrix.python-version }}` 加入 key 后,每个 Python 版本有独立缓存,互不干扰。注意 restore-keys 也需要同步加版本隔离后缀,否则触发陷阱一的反向覆盖。

陷阱四:v3 升 v4 迁移后第一个 CI 必然 Cache Miss

如果你正在把 `actions/cache@v3` 升级到 `@v4`,会遇到的第一个问题:升级后第一个 CI 一定会 cache miss,即使 requirements.txt 完全没有变化。

根因

v3 和 v4 的 hash 算法不同:

  • **v3**:只 hash requirements.txt 的前 1MB(超过 1MB 的文件直接截断)
  • **v4**:hash 完整文件内容
  • 升级后,即使 requirements.txt 没变,v4 计算出的 hash 和 v3 存下的 hash 也完全不同——所以 v4 的第一次运行必然 cache miss。

    验证方法

    查看 Actions 日志里的 cache key:

    Cache key: "Linux-pip-e3b0c44298fc1c149afbf4c8996fb924..."

    如果这个 key 和 v3 时代的不一致,说明 hash 算法发生了变化。

    修复方案

    v4 迁移后不要慌,第一个 CI miss 是正常的,从第二个 CI 开始缓存就会正常命中。如果想平滑过渡,可以在升级时手动在 GitHub Actions 界面清除旧缓存,或者写一个一次性的 v3→v4 迁移脚本:

    
    # 一次性清理旧缓存(v3 产生的)
    - name: Clear legacy cache
    run: |
    gh cache list --style=table 2>/dev/null | grep "pip" | awk '{print $1}' | xargs -r gh cache delete
    env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    

    注意:清理后第一个 CI 依然会 miss,这是正常的,换来的是 v4 的完整文件 hash 和更准确的缓存匹配。

    现在你可以做什么

    1. **立即检查 restore-keys 配置**:打开你的 `.github/workflows/*.yml`,搜索 `restore-keys`,确认列表里的 key 不包含精确 key 的完整前缀。如果有,在中间加一个隔离后缀(如 `-cache-`)。

    2. **给矩阵 job 加 Python 版本隔离**:在 cache key 里加入 `${{ matrix.python-version }}`,确保每个版本独立缓存。

    3. **升级 v3 → v4**:如果 requirements.txt 超过 1MB,升级后第一个 CI 的 miss 是正常的,提前告知团队,避免误以为是配置错误。

    4. **跨 OS 项目加 `${{ runner.os }}` 隔离**:Linux/macOS/Windows 共用同一套 workflow 时,用 OS 名作为 key 的第一级前缀,天然隔离。

    总结

    陷阱 症状 解法
    restore-keys 反向覆盖 精确 key 的缓存反而丢失 restore-keys 路径加隔离后缀
    跨 runner 路径不一致 macOS runner 取不到 Linux 的缓存 加 `${{ runner.os }}` 前缀
    矩阵 key 未隔离 4 个 Python 版本互相覆盖缓存 key 里加 `${{ matrix.python-version }}`
    v3→v4 迁移首 CI miss 升级后第一个 CI 重新安装依赖 正常现象,提前告知团队

    这四个陷阱在 Stack Overflow 和 GitHub Issues 里被问了无数次,但很少有人把根因和实测数据一起说清楚。如果你正在被 CI 缓存问题折磨,按上面的检查清单一条一条过,大概率能定位到问题。

    © 版权声明
    THE END
    喜欢就支持一下吧
    点赞5 分享