CI 的体感速度由两件事决定:一是单次运行有多快,二是同时跑了多少次无意义的运行。前者靠缓存、并行与路径过滤压缩,后者靠 concurrency 与超时把冗余运行掐掉。本文把 GitHub Actions 工作流的性能与并发控制拆成可落地的几块:concurrency 的分组与取消策略、超时兜底、needs 依赖图并行化、缓存键设计、checkout 与安装开销削减、路径过滤增量构建,以及按计费倍率做分钟数优化,最后给出一份速查表。
一、concurrency 并发控制
1.1 基本语法
默认情况下,同一个 workflow 不限制并发。十分钟内往同一个 PR 推五次提交,就会有五次完整 CI 同时在跑,前四次的结果没人关心,还会在共享的测试库、缓存写入上互相打架。concurrency 用「分组 + 取消」解决:同组内只允许一个运行活跃。
# 工作流级:作用于所有 job
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group 分组名,字符串。同名者互斥,只允许一个活跃运行
cancel-in-progress true 表示新运行到达时取消同组中正在进行的旧运行
false(默认)表示旧运行跑完,新运行排队等待
也可以写在单个 job 上,作用域更小:concurrency 支持在 jobs.<id> 下声明,此时只对该 job 生效。生产部署要设 cancel-in-progress: false,让部署按顺序排队,而不是把跑到一半的部署砍掉留下半成品状态。
1.2 按分支与 PR 分组
# 每个分支独立一组:不同分支互不干扰,同分支的新提交取消旧运行
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# 每个 PR 独立一组:用 PR 编号而不是分支名,避免 fork 分支重名
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
github.ref 在 PR 事件里是 refs/pull/123/merge,天然带 PR 编号,所以第一种写法在多数情况下够用。需要 github.event.pull_request.number 的场景是你在 workflow_run 或 issue_comment 里想追溯回原始 PR。
1.3 只取消 PR 而不取消 main
最常见的策略是:PR 上频繁推送时取消旧运行,但 main 分支上的运行必须全部跑完(可能挂着部署或发布)。
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress 接受布尔表达式,因此可以按引用做差异化。更细的写法是同时放过 release 分支:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(fromJSON('["refs/heads/main","refs/heads/release"]'), github.ref) }}
表达式返回的是字符串 "true" 或 "false",GitHub 会做布尔转换,不必再包一层。
1.4 concurrency 与 environment 的交互
environment 自带一层并发保护:同一个 environment 同时只允许一个 job 部署,这是保护规则的排队机制,与 concurrency 相互独立。
environment 排队 :由保护规则控制,新部署等待旧部署完成,不取消
concurrency 取消 :由 cancel-in-progress 控制,可取消旧运行
同时配置时 :先由 concurrency 决定谁进入队列,再由 environment 决定谁被放行
实践中的坑:给部署 job 同时设了 cancel-in-progress: true 和 environment 保护,一次取消把正在等待人工审批的部署也干掉了,而审批人还在界面上找那个待批的 run。部署类 job 一律 cancel-in-progress: false。
1.5 踩坑清单
分组名没带分支 :group 写成固定字符串,导致 main 和 feature 分支互相取消
group 里塞了 run_id:每次运行分组都不同,等于完全没设 concurrency
用 run_number 做后缀:同样让分组失去意义
push 与 pull_request 双触发:同一提交触发两次,ref 不同则互斥失效
最隐蔽的是最后一条:一个 workflow 同时监听 push 和 pull_request,同一次推送产生两个运行,它们的 github.ref 不同(refs/heads/x 与 refs/pull/N/merge),分组名天然不同,互斥失效。解决办法是二选一触发,或在分组名里用 PR 编号统一。
二、超时控制
2.1 job 级 timeout-minutes
每个 job 的默认超时是 360 分钟(6 小时)。一个卡死的命令可以烧掉 6 小时分钟数,在私有仓库里是实打实的账单。
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
设置原则:把 timeout-minutes 设成正常耗时的大约 2 到 3 倍。正常 4 分钟的测试 job 设 15 分钟,既不会误杀,也能在卡死时快速止损。
2.2 step 级 timeout-minutes
- name: Run integration tests
timeout-minutes: 10
run: ./scripts/integration-test.sh
- name: Start dev server and smoke test
timeout-minutes: 3
run: |
npm run start:test &
sleep 5
curl --max-time 10 --fail --retry 5 http://localhost:3000/health
step 超时到期时该 step 标记失败,job 继续按后续逻辑走(除非是最后一步);job 超时则直接终止整个 job。
2.3 默认 360 分钟的风险
交互式命令等待输入 :git 要密码、apt 要确认、npm login
无超时的网络请求 :curl 不加 --max-time,对端不响应就一直等
服务端启动阻塞 :node server 前台运行,脚本永远不退出
死锁的测试 :并发测试互相等待,pytest 没有超时插件
对应的防御写法:
- name: Install dependencies
timeout-minutes: 8
env:
DEBIAN_FRONTEND: noninteractive
run: sudo -E apt-get install -y --no-install-recommends build-essential
- name: Health check
timeout-minutes: 2
run: curl --max-time 10 --fail --retry 3 http://localhost:8080/health
DEBIAN_FRONTEND: noninteractive 让 apt 不弹确认框,--max-time 给 curl 一个硬上限,两者都能把「等到 360 分钟」变成「几秒内失败」。
2.4 与 continue-on-error 和重试的配合
- name: Flaky integration tests
id: integration
continue-on-error: true
timeout-minutes: 10
run: ./scripts/integration-test.sh
- name: Retry once on failure
if: steps.integration.outcome == 'failure'
timeout-minutes: 10
run: ./scripts/integration-test.sh
continue-on-error 会让 job 的整体结论不因该 step 失败而变红,必须显式检查 steps.<id>.outcome,否则「失败被吞掉」比超时更危险。
三、缩短关键路径
3.1 needs 依赖图
关键路径是整条流水线中耗时最长的那条链。默认所有 job 并行,一旦写了 needs 就变成串行:
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: npm run build
这里 lint 与 test 并行,build 等两者都完成,关键路径是 max(lint, test) + build。如果写成 build: needs: lint 再加 test: needs: build,关键路径就变成三者之和。审查时先画出依赖图,确认没有任何一条 needs 是顺手写的。
3.2 矩阵并行与 max-parallel
strategy:
fail-fast: false
max-parallel: 4
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
max-parallel 限制同时运行的组合数,不缩短总时长,但能避免一次性打满并发额度导致排队,也避免共享测试库被打爆。矩阵维度相乘会爆炸:3 个 Node 版本 × 3 个操作系统 × 2 个数据库 = 18 个 job,每个跑 5 分钟就是 90 分钟的分母。控制维度数量比压缩单 job 时长更有效。
3.3 fail-fast 的取舍
fail-fast: true 是默认值,某个组合失败时立刻取消其余组合,省分钟数、反馈快;fail-fast: false 则所有组合都跑完,能看到完整失败矩阵,便于一次性修多个问题。主分支 CI 建议 fail-fast: false,因为你往往需要知道「是只有 Node 18 挂了,还是三个版本全挂」。PR 上的快速反馈场景可以保持默认。
3.4 拆分巨型 job
一个 job 里塞了 checkout、安装、lint、test、build、上传产物,失败时得从头再跑一遍。拆成多个 job 后:失败重跑只重跑失败的 job(gh run rerun --failed)、lint 与 test 并行缩短关键路径、每个 job 有独立的超时粒度。代价是每个 job 都要重新 checkout 与安装依赖,用缓存与 actions/upload-artifact 抵消后净收益通常是正的。
四、缓存优化
4.1 两类缓存
setup-* 内置缓存 :actions/setup-node、setup-python、setup-java 自带 cache 参数
一行配置搞定,键由 action 维护,推荐优先使用
actions/cache 手动 :缓存任意路径(.venv、target、node_modules、~/.cargo)
灵活但键要自己设计,命中率靠自己调
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
4.2 键设计与 restore-keys
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
key 是精确匹配,命中则直接使用、不执行下载;restore-keys 按顺序做前缀匹配,命中最近的一个作为起点(部分命中);两者都没命中才是真正的未命中,由后续 save 步骤写入新缓存。restore-keys 的价值在于锁文件一变精确键必然失配,但前缀缓存仍能提供大部分依赖,只需增量下载少量变更的包。把 hashFiles 指向锁文件而不是 package.json,避免无关的版本号微调导致整包重下。
4.3 命中率诊断
缓存是否命中,看日志里的这段:
Cache restored from key: cargo-Linux-abc123
Cache not found for input keys: cargo-Linux-abc123, cargo-Linux-
第二种就是未命中,常见原因有四类:key 里含 github.run_id 或 run_number 导致每次运行都不同、hashFiles 指向的文件不存在导致返回空串使键退化成固定前缀、缓存超过仓库容量被 LRU 淘汰、以及跨分支隔离(默认只在当前分支与默认分支间共享)。第二类是高频错误:hashFiles('**/package-lock.json') 在路径拼错时返回空字符串,键看起来正常但实际永远不匹配锁文件内容。
4.4 容量上限与淘汰
单仓库缓存总容量默认 10 GB,单个条目无显式上限但超大缓存上传下载都慢;超出容量后按最后访问时间 LRU 淘汰,7 天未被访问的缓存会被清理。压缩缓存的实用手段:只缓存依赖目录而不是整个 node_modules(改用 ~/.npm 加 npm ci --prefer-offline),把构建产物与依赖缓存放进不同的键,避免一次失效全部失效。
五、减少 checkout 与安装开销
5.1 浅克隆与 sparse-checkout
actions/checkout 默认拉取全部历史,大仓库这一步就能耗掉几十秒:
- uses: actions/checkout@v4
with:
fetch-depth: 1
不需要历史时用 fetch-depth: 1,需要对比上一个提交用 fetch-depth: 2(0 表示全部),需要打 tag 或算版本号则用 fetch-depth: 0 并加 fetch-tags: true。Monorepo 里只关心某个子目录时,可以只检出需要的路径:
- uses: actions/checkout@v4
with:
fetch-depth: 1
sparse-checkout: |
apps/web
packages/shared
sparse-checkout-cone-mode: true(默认)表示按目录前缀匹配的锥形模式,设为 false 则支持 gitignore 风格的通配模式。注意 sparse-checkout 与需要全仓库扫描的工具(全量 lint、依赖图分析)不兼容。
5.2 包管理器离线优先
# npm:优先使用缓存,减少网络往返
npm ci --prefer-offline --no-audit --no-fund
# pnpm:从 store 硬链接,几乎不重复下载
pnpm install --frozen-lockfile --prefer-offline
# pip:优先本地 wheel 缓存
pip install --prefer-binary -r requirements.txt
# composer:从缓存目录安装
composer install --prefer-dist --no-interaction --no-progress
npm ci 比 npm install 快且可复现,--no-audit 与 --no-fund 省掉两次额外的网络请求,在 CI 里几乎没有损失。
六、条件执行与路径过滤
6.1 paths 与 paths-ignore
on:
push:
branches: [main]
paths:
- "src/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/ci.yml"
paths 与 paths-ignore 二选一,不能同时用。文档类改动可以用 paths-ignore 排除,在 pull_request 下写 paths-ignore: ["docs/**", "**.md", "LICENSE"] 即可。
坑在于:如果某个 job 是必需状态检查(required status check),被 paths 过滤掉的 PR 上该检查永远不出现,PR 会卡在「等待状态检查」。这类 job 要么不做路径过滤,要么改用 job 级条件。
6.2 if 表达式
jobs:
test:
runs-on: ubuntu-latest
if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'skip-ci')
steps:
- uses: actions/checkout@v4
- name: Run tests
if: ${{ !cancelled() }}
run: npm test
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: reports/
状态函数里 success() 是默认行为,failure() 表示之前有步骤失败,always() 无论成败都执行,cancelled() 表示运行被取消。上传产物与清理步骤一律用 always(),否则测试失败时你拿不到报告,排查全靠日志翻页。
6.3 dorny/paths-filter 增量构建
Monorepo 里只构建被改动的包:
jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
web: ${{ steps.filter.outputs.web }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
web:
- "apps/web/**"
- "packages/ui/**"
api:
- "apps/api/**"
- "packages/shared/**"
build-web:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: npm run build --workspace=apps/web
改前端只跑 web 构建,改后端只跑 api 构建,两者都改才两个都跑。注意 changes job 的输出通过 outputs 传递给下游,下游用 if: needs.changes.outputs.web == 'true' 判断。
七、分钟数与计费优化
7.1 计费倍率
GitHub 托管的 runner 按操作系统乘倍率计费,消耗的是账户的分钟数配额:
Linux(ubuntu-latest) 1x
Windows(windows-latest) 2x
macOS(macos-latest) 10x
一个 10 分钟的 macOS job 消耗 100 分钟配额,而同样的活儿在 Linux 上只要 10 分钟。能放 Linux 的绝不放 macOS:iOS 构建必须用 macOS,但 lint、单元测试、文档生成完全可以迁到 Linux。免费额度参考:公开仓库不计费,私有仓库 GitHub Free 每月 2000 分钟,Pro 与 Team 3000 分钟,Enterprise 50000 分钟。
7.2 larger runner 与 ARM
jobs:
build:
runs-on: ubuntu-latest-8-cores
timeout-minutes: 10
更大的 runner 单价更高但更快,是否划算取决于任务是否真的受 CPU 限制。ARM runner(ubuntu-24.04-arm)单价低于同规格 x64,对原生 ARM 编译、容器构建提速明显。判断标准:job 在更大 runner 上耗时减少的百分比大于单价上涨的百分比就是赚的。编译、打包、镜像构建通常赚,纯 IO 等待型的 job 通常亏。
7.3 self-hosted 分流
高频、长耗时的任务放到自托管 runner 上,配额压力立刻缓解:
jobs:
heavy-test:
runs-on: [self-hosted, linux, x64, ci]
timeout-minutes: 30
注意维护成本:打补丁、隔离不同仓库的任务、防止恶意 PR 在 runner 上执行任意代码(公开仓库的自托管 runner 尤其危险,PR 触发的 workflow 会跑在宿主机上)。
7.4 取消冗余运行
这是最直接的省分钟数手段,效果常常超过所有优化之和:concurrency 加 cancel-in-progress 掐掉被新提交取代的旧运行,paths 过滤跳过不相关改动,if 条件跳过不需要的分支。一条经验值:开发者在 PR 上平均推送 3 到 5 次,如果全部跑满,取消策略能砍掉其中的 60% 到 80%。
八、度量、观测与速查表
8.1 gh run list 分析时长
# 最近 20 次运行的结论、耗时与分支
gh run list --limit 20 --json displayTitle,status,conclusion,createdAt,updatedAt,headBranch
# 某个工作流的运行历史
gh run list --workflow=ci.yml --limit 50
# 查看单次运行各 job 的耗时
gh run view <run-id> --json jobs
# 重跑失败的 job,不重跑整个工作流
gh run rerun <run-id> --failed
配合 jq 可以算出 p50 与 p95 时长,找出长尾,例如把 --json createdAt,updatedAt,conclusion 交给 jq 求差值再排序即可。
8.2 用 step summary 输出耗时表
- name: Timing summary
if: always()
run: |
{
echo "### 工作流耗时"
echo ""
echo "| 阶段 | 秒 |"
echo "| --- | --- |"
echo "| install | 42 |"
echo "| test | 128 |"
echo "| build | 63 |"
} >> "$GITHUB_STEP_SUMMARY"
$GITHUB_STEP_SUMMARY 支持 Markdown,渲染在运行详情页顶部,比翻日志直观得多。把各步骤的实际耗时写进去,长期看趋势就能定位到是哪一步在变慢。
8.3 速查表
并发控制
concurrency.group 分组名,同名互斥
cancel-in-progress: true 新运行取消旧运行
cancel-in-progress 用表达式 如 ${{ github.ref != 'refs/heads/main' }}
concurrency 写在 job 上 作用域更小,部署 job 常用
超时
jobs.<id>.timeout-minutes 默认 360,建议设为正常耗时 2 到 3 倍
jobs.<id>.steps[].timeout-minutes 单步超时,粒度更细
curl --max-time / DEBIAN_FRONTEND 防挂死的两个常用开关
并行与依赖
needs: [a, b] 等待多个 job
strategy.matrix / max-parallel 矩阵并行与并发上限
strategy.fail-fast: false 跑完所有组合再收尾
缓存
actions/cache@v4 手动缓存任意路径
key 精确匹配,restore-keys 前缀回退
hashFiles 指向锁文件 避免无关变更导致失效
单仓库缓存上限 10 GB,7 天未访问淘汰
checkout 与安装
fetch-depth: 1 浅克隆
sparse-checkout 只检出子目录
npm ci --prefer-offline --no-audit 离线优先安装
路径过滤与计费
on.push.paths / paths-ignore 事件级过滤
dorny/paths-filter@v3 job 级增量判断
if: always() 失败也执行上传与清理
Linux 1x / Windows 2x / macOS 10x 计费倍率
ubuntu-24.04-arm / self-hosted ARM 与自托管分流
观测
gh run list / gh run view --json jobs
gh run rerun <id> --failed
$GITHUB_STEP_SUMMARY 运行页顶部的 Markdown 摘要
总结
工作流性能优化可以按「少跑、快跑、跑得准」三层来做。少跑靠 concurrency 的 cancel-in-progress 掐掉被取代的运行,靠 paths 过滤与 if 条件跳过不相关的改动,这是收益最大、改动最小的一层。快跑靠缓存键设计、浅克隆、依赖离线安装、needs 依赖图并行化和矩阵拆分,把关键路径压到最短。跑得准靠 timeout-minutes 兜底,避免一条挂死的命令烧掉 6 小时配额。
三个最容易踩的坑:concurrency 的分组名不带分支或 PR 编号,导致不同分支互相取消;缓存键里混进 github.run_id 或 hashFiles 指向不存在的文件,缓存永不命中;needs 顺手写成一串,把本可并行的 job 串成关键路径。计费上记住 macOS 是 10 倍倍率,能把任务迁到 Linux 或 ARM 就先迁。
延伸阅读:
- GitHub Actions 缓存优化 — 缓存键与命中率深入
- GitHub Actions 成本优化 — 分钟数与账单控制
- GitHub Actions 矩阵策略 — matrix 与并行度设计
- GitHub Actions 自托管 Runner — 把重活迁出托管 runner
- GitHub Actions 单体仓库 CI — 路径过滤与增量构建
- DevOps 专题 — CI/CD 通用实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。