GitHub Actions 工作流性能优化实战,先看瓶颈,再改缓存和并发

前言

GitHub Actions 跑得慢,很多时候不是某一个配置写错了,而是工作流一开始就没有按耗时来源拆开看。

我之前遇到过一个很典型的前端项目。每次 PR 提交后,CI 要跑十几分钟。看起来是测试慢,实际打开日志以后才发现,依赖安装、构建、重复测试、无关文件触发、旧运行排队,几个问题叠在了一起。单独改某一项都有收益,但真正能稳定提速,还是要先把时间花在哪里看清楚。

GitHub Actions 的优化顺序不应该从"加缓存"开始,而应该先看每个 job 和 step 的耗时。Actions 页面里的可视化图可以看到 job 之间的依赖关系和运行状态,进入具体 job 后也可以查看日志和执行时间。先确认慢在安装依赖、构建、测试、镜像构建、部署,还是排队等待,再决定改哪里。

一、先看时间花在哪里

我一般会先看三类信息。

第一类是 job 之间有没有不必要的串行。比如 lint、test、build 明明互不依赖,却被写在一个 job 里按步骤串行执行。这样任何一个步骤变慢,整个反馈都会变慢。

第二类是单个 step 的耗时。依赖安装、构建、测试、镜像构建通常是最值得看的几个点。不要凭感觉判断,直接看日志时间。很多项目以为测试慢,最后发现每次都在重新下载依赖。

第三类是触发次数。一个工作流单次只跑 5 分钟,但每次改 README、文档、注释都触发完整 CI,一天累计下来也会浪费很多分钟数。还有一种更隐蔽的情况是 PR 连续 push,旧提交的检查还在跑,新提交又开始排队,最后 Actions 页面堆了一串已经没有参考价值的旧运行。

如果想在日志里更清楚地标记阶段,可以给关键步骤加上分组输出:

yaml 复制代码
- name: Install dependencies
  run: |
    echo "::group::npm ci"
    npm ci
    echo "::endgroup::"

- name: Run tests
  run: |
    echo "::group::npm test"
    npm test
    echo "::endgroup::"

这个小改动不会让工作流变快,但能让排查更轻松。真正开始优化前,先把"慢在哪里"拆出来,后面每一步才有依据。

二、依赖缓存先用内置能力

Node 项目最常见的提速点是依赖安装。

很多老配置会直接用 actions/cache 缓存 node_modules,但我现在不建议默认这样做。node_modules 体积大,平台相关文件多,包管理器版本和系统差异也容易带来问题。对 npm、Yarn、pnpm 这类项目,优先使用 actions/setup-node 的内置缓存更稳。它缓存的是包管理器的全局缓存数据,不会缓存 node_modules,并且可以根据锁文件计算缓存 key。

一个常规 Node CI 可以这样写:

yaml 复制代码
name: Node CI

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: package-lock.json

      - run: npm ci
      - run: npm test

这里有几个细节值得保留。

node-version 要明确指定,不要依赖 runner 默认环境。Node 版本变了,安装和测试结果都可能变化。cache: npm 开启 npm 缓存,cache-dependency-path 指定锁文件。Monorepo 里如果锁文件不在根目录,要明确写子目录路径。setup-node 支持 cache-dependency-path 指向子项目锁文件,也支持多个依赖文件。

Monorepo 里可以按 workspace 拆:

yaml 复制代码
name: Monorepo CI

on:
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        workspace:
          - apps/web
          - apps/admin
          - packages/ui

    defaults:
      run:
        working-directory: ${{ matrix.workspace }}

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: ${{ matrix.workspace }}/package-lock.json

      - run: npm ci
      - run: npm test

这样每个 workspace 的缓存更独立。apps/web 改了依赖,不会让 packages/ui 的缓存跟着失效。

如果项目不是 Node,也可以用对应生态的 setup action。Python 可以优先看 setup-python 的 pip、pipenv、poetry 缓存,Java 可以看 setup-java 的 Maven、Gradle、sbt 缓存。只有内置能力不够用时,再自己写 actions/cache

手写缓存时,key 要包含系统、语言版本、包管理器和锁文件哈希:

yaml 复制代码
- name: Cache Maven packages
  uses: actions/cache@v4
  with:
    path: ~/.m2/repository
    key: ${{ runner.os }}-maven-java-25-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      ${{ runner.os }}-maven-java-25-
      ${{ runner.os }}-maven-

缓存命中时会恢复指定路径;没有命中时,如果 job 成功完成,会按当前 key 创建新缓存。缓存内容不能原地修改,策略变了就换新的 key。restore-keys 会按前缀找最近的可用缓存,但它只代表"有东西可复用",不代表依赖完全一致,所以安装命令仍然要跑。

缓存里也不要放 token、私钥、.npmrc 里的敏感凭据。PR 场景下,fork 也可能读取 base branch 的缓存内容,敏感文件一旦进缓存,风险会被放大。

三、能并行的 job 不要写成串行 step

GitHub Actions 里,job 默认可以并行,step 在同一个 job 内按顺序执行。很多工作流慢,是因为把所有东西都塞进了一个 job。

比如这样写:

yaml 复制代码
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

这不是错,但 lint、test、build 全部串行。只要项目稍微大一点,反馈时间就会被拉长。

如果它们互不依赖,可以拆成多个 job:

yaml 复制代码
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build

这样总耗时通常接近最慢的那个 job,而不是三个 job 的总和。

如果 build 必须等 test 通过,再用 needs 控制依赖:

yaml 复制代码
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm run build

矩阵测试也要控制范围。矩阵能把多个系统、多个版本拆成多份 job 并行执行,但维度太多会迅速膨胀。matrix 支持组合变量,也支持 includeexclude 调整组合。

比如只在主版本上跑完整系统矩阵:

yaml 复制代码
jobs:
  test:
    runs-on: ${{ matrix.os }}

    strategy:
      fail-fast: false
      matrix:
        node: [20, 22, 24]
        os: [ubuntu-latest, windows-latest]
        exclude:
          - node: 20
            os: windows-latest
          - node: 22
            os: windows-latest

    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test

这里不是为了少测,而是把资源放在更有价值的组合上。日常 PR 可以跑 Ubuntu 加主 Node 版本,定时任务或 release 分支再跑完整矩阵。这样反馈更快,覆盖也不会完全丢掉。

四、触发条件要挡住无关变更

很多项目的 CI 浪费,不在单次运行,而在触发太多。

如果 README、文档、图片、示例配置改动也触发完整测试,开发体验会被拖慢。可以用 pathspaths-ignore 控制触发条件。pushpull_request 都支持路径过滤,branchespaths 同时存在时,需要两个条件都满足才会触发。需要注意,路径过滤跳过的 required check 可能保持 Pending 状态,所以分支保护里要设计好对应检查。

只在代码相关文件变化时跑 CI:

yaml 复制代码
on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/ci.yml'

如果只是排除文档,可以用:

yaml 复制代码
on:
  pull_request:
    paths-ignore:
      - 'docs/**'
      - '**/*.md'

pathspaths-ignore 不要在同一个事件里同时使用。需要包含和排除混合时,可以在 paths 里使用 ! 负向模式,但顺序会影响结果。

高频提交还要加并发控制。PR 连续 push 时,旧提交的检查通常已经没有意义,可以取消旧运行:

yaml 复制代码
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

github.workflow 能避免同仓库不同 workflow 共用一个并发组导致互相取消。github.head_ref 只在 PR 事件里有值,混合 pushpull_request 时要加 fallback。concurrency 可以放在 workflow 级,也可以放在 job 级;如果要排队而不是取消,可以用 queue: max,但它不能和 cancel-in-progress: true 一起用。

我一般这样分:

text 复制代码
PR 检查:取消旧运行
开发分支 CI:取消旧运行
预览环境部署:通常取消旧运行
生产部署:排队,不随便取消

CI 可以追最新结果,部署要考虑副作用。

五、构建产物和缓存不要混在一起

缓存和 artifact 经常被混用。

依赖缓存解决的是"下次还能复用什么"。比如 npm cache、pip cache、Maven 本地仓库、Gradle 缓存。

Artifact 解决的是"这次构建产出了什么"。比如前端 dist、测试报告、覆盖率报告、部署包。

构建产物应该上传 artifact,而不是塞进 dependency cache:

yaml 复制代码
- name: Build
  run: npm run build

- name: Upload build artifact
  uses: actions/upload-artifact@v7
  with:
    name: web-dist-${{ github.sha }}
    path: dist/
    retention-days: 7

Artifact 默认保留 90 天,也可以通过 retention-days 设置更短保留时间,最小 1 天,最大值会受仓库设置影响。

如果一个 workflow 里先 build 后 deploy,可以用 artifact 在 job 之间传递产物:

yaml 复制代码
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: web-dist
          path: dist/
          retention-days: 3

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v7
        with:
          name: web-dist
          path: dist/
      - run: ./scripts/deploy.sh

这样 deploy 不需要重新 build。构建只发生一次,部署只消费产物。这个结构也更利于审计:你部署的是哪一次构建产物,可以从 workflow run 里直接追溯。

Docker 镜像构建要单独看。它适合用 BuildKit layer cache,不要和 npm、Maven 依赖缓存混在一起:

yaml 复制代码
- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: app:test
    cache-from: type=gha
    cache-to: type=gha,mode=max

我的判断方式很简单:

text 复制代码
依赖下载慢,用 cache
构建产物要传给后续 job,用 artifact
Docker 镜像构建慢,用 layer cache

这三个东西边界清楚以后,Actions 文件会好维护很多。

六、Runner 选择不要只看速度

自托管 Runner 确实能提速,尤其是项目需要固定环境、大量缓存、本地网络资源、专用硬件或内网访问时。比如大型 C++ 编译、Android 构建、私有依赖下载、机器学习任务,这些场景用自托管 Runner 可能会比通用 GitHub-hosted runner 更稳。

但它不是纯粹的性能开关。自托管 Runner 需要自己处理系统更新、安全补丁、网络隔离、磁盘清理、权限控制和监控。如果它跑的是来自 fork PR 的不可信代码,安全风险会更高。对小团队或普通 Web 项目来说,先优化缓存、触发条件、并发和 job 拆分,通常比一上来搭自托管 Runner 更划算。自托管 Runner 的管理、访问控制和安全边界都需要单独设计。

如果 GitHub-hosted runner 不够用,也可以考虑 larger runners。它们比自托管 Runner 少一些维护成本,但仍然要看预算和实际耗时来源。构建本身 CPU 密集,可以换更大 runner;依赖下载慢,换 runner 未必解决;测试设计不合理,换机器也只是暂时缓解。

我会按这个顺序判断:

text 复制代码
先看日志,确认慢在哪里
依赖慢,先做缓存
无关触发多,先做 paths 和 concurrency
任务串行,先拆 job 和 matrix
构建真的吃 CPU,再考虑 larger runner 或 self-hosted runner

总结

GitHub Actions 性能优化,最好从一次真实运行记录开始。先看 job 图和日志,找出最耗时的步骤,再决定改缓存、并行、触发条件,还是 Runner。

依赖安装慢,优先用 setup-nodesetup-pythonsetup-java 这类内置缓存能力。Node 项目不要默认缓存 node_modules,先缓存包管理器的全局缓存数据。Monorepo 要按 workspace 或锁文件拆,不要全仓库混成一个缓存。

任务能并行就拆成多个 job。lint、test、build 没有依赖关系时,不要塞在一个 job 里串行执行。矩阵测试要控制维度,日常 PR 跑关键组合,完整矩阵可以放到 release 或定时任务。

触发条件要拦住无关变更。文档、图片、示例配置不一定需要完整 CI。PR 连续 push 时,用 concurrency 取消旧运行,让最新提交优先得到反馈。部署类任务有副作用,不要随便取消,必要时用队列。

构建产物用 artifact,依赖复用用 cache,Docker 构建用 layer cache。三类东西不要混在一起。Runner 选择放到最后判断,真正需要专用环境或更强硬件时再上自托管或 larger runner。

我的建议是每次只改一两个点,然后对比优化前后的运行时间。CI 优化不是一次性重写 YAML,而是持续把浪费的时间一点点拿掉。这样改出来的工作流更稳,也更容易长期维护。

相关推荐
0x00078 小时前
Git Bash 中无法启动 Claude Code ?
开发语言·git·bash
xuhaoyu_cpp_java8 小时前
Git学习(六)
git·学习
happyness441 天前
Git:AI编程时代的“安全带“与“时光机“
git·ai编程
To_OC1 天前
踩坑无数!终于捋顺Git基础核心工作流(新手必看)
git·程序员
xuhaoyu_cpp_java1 天前
Git学习(三)
经验分享·笔记·git·学习
C137的本贾尼1 天前
Git基本操作(二):add与commit,把文件交给Git管理
git
咸鱼永不翻身1 天前
Git Hooks—提交Commit前检查本地时间是否不对
git·git-hooks·git钩子
John_ToDebug1 天前
如何针对指定目录生成 Git Patch 并精准应用到其他分支
chrome·git
Joy T1 天前
【Web3】Hardhat工程架构中Solidity与TypeChain的协作机制
git·架构·typescript·web3·智能合约·hardhat·typechain