在大型工程中,CI 构建时间的 60%-80% 消耗在依赖下载和编译产物生成上。GitHub Actions 的
actions/cache是官方提供的缓存解决方案,但「用了」和「用好」之间隔着巨大的性能鸿沟。本文从缓存底层机制出发,系统讲解缓存键设计、跨工作流复用、Docker 镜像分层缓存、monorepo 场景下的缓存隔离策略,以及命中率的量化调优方法。
一、GitHub Actions 缓存的底层机制
1.1 缓存存储架构
┌─────────────────┐ cache key hash ┌──────────────────┐
│ Workflow Job │ ──────────────────────► │ GitHub Cache │
│ (runner) │ │ Service │
│ │ ◄────────────────────── │ (每个仓库 10GB) │
└─────────────────┘ tarball download └──────────────────┘
│
│ restore-keys 后备匹配
▼
key: npm-linux-abc123 (精确匹配)
restored from: npm-linux-def456 (前缀匹配)
GitHub Actions 的缓存本质上是一个基于内容寻址的分布式对象存储:
- 存储位置:与仓库关联,跨 workflow 和 branch 共享
- 容量限制:每个仓库 10 GB(免费/Pro/Team),Enterprise 可调整
- 过期策略:LRU(最近最少使用),超过 7 天未访问的缓存可能被清理
- 键空间:仓库级别隔离,键名在仓库内必须唯一
1.2 缓存生命周期
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
一个完整的缓存操作包含三个阶段:
| 阶段 | 行为 | 输出 |
|---|---|---|
| Restore | 任务开始时,按 key 精确匹配,失败则按 restore-keys 前缀降级匹配 | cache-hit (true/false) |
| Job 执行 | 安装新依赖、编译产物 | 磁盘上的新文件 |
| Save | 任务结束时,如果 key 未命中,将 path 打包上传 | 新缓存条目写入 |
关键洞察:key 用于恢复时的精确匹配,也用于保存时的键名。如果恢复时命中了降级键(如 restore-keys),保存时仍使用原始 key——这意味着不会覆盖旧的降级缓存,而是创建一个新的精确匹配缓存。
二、缓存键设计:从入门到精通
2.1 反模式示例
# ❌ 反模式:静态键,永远无法更新
key: npm-cache
# ❌ 反模式:过度精确,每次 commit 都产生新缓存
key: ${{ runner.os }}-npm-${{ github.sha }}
# ❌ 反模式:忽略依赖文件变化
key: ${{ runner.os }}-npm-cache
| 反模式 | 问题 | 结果 |
|---|---|---|
| 静态键 | 依赖更新后仍命中旧缓存 | 构建失败或隐式使用过期依赖 |
github.sha 键 | 每次 commit 都 MISS | 缓存完全失效,0% 命中率 |
| 无 hash 键 | 锁文件更新仍命中旧缓存 | 同静态键问题 |
2.2 推荐键设计模式
# ✅ 黄金标准:锁文件哈希 + 操作系统 + 架构
key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-npm-
# 适用场景:锁文件不变时依赖不变;锁文件变化时缓存自动失效重建
分层键设计原则:
精确键:os-arch-manager-hashFiles(lock) → 最理想命中,内容完全匹配
降级键1:os-arch-manager- → 同环境的不同锁文件版本
降级键2:os-arch- → 不同包管理器但同平台
2.3 多语言 monorepo 的缓存隔离
在包含前端(npm)、后端(Maven)、移动端(CocoaPods)的 monorepo 中,缓存必须隔离避免交叉污染:
# frontend-ci.yml
- name: Cache frontend deps
uses: actions/cache@v4
with:
path: |
frontend/node_modules
~/.npm
key: ${{ runner.os }}-frontend-npm-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: ${{ runner.os }}-frontend-npm-
# backend-ci.yml
- name: Cache backend deps
uses: actions/cache@v4
with:
path: |
backend/.m2
~/.m2/repository
key: ${{ runner.os }}-backend-maven-${{ hashFiles('backend/pom.xml') }}
restore-keys: ${{ runner.os }}-backend-maven-
键前缀命名规范:{os}-{subsystem}-{tool}-{hash}
三、Docker Build 缓存加速
3.1 传统 Docker 缓存的问题
# ❌ 传统方式:每次 build 都重新拉取基础镜像和层
- run: docker build -t myapp:${{ github.sha }} .
Docker 镜像构建的核心优化点在于层缓存复用。但在 GitHub-hosted Runner 上,每次 job 运行在一个全新的 VM 中,本地 layer cache 完全丢失。
3.2 方案一:GitHub Actions Cache + BuildKit
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha 是 BuildKit 的 GitHub Actions 缓存后端,将 Docker layer cache 直接存入 GitHub Cache 服务:
| 属性 | 说明 |
|---|---|
type=gha | 使用 GitHub Actions 内置缓存服务 |
mode=max | 缓存所有层(包括中间层),而非仅最终层 |
cache-from | 恢复时读取的缓存源 |
cache-to | 构建完后保存的缓存目标 |
3.3 方案二:Registry 作为缓存存储
对于频繁构建的大型镜像,将 layer cache 推送到镜像仓库更可靠:
- name: Build with registry cache
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry/myapp:${{ github.sha }}
cache-from: type=registry,ref=myregistry/myapp:buildcache
cache-to: type=registry,ref=myregistry/myapp:buildcache,mode=max
对比:
| 方案 | 存储位置 | 容量限制 | 跨区域共享 | 适用场景 |
|---|---|---|---|---|
type=gha | GitHub Cache | 10 GB/仓库 | ❌ 仅限仓库内 | 中小型镜像、快速原型 |
type=registry | 镜像仓库 | 无限制 | ✅ 跨 CI/本地 | 大型镜像、生产环境 |
四、高级缓存策略
4.1 跨工作流缓存复用
默认情况下,缓存是仓库级别的,同一仓库内所有 workflow 共享。但 GitHub 有一个限制:只有默认分支(main/master)的缓存可以被其他分支读取,反之不行。
这意味着:feature 分支构建时,如果 main 分支已有缓存,可以直接命中。
利用此特性优化 CI:
# 在 main 分支的 workflow 中,确保每次合并后更新缓存
name: Update Cache on Main
on:
push:
branches: [main]
jobs:
update-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Save cache for feature branches
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-npm-main-${{ hashFiles('package-lock.json') }}
4.2 缓存预热(Cache Warming)
对于大型团队,可以设置定时任务在凌晨预先填充缓存:
name: Cache Warmup
on:
schedule:
- cron: '0 2 * * *' # 每天凌晨 2 点
jobs:
warmup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install all dependencies
run: |
npm ci
pip install -r requirements.txt
# 无需显式保存,actions/cache 在 job 结束时自动保存
4.3 缓存大小控制
GitHub Cache 的 10 GB 限制在 monorepo 中容易被填满。监控和清理策略:
# 使用 gh CLI 查看缓存占用
gh actions-cache list --repo your-org/your-repo
# 删除特定缓存
gh actions-cache delete npm-linux-abc123 --repo your-org/your-repo --confirm
在 workflow 中设置清理步骤:
- name: Cleanup old caches
run: |
gh extension install actions/gh-actions-cache
CACHES=$(gh actions-cache list --limit 100 --sort size --order desc | tail -n +6 | awk '{print $1}')
for cache in $CACHES; do
gh actions-cache delete "$cache" --confirm
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
五、命中率量化与监控
5.1 在 workflow 中记录命中率
- name: Cache dependencies
id: cache-deps
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Report cache status
run: |
if [ "${{ steps.cache-deps.outputs.cache-hit }}" == "true" ]; then
echo "✅ Cache HIT — dependencies restored from cache"
else
echo "❌ Cache MISS — dependencies installed from scratch"
fi
5.2 组织级缓存仪表盘
对于使用自托管 Runner 的团队,可以通过 CI 日志聚合统计全局命中率:
# 从 workflow 日志中提取缓存状态
grep -r "Cache HIT\|Cache MISS" /var/log/actions-runner/ \
| awk '{hit[$2]++} END {for(k in hit) print k, hit[k]}'
# 输出示例:
# HIT 1247
# MISS 203
# 命中率:1247 / (1247 + 203) = 86%
命中率目标:
| 场景 | 目标命中率 | 未达标排查方向 |
|---|---|---|
| 日常 CI(锁文件不变) | > 90% | 检查 hashFiles 路径是否正确 |
| 依赖更新后首次构建 | 预期 MISS | 确认 restore-keys 降级策略存在 |
| Docker layer cache | > 70% | 检查 Dockerfile 层顺序是否稳定 |
六、常见问题解答(FAQ)
Q1: 为什么 hashFiles 返回了不同的哈希,但依赖实际上没变?
hashFiles 对文件内容做 SHA256,任何空白字符或行尾变化都会改变哈希。建议只锁定严格控制的锁文件(package-lock.json、Cargo.lock、go.sum),而非 package.json(后者每次添加脚本都可能变化)。
Q2: 缓存是否安全?其他仓库或 fork 能否读取我的缓存?
- 同一仓库内:所有 workflow 和分支共享缓存。
- Fork 仓库:无法读取上游仓库的缓存。PR 来自 fork 时,如果启用了
pull_request_target,缓存行为需要额外审查。 - 跨仓库:默认不共享。可通过
actions/cache的enableCrossOsArchive在组织内探索共享,但需谨慎。
Q3: actions/cache v4 与 v3 的核心区别?
v4 引入了不可变缓存(immutable caches)—— 同一 key 不能被覆盖。这解决了 v3 时代「两个并发 job 同时写入同一 key 导致缓存损坏」的竞态问题。v4 推荐用于所有新配置。
Q4: 如何调试缓存为什么没命中?
在 workflow 中启用 debug 日志:
env:
ACTIONS_STEP_DEBUG: true
然后在日志中搜索 Cache Key: 和 Cache Result:,可以看到精确的匹配过程。
总结
GitHub Actions 缓存不是「加一行 actions/cache 就万事大吉」的魔法。它的效果取决于三个核心设计决策:
| 决策 | 影响 | 黄金法则 |
|---|---|---|
| 键设计 | 决定何时命中、何时失效 | 锁文件哈希 + 操作系统 + 子系统前缀 |
| 路径选择 | 决定缓存体积与恢复速度 | 精确锁定依赖目录,排除 .git 和构建产物 |
| 降级策略 | 决定部分更新时的兜底能力 | 至少保留 2 级 restore-keys 前缀匹配 |
对于 Docker 构建场景,优先使用 type=gha 快速验证,生产环境大型镜像迁移到 type=registry 获取无限容量。在 monorepo 中严格隔离不同子系统的缓存键前缀,避免交叉污染导致的构建不确定性。最后,通过 steps.cache-deps.outputs.cache-hit 持续监控命中率,将缓存从「凭感觉优化」转变为「数据驱动的工程实践」。
延伸阅读:
- GitHub Actions 自托管 Runner — 本地缓存无容量限制的终极方案
- GitHub Actions 矩阵构建策略 — 多平台构建下的缓存隔离与复用
- Monorepo 工程化最佳实践 — pnpm workspace 与 Turborepo 的本地缓存层级
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。