我修过的每一个慢速构建,开头都一样:有人先有了一个猜测。「是 webpack。」「是 Docker pull。」「是 CI 机器,永远是 CI 机器。」然后我们真的去计时,猜测全是错的。一个 慢速构建几乎从来不是你能猜中的某一件东西——它是一叠小税,而构建性能的活儿大 部分其实是记账。所以这是一份清单,不是一篇吐槽:先测量,再搬大石头,然后在「优化一个 根本没人等的东西」之前停手。
为什么构建性能本质是一个测量问题
构建是一条流水线,不是一条命令。典型形状是:
安装依赖 → 类型检查 → 编译/打包 → 打镜像 → 推送 → 发布
整条流水线的墙钟时间不是各步骤之和,而是「和」再加上关键路径上的东西——也就是 最长的那条依赖链。一个十分钟的步骤如果和别的并行跑,它只花十分钟一次;一个两分钟的步 骤如果挡住后面三个,它就要了你六分钟。这就是「缓存一下不就行了」并不总是管用的原因, 也是为什么第一个诚实的问题从来不是我怎么把它弄快,而是时间到底花在哪儿。
冷构建与热构建:两个数都要报
冷构建什么都没有缓存:新克隆、空的模块缓存、没有 Docker 层。热构建复用本地 已有状态。两者可以差一个数量级,而把它们混为一谈,正是人们鸡同鸭讲的原因。
- 你的笔记本通常是热的。你的 CI 默认是冷的——除非你配了缓存。
- 「这个构建要四分钟」如果不说明是哪一种,等于什么都没说。
- 冷与热之间的差距,恰好就是你的构建缓存的价值。它同时也度量了缓存能帮你省多少。
先测量:墙钟时间到底花在哪
在看到每一步的墙钟数字之前,别急着上 profiler。一个逐步骤的 time 循环通常就够了:
#!/usr/bin/env bash
# measure-build.sh —— 输出每一步的墙钟时间,最后按从慢到快排序。
set -euo pipefail
step() { # step NAME CMD...
local name="$1"; shift
local t0=$(date +%s.%N)
"$@" >/dev/null 2>&1
printf '%.1fs\t%s\n' \
"$(echo "$(date +%s.%N) - $t0" | bc)" "$name"
}
{ step "install deps" npm ci
step "typecheck" npm run typecheck
step "bundle" npm run build
step "docker image" docker build -q -t app .
} | sort -rn
跑两遍:一遍在干净检出上(冷),一遍在缓存已热时。这个差就是你的预算。当某一
步占了大头,就用工具自己的 profiler 往下钻——cargo build --timings、
docker build --progress=plain、webpack 的 --profile,或者干脆用 hyperfine 包住
某个子命令。流水线的发布那一侧也是同样的纪律,正如零停机部署里
讲的:度量那个卡住发布的步骤,而不是那个看起来很忙的步骤。
常见的元凶
大致按咬人的频率排序:
- 每次都重装依赖。 在冷缓存上跑
npm ci、pip install -r、go mod download—— 同样的字节,再取一遍。 - 完全没有缓存。 没有层缓存、没有依赖缓存、没有编译器缓存、没有测试结果缓存。构建 在重做它已经做过的事。
- 一个巨大的容器镜像。 一个 2 GB 的基础镜像,在你的代码还没开始之前就要拉 90 秒。
- 所有东西都单线程。 打包器、linter 或测试运行器在一台 16 核机器上只用一个核。
- 在 CI 里跑开发构建。 未压缩的输出、没人看的 source map、watch 模式的开销、忘了关 的 debug 断言。
- 不必要的步骤。 只改了文档却跑全套测试;只改了一个包却重建所有包。
- 受 I/O 限制的图片/资源处理。 一个一个地缩放、压缩、转码成千上万个文件,还压在慢 盘上。
- 关键路径上的网络取数。 每一次构建都同步地拉基础镜像、工具链或 registry。
这些大多不是「编译器」的问题。它们是管道的问题。
修复:构建缓存、增量构建与关键路径
自上而下地做工。先修占大头的步骤;在三分之一的步骤消失之前,别管那三秒的。
用你信得过的键来缓存层
构建缓存的好坏取决于它的键。按真正会变的输入来打键——lockfile 的哈希、源码哈希、
编译器版本——绝不要按 latest:
# 依赖很少变,源码一直在变。按这个顺序摆放,让昂贵的层在寻常改动下存活。
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
现在改一个源文件只会让最后一层失效。在 CI 里再加一对 cache-from/cache-to,让一次
性 runner 继承上一次构建的层,而不是从零开始。缓存里已有的东西,永远别重新下载。
让它增量
增量构建只重算变了的部分。这正是缓存变现的地方:tsc --incremental、cargo 的
target 目录、Vite 的依赖缓存、Gradle 的 up-to-date 检查、Bazel/Turborepo/Nx 的内容哈
希。代价是输入必须诚实——如果你的缓存键忽略了编译器会读的某个环境变量,你会得到一个很
快但错误的构建。失效逻辑要有意设计,别让它给你惊喜。我在版本控制这一侧的刻意做法,就是
单人开发者的 Git 工作流里的那套论点。
缩短关键路径
- 并行化独立步骤:lint、类型检查、测试可以同时跑,多数打包器也接受线程数参数。
- 把活儿挪出路径:后台拉镜像、用定时任务预热缓存、按定时器预构建基础镜像。
- 砍掉依赖图:更少的包是永久提速,而不是一次性技巧。拿掉一个沉重的传递依赖,可能比 任何编译选项都省得多。
- 换更快的 I/O:在本地 NVMe 上构建,别放在网络卷上。在 NFS 上搬几万个碎文件,往往就 是「编译器很慢」背后真正的瓶颈。
- 跳过没变的东西:路径过滤、受影响包检测,以及
[skip ci]一类的护栏,能省掉你根本 不必付的那些开销。
前后对比:一次构建,两组数字
这是我修剪过的一个真实 CI 任务。同一个提交、同一个 runner,只改了管道:
BEFORE (冷) AFTER (热缓存)
安装依赖 214s 安装依赖 27s
lint+类型检查 38s lint+类型检查 38s
打包 142s 打包 46s
docker 镜像 189s docker 镜像 34s
推送镜像 47s 推送镜像 12s
发布 61s 发布 9s
---------------------------------- ----------------------------------
合计 691s 合计 166s
691 秒降到 166 秒——大约 4 倍,而最大的几笔进账都很朴素:缓存依赖步骤、调整 Docker 层 顺序、换个更小的基础镜像、让打包器吃满所有核。没什么花招。剩下的是关键路径上排队的工作。
关键路径。CI 速度:那些不属于你编译器的部分
CI 速度是另一个问题,本地构建速度替代不了,它有自己的翻车方式。一次性 runner 永远 是冷的,所以缓存必须显式配置,而且每一次缓存上传和下载也要花墙钟时间。并发上限意味着你 的任务排在别人后面。一个被限到 1 个 vCPU 的 runner,会让所有并行构建参数形同虚设。当构 建在你笔记本上很快、在 CI 里很慢时,差异几乎总是缓存状态、核数或磁盘——在动配置之前, 先查这三样。
陷阱:优化一个根本没人等的构建
目标不是最快的构建。目标是没人会注意到的构建。一条夜里跑、从不挡人的发布流水线已经完成 了;把它从九分钟削到八分钟,什么都没买到。要优化的是压在人的关键路径上的那些构建 ——你在「改一处」和「跑测试」之间跑的那个,卡住部署的那个。改个文件在 CI 里等 40 秒?那 值得投入。一个你启动了就走开的每周任务?让它慢着吧。
所以这份清单结束在它开始的地方:测量,找到占大头的步骤,缓存它,把它增量 化,并行化剩下的——然后停手。 最好的构建性能,就是你不再关心它、因为它从不让你等待 的那个构建。