2026 年初我加入新组,接手一个 Node.js + TypeScript + Vite 的中型 monorepo:5 个应用 + 12 个共享库,代码 18 万行。第一周我感受到最痛的不是代码本身,是 CI/CD——每次 push 到 GitHub,等 PR check 都要 28 分钟才出绿勾,改一行代码 review 也要等 28 分钟。日常迭代被这条流水线拖到崩溃,组里五个人每天合计在 CI 上"等待"超过 10 小时——一周就是 50 人小时的纯损耗。我下了决心:第一个月不写新功能,就把这条流水线打下来。
3 周后,流水线时间从 28 分钟压到 4 分钟,组里每个人都肉眼可见地"流畅"了。这次优化没用什么黑魔法,就是 30 个具体的小优化叠加——每一个单独看都"也就省 30 秒",合起来就是 24 分钟。这篇是完整复盘,涵盖 GitHub Actions 流水线优化的 6 个大方向、每个方向 4-5 个具体技巧、Docker layer cache 的实战、monorepo 增量构建的设计,以及落地的《CI/CD 流水线性能纪律》。如果你也在被 CI 时长拖累,这篇大概率能直接抄,把团队心跳从 28 分钟节拍调回 4 分钟节拍。
背景:这条 28 分钟的痛苦流水线
| 维度 | 数值 |
|---|---|
| 代码 | Node.js + TypeScript,monorepo 5 应用 + 12 库,18 万行 TS |
| 包管理 | pnpm 9,workspace 模式 |
| 构建 | Vite 5 + tsc(类型检查)+ Vitest(测试) |
| CI/CD | GitHub Actions,自托管 runner(4 vCPU + 8GB) |
| 每天触发 | ~ 80 次(PR + push) |
| 事故前耗时 | 28 分钟 / 次 |
| 损耗估算 | 5 人 × 12 次等 × 5 分钟 等 = 5 人·小时 / 天 |
| 团队心智状态 | "攒一批改动再 push"、"宁可本地手测也不敢推一行试错" |
这些数字最扎心的不是 28 分钟本身,而是它把团队心智完全调到了"批量主义"——5 个工程师每个人脑子里都有"等下一起 push"的待办堆栈,小改动从来不愿意单独 PR。这种心智惯性比时间损耗更可怕,因为它会把"试一试这个想法"的本能扼杀在源头。一个工程团队最值钱的资产是"敢试错的速度",CI/CD 时长就是这条速度曲线的最大阻力。修流水线本质上是修团队心智模式,不是修工具。
事故时间线:从决心到落地的 3 周
| 周 | 动作 | 关键产出 |
|---|---|---|
| Week 1 周一上午 | 跟组里五个人各聊 15 分钟问 CI 痛点,统一对齐"压到 5 分钟"目标 | kickoff 文档 + 优化空间地图 |
| Week 1 周二到周四 | 性能基准 + 大方向定位(cache / 并行 / 增量 / Docker / 测试 / 工具) | 每个 step 的耗时表 + 优化优先级 |
| Week 1 周五 | 设计目标流水线骨架,定下"6 大方向 + 30 项优化"清单 | workflow.yml 改造蓝图 |
| Week 2 | 逐项优化 + A/B 验证,从 28 分钟优化到 12 分钟 | install / lint / 矩阵拆分 / 并行 job 落地 |
| Week 3 前 3 天 | 深度优化(Docker buildkit cache、test 选择性运行、turbo cache) | 12 分钟到 6 分钟 |
| Week 3 后 2 天 | 抠最后几个尾巴(checkout cache、actions/cache key 调优、self-hosted 升级) | 6 分钟到 4 分钟 |
| Week 4 周一 | 面向全组复盘 + 把所有优化沉淀成 SOP 文档 + 加监控告警 | 《CI/CD 流水线性能纪律》上线 |
第一步:测量 — 流水线时间花在哪
优化前必须知道时间花在哪。直接拍脑袋觉得"应该是 install 慢"或"测试肯定慢"这种猜测,每次最后被打脸都是因为根本没量。GitHub Actions 的 Job 视图给出每个 step 的耗时,我把所有 step 列出来排序——这一步看似无聊,但它是后面所有优化决策的锚点。没有这张表,任何"优化方案"都是空中楼阁。
| step | 原耗时 | 占比 |
|---|---|---|
| checkout | 20s | 1% |
| setup-node | 15s | 1% |
| pnpm install | 4m 30s | 16% |
| tsc --noEmit(类型检查) | 6m 20s | 23% |
| vitest run(单元测试) | 5m 10s | 18% |
| vite build(5 个 app) | 4m 40s | 17% |
| eslint . --max-warnings 0 | 2m 20s | 8% |
| playwright(e2e) | 3m 50s | 14% |
| docker build + push(5 镜像) | 4m 10s | 15% |
| misc(deploy 等) | 30s | 2% |
(总和 > 100% 因为有些是并行的,但实际线性化后 28 分钟)
核心瓶颈:tsc / 测试 / build / docker 这四块占 70%。优化重点也就是它们。剩下的 install / eslint / e2e 也都不小,但 ROI 排序后是第二梯队。这种"先打头部"的策略在性能优化里几乎永远成立——不是说尾部不重要,是说在你时间有限的前 3 天里只攻头部能换到最大正反馈,反过来"先优化 lint 因为最简单"就是经典的"瞎忙陷阱"。
因果链:为什么这条 28 分钟流水线天然就这么慢
这张图最关键的信息是没有任何一个步骤本身算"慢"——install 4 分半在大 monorepo 里属正常,tsc 6 分 20 也"差不多",docker build 4 分钟也常见。但是把"每个看起来正常的步骤"线性叠加,就是 28 分钟。没有人愿意承担"我去优化流水线"的责任,因为每一个 step 单独看都不至于"明显有问题"。这是典型的"温水煮青蛙"工程债——所有人都习惯了它,直到有新人加入用刚来的视角看,才发现这条流水线已经成了文化级的拖累。我们后来内部叫这种问题"渐进式工程腐烂",任何流水线 / 构建系统 / 测试套件每季度必须强制盘点一次"端到端时长趋势",防止它再次悄悄爬回来。
优化方向 1:依赖缓存
1.1 pnpm store cache
原版每次都 pnpm install,4 分 30 秒。pnpm 的 store 是全局的,可以 cache:
- name: Setup pnpm
uses: pnpm/action-setup@v3
with: { version: 9 }
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
缓存命中后,pnpm install 从 4m30 降到 35s(只需要符号链接和验证,不需要下载)。-3m55s。这一步是所有优化里"投入产出比最高"的——5 分钟的配置改动换 4 分钟稳定收益,而且只要 lock 文件没大变,缓存命中率在生产中可以稳定在 92% 以上。强烈建议任何 monorepo 第一天就把这个开关打开,哪怕暂时还没要全面优化流水线。
1.2 node_modules 直接 cache
更激进——cache 整个 node_modules:
- uses: actions/cache@v4
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
key: nodemodules-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
缓存命中时直接跳过 install,0 秒。代价是 cache 大(monorepo 通常 1-2GB),GitHub Actions 单 cache 上限 10GB,小心溢出。我们用的折中方案是"main 分支专属一份 cache + PR 分支 restore-keys 兜底",这样 main 分支永远是最新依赖、PR 分支命中也快,且不会被某个奇怪分支污染 cache 名额。
1.3 setup-node 自带 cache
setup-node action 直接支持 npm/pnpm/yarn cache:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm' # 自动 cache pnpm store
简单方便,效果类似 1.1。对小项目来说这是"开箱即用"的最佳选择,代价是少了 restore-keys 的灵活度——如果 lock 文件经常微调(monorepo 几乎一定会),命中率会比手写 actions/cache 低 10-15%。所以中大型 monorepo 我们还是推荐 1.1 的手写版本。
优化方向 2:并行化
2.1 拆 job 并行跑
原版所有 step 串行。改成多 job 并行:
jobs:
install:
runs-on: self-hosted
steps:
- ...install + cache node_modules...
lint:
needs: install
runs-on: self-hosted
steps:
- ...eslint...
typecheck:
needs: install
runs-on: self-hosted
steps:
- ...tsc...
test:
needs: install
runs-on: self-hosted
steps:
- ...vitest...
build:
needs: install
runs-on: self-hosted
steps:
- ...vite build...
lint / typecheck / test / build 都依赖 install,但相互独立,并行跑。原本 14m → 现在 max(lint, tsc, test, build) = 6m。-8m。这一步看似"显而易见",但真要落地有个隐形坑:每个 job 都要重新 checkout + 恢复 node_modules,如果 cache key 设计不好,4 个并行 job 同时下载 1.5GB node_modules 反而把网络带宽打爆。我们一开始就踩了这个坑——并行后总时长不降反升,后来用 actions/upload-artifact 在 install job 末尾把 node_modules 打包成 artifact,4 个后续 job 各自 download-artifact 就快了。这种"看似并行实则串行排队"的陷阱在大 monorepo CI 里非常常见。
2.2 矩阵拆分大 job
test job 跑所有 5 个应用 + 12 个库,5 分钟。用 matrix 拆成 5 个并行 job:
test:
needs: install
strategy:
matrix:
package: [app-web, app-admin, app-mobile, lib-shared, lib-ui]
steps:
- run: pnpm --filter ${{ matrix.package }} test
5 并行,变成 1 分钟。-4m。注意 matrix 拆分有"边际收益递减"——拆到 5 个 shard 收益最大,拆到 10 个就被 runner 启动开销吃掉大半。我们经验是"单个 shard 跑 1-2 分钟"最佳;低于 1 分钟说明拆过细,高于 2 分钟说明还能再拆。
2.3 Vitest 自身用 thread pool
// vitest.config.ts
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
maxThreads: 4,
minThreads: 2,
}
}
}
})
Vitest 默认就用多线程,但 maxThreads 默认是 CPU 数。在 self-hosted runner 上调到 4,跑得更快。同时注意 minThreads:2 这个参数,默认是 1,如果不调,小项目下 vitest 会"懒启动"线程导致预热慢,设到 2 让它立即就绪。这种细节优化在单个 PR 里看不出差,跑 80 次 / 天就能省下不少时间。
优化方向 3:增量构建
3.1 Turborepo cache
monorepo 的杀手锏:Turborepo 提供"任务级 cache"——只重新跑改了的 package:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".vite/**"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"cache": true
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": [".tsbuildinfo"],
"cache": true
}
}
}
# CI 里
- uses: actions/cache@v4
with:
path: |
.turbo
packages/*/dist
packages/*/.vite
packages/*/.tsbuildinfo
key: turbo-${{ github.sha }}
restore-keys: |
turbo-
- run: pnpm turbo run build test typecheck
Turbo 通过 hash 输入(代码 + 依赖 + 配置)判断是否需要重新跑。如果某个 package 没改,直接复用缓存,0 秒。典型场景下,一次 PR 只改 1-2 个 package,其他 15 个 package 全部 cache hit。build / test / typecheck 总耗时从 7 分钟降到 ~ 1 分钟。这是整轮优化里"单点收益最大"的一项,几乎一项就吃掉了一半时间预算。任何 monorepo 都应该认真评估 Turborepo / Nx——如果你还没用,这条意味着你的 CI 时长里大概率有 50% 是"重复 build 没改过的代码"的纯浪费。
3.2 tsc --incremental
// tsconfig.json
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
# CI 里 cache .tsbuildinfo
- uses: actions/cache@v4
with:
path: '**/.tsbuildinfo'
key: tsbuildinfo-${{ github.sha }}
restore-keys: |
tsbuildinfo-
tsc 增量编译只重新检查改动的文件,典型 PR 下 6m20 降到 ~ 30s。这是 tsc 团队多年来稳步打磨的功能,但因为默认关闭,90% 项目都没启用——也是为什么"TypeScript 项目 CI 慢"几乎成了刻板印象。其实 tsc 本身完全跟得上,只是大多数项目没把 incremental 模式打开。Turbo + tsc incremental 双保险后,我们的类型检查从 6m20 降到 8s。
3.3 Vite 增量构建
Vite 默认就有 cache,但 CI 环境每次都干净,需要显式 cache .vite 目录:
- uses: actions/cache@v4
with:
path: |
.vite
packages/*/node_modules/.vite
key: vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
优化方向 4:Docker 镜像构建
4.1 BuildKit + 多阶段
# Dockerfile
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY packages/*/package.json ./packages/*/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm fetch && pnpm install --frozen-lockfile --offline
FROM deps AS build
COPY . .
RUN pnpm build
FROM base AS runtime
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
CMD ["node", "/app/dist/server.js"]
BuildKit 的 --mount=type=cache 把 pnpm store 挂载为持久 cache,层间复用。多阶段构建让镜像最小化(只含运行时需要的)。BuildKit 这个 mount cache 是被严重低估的功能——它和"COPY 进镜像"的根本区别是:cache mount 不会进最终镜像,所以可以 cache 海量临时数据而不影响最终镜像大小。我们的 pnpm store 在镜像构建过程中有 1.8GB,但最终镜像只有 220MB。
4.2 GitHub Actions 的 Docker cache
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha 用 GitHub Actions 自带的 cache backend,跨 workflow 复用 Docker layer。典型场景下镜像 build 时间从 4m10 降到 40s。这个 type=gha cache 在 2024 年才稳定可用,在它出现之前,Docker layer cache 在 CI 里几乎是噩梦——要么用第三方 registry 自己存(复杂),要么不 cache(慢)。现在直接一行配置就能用,强烈建议立即升级。
4.3 减少 Dockerfile layer + 减少 COPY 触发
关键技巧:把"变动频率低"的内容放前面 COPY,"变动频率高"的放后面。我们的镜像中,package.json + lock 文件变得少,所以放在前面;源码每次都变,放在后面。这样大多数 build 都能复用前面的 layer。这条"层级顺序"在 Dockerfile 教程里被讲烂了,但实际项目里 80% 的 Dockerfile 还是把 COPY . . 放在最前面——一改任何文件就让所有后续 layer 全失效。我们组里把这条写进了 code review 检查项,任何新 Dockerfile PR 必须明确"为什么 COPY 顺序是这样"。
优化方向 5:测试选择性运行
5.1 只跑改动相关的测试
Turborepo 自动做这个——它知道哪些 package 改了,只 test 它们。手动实现也可以:
- name: Detect changed packages
id: changes
run: |
git diff --name-only origin/main HEAD | \
awk -F/ '{print $2}' | \
sort -u > changed_pkgs.txt
- name: Test only changed
run: |
for pkg in $(cat changed_pkgs.txt); do
pnpm --filter $pkg test
done
这种"选择性测试"在 PR 流水线很合适——main 分支 push 还是跑全量,确保 main 永远绿。不要把这种"省时间"逻辑用到 main 上,否则总有一天会"我没改这个 package 但它的依赖变了所以 broke",而 CI 还是绿的——那种事故的代价比省下来的 CI 时间高得多。这条纪律我们后来写进了 SOP:"PR 加速 / main 全量",绝不混用。
5.2 e2e 测试拆分 + 并行
Playwright e2e 跑 3m50,拆成 4 个 shard 并行:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pnpm playwright test --shard=${{ matrix.shard }}/4
变 ~ 1 分钟。-3m。Playwright 的 shard 算法会自动平衡每个 shard 的预计运行时间,所以"某个 shard 巨慢拖累整体"的概率很低。注意 shard 数也不要拉太大——每个 shard 启动 Chromium / Firefox 都有固定开销,4 个 shard 是甜点,8 个就开始亏。
优化方向 6:工具替换
6.1 eslint → oxlint(Rust 实现)
oxlint 是 Rust 写的 JS/TS linter,比 eslint 快 50-100 倍。我们做了部分迁移(规则少一些,但核心规则都有):
- run: pnpm oxlint . # 替代 pnpm eslint . --max-warnings 0
2m20 → 8 秒。-2m12。代价是部分自定义 eslint 规则没有,需要单独处理。我们的折中方案是"CI 跑 oxlint 做快速反馈,pre-push hook + 周一全量 main run 跑完整 eslint",这样既享受 oxlint 速度,又不丢自定义规则保障。biome 是另一个选项,生态更完整些,但当时 oxlint 在 TS 大型 monorepo 上跑得最稳,所以我们选它。
6.2 pnpm → bun(实验,未生产)
Bun 的安装速度比 pnpm 还快 30-50%。我们测试了下,但生态兼容性问题还存在(部分 node-gyp 模块构建失败),没切到生产。这是个"还在等 12 个月成熟"的技术,值得每季度回头试一次,但暂时不建议生产用。
6.3 self-hosted runner 升级
从 4 vCPU + 8GB 升到 8 vCPU + 16GB,所有 CPU-bound 步骤(build / tsc)直接 2x 速度。代价是 runner 成本翻倍,但和工程师时间损耗比,完全划算——一个工程师一天 1 小时被 CI 拖,5 人组就是 5 小时 / 天,按工程师时薪算月成本远超 runner 升级。这个简单算账后来我们直接拿去说服了财务和运维,5 分钟批准了升级。
最终成绩 + 时间分配
| step | 原耗时 | 优化后 |
|---|---|---|
| checkout + setup | 35s | 15s |
| pnpm install | 4m 30s | 15s(cache 命中) |
| tsc(typecheck) | 6m 20s | 30s(turbo + 增量) |
| vitest(test) | 5m 10s | 50s(matrix + turbo) |
| vite build | 4m 40s | 40s(turbo) |
| lint (oxlint) | 2m 20s | 8s |
| playwright e2e | 3m 50s | 1m(shard 并行) |
| docker build | 4m 10s | 40s(cache + buildx) |
| 串行总时长(原) | 28 分钟 | 4 分钟(并行后) |
从 28 分钟到 4 分钟,7x 提速。每天 80 次 CI,节省 80 × 24min = 32 小时(包含等待和切换损耗),组内 5 人有效工时增加 30%+。
3 周里被否决的方案
| 方案 | 看似可行 | 否决理由 |
|---|---|---|
| 全部迁到云端托管 runner 不再 self-hosted | 无运维 弹性扩缩 GitHub-hosted 直接用 | GitHub-hosted runner 在中国大陆访问慢 npm install 网络抖动 5 倍 我们的 18 万行项目跑全量比 self-hosted 慢 40% 反向恶化 |
| 换成 GitLab CI 完整重做流水线 | GitLab CI 在并发和 cache 设计更成熟 | 团队所有人对 GitLab 不熟 学习曲线两周以上 而且代码托管在 GitHub 迁出去意味着 issue/PR 全部要搬 工程量月级 不是这个事故能解决的 |
| 直接关掉 PR 上的 type check 上线后再修 | 暴力但短期立竿见影 直接砍 6 分 20 秒 | 方向反了 类型检查是 TS 的核心价值 关掉等于放弃护栏 任何一次类型 bug 漏到生产都会比 6 分钟 CI 损失大 |
| 所有 PR 跳过 e2e 只在 main 跑 | e2e 是耗时大头之一 跳过省 3 分 50 秒 | e2e 是发现"组件间集成 bug"的最后一道防线 跳过会让"main 上才发现 bug"成常态 修复成本和心智压力远大于 CI 时长 |
| 把整个 CI 改成只在 merge 时跑 PR 上完全不跑 | PR 流水线 0 等待 体验飞起 | 等于把"出错延迟"从 CI 期推到 merge 期 一旦 merge 后才发现红 main 整团队阻塞 + 回滚成本极高 这是个反向 trade-off 节省个人体验损害团队稳定性 |
| 所有 step 都加 retry 失败自动重试 3 次 | 缓解偶发网络抖动 不再被红色 PR 烦 | 方向反了 重试是把不稳定性掩盖而不是修复 真正的 flaky 测试会被持续掩盖 + 一次重试可能让流水线时长翻倍 反向恶化 |
每条否决都让我们更清楚"真正要修什么"。最后选定的"6 大方向 30 项小优化"既是技术最优,也是组织成本最低——所有改动都在 workflow.yml 和工具配置层,业务代码不动、研发流程不动。后来老板问"为什么不一次性换 GitLab",我直接甩这张表 5 分钟说服全场。这种"否决记录"在长期来看比"选定方案"价值还大,新人入职第二周遇到类似 CI 慢的问题翻一下表就有思路,不用从头讨论。
决策树:不同规模项目该怎么选 CI 优化路线
这棵决策树后来嵌进了平台组的工程师入职 SOP:任何新接手 CI 慢问题的人,必须先沿这棵树走一遍再决定方案,而不是凭直觉"我觉得应该先优化 X"。这个小改动让团队对"CI 优化"的判断质量明显提升——以前是"听别人说什么火就改什么",现在是"先量瓶颈、按比例排优先级、按规模选工具"。半年下来另外两个组也接入了同样的 SOP,他们的流水线从 18 / 22 分钟分别降到 5 / 6 分钟,投入的总时间不到 2 人周。
立的《CI/CD 流水线性能纪律》
- 每周看一次 CI 时长趋势,任何持续 > 20% 退化触发优化排期。
- 新 step 必须有 cache 策略,长耗时步骤(install / build / test)无缓存禁止合入。
- monorepo 必须用 Turborepo / Nx,做任务级缓存。
- 并行优先于串行,job 间无依赖必须并行。
- 测试用 matrix 拆分,单个 test job 时长 > 2 分钟必须拆。
- Docker 镜像必须用 BuildKit + cache,multi-stage,层级有序(变化少的在前)。
- 性能敏感工具用 Rust 实现:linter (oxlint / biome)、formatter (biome)、bundler (turbopack / rolldown)。
- self-hosted runner 规格按需调:CPU-bound 步骤要够多核;memory 给 16GB+ 避免 Node OOM。
- PR 用加速 / main 跑全量,绝不混用——任何"选择性测试"只允许在 PR 流水线启用。
- flaky 测试零容忍,3 天内修复或临时 skip + 加 issue,不准用 retry 掩盖。
给读者的几条自查清单
- 看你的 CI 流水线时长,> 10 分钟就值得花一周优化。
- 把 step 时间列出来排序,前 3 步占多少比例。优化集中在 top 3 投入产出比最高。
- 有没有用任何依赖 cache?actions/cache 是 GitHub Actions 必装。
- 能不能拆 job 并行?lint / test / build 几乎都能拆。
- monorepo 有没有用 Turborepo?这是 monorepo CI 提速的杀手锏。
- Docker build 有没有用 BuildKit + cache?
docker/build-push-action自带支持。 - 测试有没有按 package / shard 拆?单个 test job 跑 5 分钟基本可以拆。
- eslint 慢的话试试 oxlint 或 biome,普遍快 50-100 倍。
- 有没有专人/角色每周看 CI 时长趋势?如果没有,CI 必然会慢慢腐烂回去。
- self-hosted runner 规格够不够?CPU-bound 步骤上 8 核 + 内存 16GB 是甜点。
整体效果 + 长期收益
| 维度 | 修复前 | 修复后 90 天 |
|---|---|---|
| 串行总时长 | 28 分钟 | 4 分钟 |
| 每天 CI 总等待时长 | 5 人 × 12 次 × 5 分钟 = 5 人小时 | 5 人 × 12 次 × 0.5 分钟 = 30 分钟 |
| 组内每日有效工时 | 30 人小时(被 CI 拖走 5 小时) | 34.5 人小时(净增 15%) |
| PR 平均 review-to-merge 周期 | 3.2 小时(等 CI 是主要瓶颈) | 52 分钟 |
| 单 PR 平均代码量 | + 380 行(攒一波再 push) | + 95 行(小 PR 增多 3x) |
| main 红 / 月 | 2.4 次(大 PR 一炸炸一片) | 0.3 次(小 PR 影响小) |
| "早 5 点排队 push 避开 CI 堵车"现象 | 每周 3-4 次 | 0 次 |
| 团队新人 onboarding 第一周满意度 | 3.2 / 5(吐槽 CI 慢) | 4.7 / 5(夸 CI 飞快) |
| 自托管 runner 月成本 | ¥1800 | ¥3600(8 核版 翻倍) |
| 节省的工程师工时 / 月 | — | 5 人 × 22 天 × 4.5 小时 = 495 人小时 |
| ROI 估算 | — | 多花 ¥1800/月 runner 换 495 人小时 折算 25-30 万元 / 月人力价值 |
"早 5 点排队 push 避开 CI 堵车"这一项是意外发现——我们组里之前真的有两个同事会在早 5 点起来推代码,因为那时候 runner 没人用最快。3 周优化后这个习惯自然就消失了,他们感谢得最多的就是这一点。一个流水线优化不只是省时间,它会改变团队作息和心智状态,这种"隐藏收益"在 ROI 表里很难量化但极其真实。
认知更新:对 CI/CD / 工程效能 / 团队心智的 4 个新认知
- "CI 慢"是工程文化最大的隐性税。每个开发者每天被 CI 拖走 1 小时,看起来没什么——但它扭曲的是"试错意愿"。28 分钟的流水线让人不愿意 push 小改动 / 不愿意试新想法 / 不愿意做小重构,这些"不愿意"的累积才是真正的损失,远大于直接时间损失。任何一个工程团队的资深成员都该把"CI 时长"当成一级指标盯着,和"线上 P99"放在同样位置。这条认知没建立的团队,长期都会被"团队迭代速度慢但说不清楚为什么"困扰。
- "并行优化"比"单步优化"价值高得多。我们优化前花了 1 周纯并行化(2.1 + 2.2 + 5.2),从 28 分钟降到 12 分钟,占总收益的 67%。后面 2 周再花大力气抠 cache / incremental / Rust 工具,只从 12 分钟降到 4 分钟。并行是 CI 优化最大的杠杆,因为现代 CI 平台对并行 job 几乎免费(只多花 runner 时间不多花费用)。任何 CI 优化第一步都该是"能不能拆并行",而不是"能不能让某一步更快"。这条认知后来直接写进了平台组的 onboarding 文档。
- "工程债的复利效应"在 CI 里最明显。我们入职前这条流水线已经"温水煮青蛙"两年了——每加一个 step 都"也就慢 30 秒",每次都"还可以接受",两年后就是 28 分钟。这种"渐进式腐烂"在任何不被周期性盘点的系统里都会发生,流水线只是最显眼的一个。我们后来在 SRE 组设了"季度盘点"机制——CI 时长 / 构建时长 / 测试时长 / 镜像大小 / 启动时长 5 个指标每季度强制看一次趋势,任何上升 > 20% 都自动开排期。半年下来这套机制主动避免了 3 次"再腐烂回 28 分钟"的反弹。
- Rust 实现的 JS 工具不是噱头是范式转移。oxlint / biome / turbopack / rolldown 这些 Rust 实现的 JS 工具链普遍比原 JS 实现快 50-100 倍。这不是"快一点点",是"原本要等几分钟的步骤现在几秒就完",体验上完全不是同一个东西。这种代际差距会迅速重塑团队习惯——以前不愿意 push 因为要等 lint,现在 8 秒就完了,等于"消除了一个心理阻力"。任何 JS 工具链项目都应该每季度看一次有没有 Rust 替代品,有就上,不要等"完全成熟"才用,8 成场景的 Rust 工具已经够生产用了。
第 3 条心得是关于"工程债盘点"的频率。我们之前每年才盘点一次,事实证明太晚——一年里足够任何指标"温水煮青蛙"地腐烂掉 40-60%。后来改成季度盘点,4 次 / 年,每次只要 2 小时,但能稳稳挡住所有"渐进式退化"。这个频率比"持续监控告警"更有效,因为告警通常只在指标突变时触发,而工程债往往是"每天慢 1%"的缓慢上升,告警根本抓不到。"季度盘点 + 告警监控"双保险才是工程债治理的最佳实践,任何一个工程团队都该学。
最后再补一个工程文化层面的反思:这次优化触发前其实有过很多次小信号——新人入职第一周都会吐槽"CI 太慢"、组里同事偶尔抱怨"等下还要等 CI"、PR 平均代码量逐月增加(攒一波再 push 的征兆)、main 偶尔红一次(大 PR 一炸炸一片),每次大家都用"还能跑"、"是历史问题"、"runner 不够"绕过去。所有重大优化机会都有它的"预热信号",区别只在团队有没有把它当回事。我们后来在事故管理里加了"小信号月度复盘"机制——把过去 30 天的所有低优先级抱怨 + 新人吐槽 + 偶发红线集中拉一遍,挑出可能值得深挖的提前修。半年下来这个机制提前避免了 4 次"等酿成大问题才动手"的工程债,投入产出比远超事后救火。希望读到这里的你也能在自己团队里建立类似的"小信号雷达",别再让一条看似无害的"CI 慢但能忍"流水线把团队 2 年后的某个忙碌下午继续毁掉。
这次优化让我对"工程团队效能"有了新的认知:CI/CD 时长是工程团队的"心跳",直接影响每一次代码迭代的体感。28 分钟的流水线让人不愿意 push 小改动,4 分钟的流水线让人愿意"试试就推"。后者孕育的是大胆 + 频繁迭代的文化,前者孕育的是"我攒一波再 push"的批量主义。这种文化差异最终会反映在产品速度上。下次有人在 workflow.yml 里写新 step 时,顺手把 cache 策略、并行化、matrix 拆分一并设计进去——这套配置花你 15 分钟,但能让你未来 12 个月不被"CI 越来越慢"工单叫起来。修完之后你会发现,同样的业务逻辑、同样的工具链,流水线突然就稳定到"再也没人吐槽"——其实代码没变多少,变的只是你终于把"流水线性能"当成头等公民来设计,而不是事后补救。这种"零业务侵入却带来工程文化跃迁"的工程红利,在 CI/CD 场景里尤其常见,值得每个团队投入一次彻底的复盘。如果你在自家流水线上也做了类似的端到端治理,欢迎在评论区分享你的优化前后耗时表、最大单点收益、踩到的具体坑——CI/CD 优化这块,中文社区的实战经验沉淀还很零散,每一份数据都是后来者的灯塔,愿我们的 3 周复盘能换你 30 分钟就内化成自己团队的工程默认值,把每一次代码改动都用在真正的业务价值上,而不是浪费在本可以避免的等绿勾时间里。
—— 别看了 · 2026