GitHub Actions 工作流性能与并发控制:concurrency、超时与分钟数优化

GitHub Actions 工作流性能与并发控制实战:concurrency 分组与 cancel-in-progress 差异化取消、job 与 step 级 timeout-minutes 超时、needs 依赖图并行化与 max-parallel、缓存键设计与 restore-keys 回退、浅克隆与路径过滤、计费倍率与 self-hosted 分流

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」更多文章

  1. GitHub Actions 本地调试与排错:act、workflow_dispatch、日志与重跑
  2. GitHub Actions 容器作业与 Service 容器:job container、健康检查与集成测试
  3. GitHub Actions 分支保护与 Rulesets:必需检查、CODEOWNERS 与仓库治理