CI 流水线慢,团队就开始"跳过测试"“只在本地跑”——质量防线失守。但很多 CI 的慢,不是"测试本来就慢",而是没做缓存、没做并行、依赖排队。本指南带你拆解流水线的时间都花在哪,以及如何通过三层缓存(依赖缓存、BuildKit 层缓存、编译/测试缓存)、并行矩阵与 Merge Queue 把 CI 时间从 40 分钟稳定压缩到 5 分钟。
目录
- 1. 先定位瓶颈:流水线时间花在哪
- 2. 第一层:依赖缓存(requirements/package-lock)
- 3. 第二层:BuildKit 镜像层缓存
- 4. 第三层:编译与测试缓存(增量构建)
- 5. 缓存键设计:Cache Key 的艺术
- 6. 并行矩阵与 Job 编排
- 7. Merge Queue:合并队列与流水线整合
- 8. 一个可复用的优化路径
- 9. 生产最佳实践与避坑
1. 先定位流水线时间花在哪
1.1 别盲目优化,先量一下
流水线各环节耗时占比(常见):
- 依赖安装(pip/npm/go mod download) 30~40% ← 最常见瓶颈
- 镜像构建(Docker build) 15~25%
- 编译/打包(compile/tsc/webpack) 15~20%
- 测试执行 10~30%
- 部署 5~10%
第一步:先跑一次流水线,看哪个 step 最久
GitHub Actions:Actions 页看每个 job 的耗时
GitLab CI:Pipeline 图看每 job 时长
先别急着加缓存——先知道慢在哪
1.2 慢的三种"非内容型"原因
真正拖慢 CI 的,常常不是代码多,而是:
1. 串行依赖:job B 必须等 job A 做完 → 没有并行
2. 缓存没配:每次重新下载/重复构建 → 浪费
3. 排队阻塞:并行数限制,作业排队等待
4. 冷启动:build 架构/镜像拉取每次都作废
本指南的核心:把"重复计算"变成"命中缓存",把"串行"改成"并行"
2. 第一层:依赖缓存
2.1 为什么依赖最容易拖慢
依赖下载通常占 CI 30%+ 的时间:
- 几百个 package/npm/gem/pip 每次全量下
- 网络往返、解压、写入都耗时
解决办法:把依赖缓存下来,只有 lock 文件变化才重下
2.2 在不同 CI 上配置
# GitHub Actions:用 actions/cache 缓存依赖
- uses: actions/cache@v3
with:
path: |
~/.cache/pip
~/.npm
node_modules
key: deps-${{ runner.os }}-${{ hashFiles('**/requirements.txt', '**/package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
# GitLab CI:用 cache 关键字(挂载到缓存卷)
cache:
key:
files:
- requirements.txt
- package-lock.json
paths:
- node_modules/
- .cache/
ℹ️ 核心:缓存键用"依赖清单的 hash"。清单没变 → 命中缓存;清单变了 → 重新下载并更新缓存。这样绝大多数提交都能秒速复用依赖。
2.2 语言各自的最佳缓存点
| 语言 | 缓存路径 | 键依据 |
|---|---|---|
| Python (pip) | ~/.cache/pip | requirements.txt |
| Node (npm/pnpm) | node_modules / ~/.npm / .pnpm-store | package-lock.json |
| Go | $(go env GOCACHE), pkg/mod | go.sum |
| Gradle | ~/.gradle/caches | build.gradle 全 hash |
| Maven | ~/.m2/repository | pom.xml |
3. 第二层:BuildKit 镜像层缓存
3.1 为什么要层缓存
Docker 构建按图层号缓存:
若 BASE 层没变,后面层可复用旧缓存 → 秒级构建
但默认 Docker 后端不做远端缓存(每次重拉)
BuildKit 提供:本地缓存 + 远端类型导入(registry / S3)
加成:多阶段构建 + 依赖层在前 → 依赖不变时缓存命中率极高
3.2 启用 BuildKit 远端缓存
# GitHub Actions 使用 docker/build-push-action + 缓存
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
cache-from: type=gha # 利用 GitHub Actions 缓存
cache-to: type=gha,mode=max
cache 类型:
- type=gha GitHub Actions 专用(简单好用)
- type=registry (docker.io/org/cache) 场景主常用(推远端 registry)
- type=local 本地/CI 卷缓存
mode=max:缓存所有中间层(大发减构建时间)
注意:缓存键通常 = 镜像 registry 的 tag/digest;
多层 cache-from 会让 CI 时快,但首次 push 体积较大
3.3 优化 Dockerfile 顺序以最大化缓存
# ✅ 把"变化少的层"放前面,依赖层尽量前移
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./ # 先复制依赖清单(变化少的)
RUN npm ci --prefer-offline # 依赖层缓存
COPY . . # 再复制源码(变化多的)
规律:COPY . .(源码)应该尽量靠后。
源码一变 → 之后所有层都重建;
依赖 list 几乎不变 → npm ci 层可以长期命中缓存
4. 第三层:编译与测试缓存(增量构建)
4.1 编译缓存
语言级增量构建:
- Go: `-cache`(编译缓存自动)
- Gradle: 本地/远端 build cache(不变就不重编译)
- Maven: incremental build / parallel
- TypeScript: tsc --incremental / esbuild 缓存
GitHub Actions 缓存编译产物:
把 GOCACHE / ~/.gradle / target 缓存,配上 key
→ 只有相关源码变化才重编译那部分
4.2 测试结果缓存(跳过未变)
高阶:测试"选择性执行"
- Gradle: 输入变化时才重跑影响到的测试
- 工具:pytest 的 --cache / 专门的 choose-tests
- 更严谨的选择器:只运行受影响影响的包(依赖图分析)
注意:选择测试有漏测风险——
建议"增量执行 + 每日全量兜底",保安全。
5. 缓存键设计:Cache Key 的艺术
5.1 键的三种粒度
1. 精确键(cache-level HASH):
命中率高但一换 lock 就全部失效(重建)
key: cache-${{ hashFiles('lock') }}
2. 恢复键(restore-key 宽松):
即使精确键 miss,也能回退到旧的近似缓存
restore-keys: cache- (会在其下一个次最接近的用上)
3. 组合键(lang-version-lockhash):
版本+lock 一起,既稳定又分版本
key: cache-linux-node20-${{ hashFiles('package-lock.json') }}
5.2 缓存失效的正确姿势
缓存需要主动"失效"场景:
- 依赖升级(lock 变)→ 必然失效
- 平台/CI runner 换(OS/arch 变)→ key 里带上 runner 特征
- 包管理器版本升级 → key 带上
- 避免:把"活的/会变的"当 key 尾(如时间戳)→ 永远 miss
经验:
- key 尽量稳定(否则命中率低)
- 用 restore-keys 兜底,减少"完全重下"的次数
6. 并行矩阵与 Job 编排
6.1 把串行改成并行
# GitHub Actions:不同维度并行跑一个矩阵
strategy:
matrix:
os: [ubuntu-latest, windows-latest] # 跨 OS 并行
node: [18, 20] # 跨版本并行
# 矩阵展开 = os×node 的组合 → 充分利用并行
# 这对你只是时间和 Runner 成本换并行
作业拆分:
- lint / unit / build / e2e 应并行(同时开始)
- 存在依赖才串行(build → e2e 依赖产物)
- 用 concurrency 防止多余 job 排队(取消旧 run)
进阶:依赖图(DAG)
- .needs 表达依赖,无依赖的并行、有依赖的等
- 把大 job 拆成小 job(build 一份 → 各 job 用产物)
用 upload/download artifact 传递,避免重复 build
6.2 利用并发上限与排队
CI 并发不是无限:
- 免费 GitHub Actions 并行有限 → 别开太多 matrix
- 自建 runner 注意 Hub 队列
- 超大 monorepo 用"受影响模块选择" + 并发备份
目标:让整个流水线"最短路径"通过,而不是"每个 job 都快"
7. Merge Queue:合并进流水线整合优化
7.1 为什么要有 Merge Queue
并发合并问题:
- 两个 PR 都基于老 main,试跑都绿
- 先后合入 → 合入后的实际状态冲突/挂掉
- Merge Queue(合并队列)解决:把要合入的 PR
排队,队列里用最新 main + 改动重新跑测试
会带来的好处:
- main 永远"绿进"
- 测试只跑"合并后 real"(不是我 merge 前 fake 绿)
- 减少互相 覆盖 的冲突
7.2 配置 Merge Queue
# GitHub 需要在仓库启用 merge queue 策略 + 分支保护
# 与并行 CI 结合,让合并队列的被测变更始终是真实最新
结合点:Merge Queue 跑"真实合并"流水线,
因此缓存 keys 里要反映 merge base——用合并起始 commit 计算,
避免缓存与真实合并状态冲突
8. 一个可复用的优化路径
8.1 从 40 分钟到 5 分钟的路线
0. 先度量:看哪些 step 最赚钱
1. 依赖缓存:先把 pip/npm/go 缓存配对(最容易的收益)
2. BuildKit 镜像缓存:Docker 副产物(layer cache 命中)
3. 编译/测试缓存:Gradle/GOCACHE/TS --incremental
4. 并行化:拆分无依赖 job,用 matrix+取舍切换
5. CI 时长暴露队列:加 runner / 合并
6. 收敛:每周看 CI 平均时长,做版本回归
把"优化"变成"度量×兑现"的循环,而不是一次冲动
8.2 关键指标
CI 优化要盯的:
- 平均 CI 时长(绿为主)
- 缓存命中率(依赖/镜像/测试)
- 队列/排队时长
- PR 平均到合并时间
- 测试覆盖率/漏测警示
9. 生产最佳实践与避坑
9.1 Checklist
□ 依赖缓存:key 用 lock 文件 hash + restore-keys 兜底
□ BuildKit 远端缓存(gha / registry)提升构建
□ Dockerfile 依赖层前移,最大化图层缓存
□ 编译/测试缓存(GOCACHE/Gradle/incremental)
□ 并行:拆分独立 job + matrix + artifact 复用
□ Merge Queue:真实合并状态下测试
□ 缓存键带 OS/arch/版本,避免失效
□ 用增量测试 但定期全量兜底
□ 持续度量 CI 时长与缓存命中率
9.2 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 缓存永远 miss | 和没配一样 | 键别带时间戳,用 hash+restore |
| 缓存太大 | 上传慢 | 收敛路径,分语言/阶段 |
| 只加缓存不并行 | 耗时没降 | 并行矩阵 + 独立 job |
| 串行依赖隐藏 | 瓶颈被藏 | 看管线 Graph 拆无依赖 |
| Merge Queue 不加 | 主分支偶红 | 用合并队列 + 保护 |
| 测试全量跳过 | 漏测 | 定期全量兜底 |
9.3 一句话原则
CI 提速 = 缓存消化重复 + 并行摊平批次 + 队列守真实合。
小结
CI/CD 性能优化是"收益最直接、最被低估“的工程投入:把重复的依赖安装、镜像层、编译结果缓存起来,把无依赖的 job 并行化,用 Merge Queue 保证真实合并状态。落地记住五件事:先度量再优化、依赖缓存收益最快、BuildKit 层缓存 & 顺序最大层命中、并行矩阵 + artifact 复用、Merge Queue 保真实绿。当 CI 从"不敢跑嫌慢"变成"人人随手跑、秒级反馈"时,测试和安全门禁才真正成为团队的默认习惯,而不是被绕开的负担。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。