Skip to content
概述
在配置好第一个工作流之后,随着项目迭代,流水线通常会越来越慢。这跟代码量增长没有直接关系,瓶颈主要来自几个固定的耗时环节:
- 依赖下载:每次运行
npm install、pip install或maven build,如果没有缓存,运行器需要从远程仓库拉取全部依赖包。网络延迟和包体积叠加后,依赖安装常常占据总耗时的一半以上。 - 重复计算:即使依赖没有变化,构建步骤仍然会重新编译所有源码。以 TypeScript 项目为例,
tsc每次从零开始编译,而不是复用上次的输出。 - 串行等待:如果需要对 Node.js 18、20、22 三个版本分别跑测试,顺序执行会线性增长总耗时。三个版本各 3 分钟的测试套件,串行跑完就是 9 分钟。
前文介绍了依赖恢复的决策点,但未展开缓存的实际工作机制。以下从缓存键的生成规则开始,完整梳理缓存链路。
依赖缓存:原理与键策略
缓存键的生成与失效规则
GitHub Actions 的缓存不是按项目名称或分支自动匹配的。缓存的唯一标识是缓存键(cache key),一个由用户定义的字符串,最大长度 512 字符 [3]。
缓存键通常使用文件哈希来生成 [5]:
yaml
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}GitHub 计算 package-lock.json 的哈希后,实际键值变为类似 Linux-npm-d5ea0750 的形式。当锁文件内容改变时,哈希变化,键也随之变化。
这个机制带来的行为是:
- 锁文件未变 → 键相同 → 命中缓存
- 锁文件变更 → 键不同 → 未命中 → 作业完成后创建新缓存
注意一个约束:现有缓存内容不可修改,只能用新键创建新缓存 [1]。这意味着每次依赖更新都会产生一条新的缓存记录,而不是覆盖旧记录。
设计缓存键时需要考虑粒度过细的问题。CloudFront 的缓存键设计原则在这里同样适用:如果键中包含高度可变的特征值(比如时间戳或随机数),会导致每个构建都产生不同的键,命中率趋近于零,每次都必须从远程源获取资源 [6]。反过来,如果键过于宽泛(只写 npm-cache 不带任何版本信息),缓存在依赖变更后不会失效,构建会使用过期的缓存内容。
一个工程上常用的键层次是:
<平台>-<包管理器>-<锁文件哈希> ← 精确键
<平台>-<包管理器>- ← 回退键 1
<平台>- ← 回退键 2精确键用于完全匹配。当精确键不命中时,回退键(restore-keys)按前缀匹配查找最近一次生成的缓存,虽然不是最新版本,但至少能减少部分下载量。
缓存的命中与恢复流程
当 actions/cache 步骤执行时,实际的查找顺序是 [2]:
- 在当前分支搜索
key的精确匹配 - 无精确匹配时,搜索
key的前缀匹配(同一分支) - 仍有匹配时,按
restore-keys顺序逐个检查 - 当前分支无结果,则在默认分支(通常是
main)重复以上步骤
精确匹配(缓存命中)时,缓存文件直接还原到 path 指定的目录。前缀匹配或 restore-keys 匹配(部分命中)也会还原缓存,但此时还原的是旧依赖的快照,后续的安装步骤仍然需要增量下载差异部分 [1]。
完全未命中时,缓存步骤不还原任何文件,后续的依赖安装步骤全量下载。作业成功完成后,GitHub 自动以当前 key 创建新缓存。
GitHub Actions 缓存动作配置
actions/cache 用法
actions/cache 是 GitHub 官方提供的缓存动作,目前最新大版本为 v4。
基本配置结构 [3][9]:
yaml
- name: Cache npm dependencies
id: cache-npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
${{ runner.os }}-参数说明:
path:必填。缓存或还原的目标路径。可以是单个目录、多个目录(使用|换行分隔),也支持 glob 模式。路径可以使用~表示用户主目录 [3]。key:必填。缓存键,最大 512 字符,超长会导致步骤失败 [3]。restore-keys:可选。换行分隔的备用还原键列表,在精确匹配失败后按顺序尝试 [3]。enableCrossOsArchive:可选布尔值,默认false。设为true时允许 Windows 与其他操作系统共享缓存 [3]。
多路径缓存示例:
yaml
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}输出参数 cache-hit 是一个布尔值字符串,表示是否命中精确匹配 [4]。可以在后续步骤中使用这个输出来决定是否跳过某些操作:
yaml
- name: Install dependencies (if cache miss)
if: steps.cache-npm.outputs.cache-hit != 'true'
run: npm ci这里用 npm ci 而不是 npm install,是因为部分命中的缓存还原后,node_modules 可能与锁文件不完全一致,npm ci 会先清理再按锁文件严格安装。
矩阵构建:一次定义,多维度执行
矩阵构建(Matrix Build)允许在单个 job 定义中声明多个维度,GitHub Actions 自动为每个组合生成独立的并行任务。
语法入口在 strategy.matrix:
yaml
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm test上面这段配置定义了 3 个 Node.js 版本 × 2 个操作系统 = 6 个并行任务。每个任务的 runs-on 和 node-version 由矩阵变量动态注入。
维度越多,组合数量呈乘积增长。2 个 os × 4 个 node-version × 3 个 database-version = 24 个任务。GitHub Actions 对并行任务有数量限制(免费计划最多 20 个并行任务),超出部分会排队等待。
include、exclude 与 max-parallel
include 用于在矩阵基础上添加特定组合,不受维度约束:
yaml
strategy:
matrix:
node-version: [18, 20, 22]
include:
- node-version: 23
experimental: true这条配置为原有的 3 个任务追加了一个 Node.js 23 的实验性任务。include 可以添加矩阵中不存在的维度字段。
exclude 用于移除不需要的组合:
yaml
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: 18
os: windows-latest移除 Node.js 18 在 Windows 上的组合,总共运行 5 个任务而非 6 个。
当矩阵规模较大时,max-parallel 限制同时运行的任务数:
yaml
strategy:
max-parallel: 4
matrix:
node-version: [16, 18, 20, 22, 23]
os: [ubuntu-latest, macos-latest, windows-latest]15 个组合,但同一时刻最多只有 4 个在执行,其余排队。
并行与并发控制
任务并行
矩阵构建生成的多个任务默认在满足并行限制的情况下同时执行。除此之外,工作流文件中不同 job 之间也是并行关系(除非使用 needs 声明依赖)。
yaml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- run: npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- run: npm run buildlint 和 test 同时运行。build 等待前两者完成后再启动。
并发组
并发组(concurrency)用于限制同一工作流在同一并发组中同时只有一个运行实例:
yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true当同一分支有新的推送触发工作流时,cancel-in-progress: true 会取消正在运行的旧实例,避免队列堆积和资源浪费。
fail-fast
fail-fast 控制矩阵中一个任务失败后其余任务的行为。默认值为 true:
yaml
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]设为 false 后,即使 Node.js 18 的任务失败,20 和 22 的任务仍会继续运行直到完成。在多版本测试场景中,设 fail-fast: false 是有意义的:一个版本的问题不应该阻塞其他版本的测试结果。
缓存与矩阵的配合:多语言版本测试
矩阵构建与缓存结合使用时,缓存键必须包含矩阵的维度变量,否则不同版本的任务会共享同一份缓存,导致内容错乱。
yaml
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node${{ matrix.node-version }}-npm-
- run: npm ci
- run: npm test键中嵌入了 node${{ matrix.node-version }},确保 Node.js 18 的缓存不会误还原到 20 的任务中。
实际运行时,每个矩阵任务独立管理自己的缓存。第一次运行全部未命中,全量安装。第二次运行如果锁文件未变,全部命中,安装时间接近于零。锁文件更新后只影响精确键,回退键仍能还原旧缓存,减少增量下载量。
完整示例:优化前后耗时对比
以一个在 Node.js 18、20、22 上运行测试的 TypeScript 项目为基准,记录首次运行、无缓存二次运行、有缓存运行三种情况。
优化前的工作流(无缓存,顺序执行):
yaml
name: test
on: push
jobs:
test-18:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npm test
test-20:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
test-22:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test优化后的工作流(矩阵 + 缓存):
yaml
name: test
on: push
jobs:
test:
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node${{ matrix.node-version }}-npm-
- run: npm ci
- run: npm test假设项目 npm ci 耗时 90 秒,npm test 耗时 60 秒。
首次运行(无缓存):
- 优化前(顺序):3 个 job 各 150 秒 = 450 秒
- 优化后(并行 + 首次全量安装):所有 job 并行启动,最慢的一个约 150 秒
二次运行(锁文件未变,缓存命中):
- 优化前:仍然是 450 秒(无缓存机制)
- 优化后:
npm ci依赖缓存还原几乎即时完成,仅测试耗时 60 秒,总耗时约 60 秒
总耗时从首次的 450 秒下降到稳定态 60 秒。实际项目中,测试耗时通常大于依赖安装,但缓存消除的 90 秒 × 3 = 270 秒的网络下载时间是实打实削减掉的。
常见陷阱与注意点
- 缓存键包含
runner.os但忘记处理 Windows 路径差异。Windows 上的用户主目录是~/AppData/...,与 Linux/macOS 的~/.npm不同。缓存path需要与运行环境匹配,或使用enableCrossOsArchive: true强制跨平台共享(但依赖中如果包含原生模块,交叉共享可能导致二进制不兼容)。 - 过于宽泛的回退键造成过期缓存。
restore-keys中如果只写了${{ runner.os }}-,可能匹配到几天前的缓存,还原后npm ci仍然需要大量下载。回退键的粒度应控制在可接受的增量范围内。 - 缓存路径包含敏感信息。任何对仓库有读取权限的用户都可以通过拉取请求访问缓存内容。分支也可以在基础分支上创建拉取请求并访问基础分支的缓存 [8]。永远不要在缓存路径中放置访问令牌、私钥或登录凭据。
- 矩阵规模控制不当导致排队。2 个 os × 5 个 node-version × 4 个 browser = 40 个任务,但免费计划并行上限 20 个。20 个任务运行期间,另外 20 个在等待,总耗时并没有充分利用并行优势。使用
exclude剔除无意义的组合,并设置合理的max-parallel。 fail-fast默认值在不同的测试策略中产生不同影响。如果测试套件按版本独立,设fail-fast: false能拿到完整的失败矩阵,便于定位问题范围。如果任务是部署步骤的同构扩展,保留true可以尽早终止并节省时间。- 频繁推送导致缓存膨胀。锁文件每次变更都创建新缓存,但 GitHub 的缓存空间有总量上限(7 天未访问的缓存会被自动清理)。高频变更项目的缓存条目会快速累积,旧的缓存被淘汰后,部分回退键可能匹配不到任何内容。这是缓存策略固有的容量限制,不是配置问题。
参考链接
- GitHub Docs — 依赖项缓存参考:缓存命中与失误的行为定义。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching
- GitHub Docs — 依赖项缓存参考:缓存还原的搜索顺序与分支优先级。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching
- GitHub Docs — 依赖项缓存参考:
actions/cache输入参数说明与配置示例。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching - GitHub Docs — 依赖项缓存参考:
cache-hit输出参数与条件步骤用法。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching - GitHub Docs — 依赖项缓存参考:使用
hashFiles自动生成缓存键。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching - AWS CloudFront — 了解缓存键:缓存键设计原则与命中率影响因素。https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/understanding-the-cache-key.html
- AWS 规范性指导 — CI/CD 管线的使用方式:小规模频繁合并在持续集成中的作用。https://docs.aws.amazon.com/zh_cn/prescriptive-guidance/latest/strategy-cicd-litmus/cicd-best-practices.html
- GitHub Docs — 依赖项缓存参考:缓存中的敏感信息安全限制。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching
- GitHub Docs — 依赖项缓存参考:使用
actions/cache缓存 npm 依赖的完整工作流示例。https://docs.github.com/zh/enterprise-cloud@latest/actions/reference/workflows-and-actions/dependency-caching
