- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 盯着构建进度条的那些时间
- 构建不是单一的动作
- 编译 vs 链接 — 不同的瓶颈
- 冷构建 vs 热构建
- 增量构建 — 没变的东西不重做
- 编译器缓存 — ccache 与 sccache
- 任务缓存 — Turbo、Nx、Bazel
- 单体仓库的问题 — 该重新构建什么
- 并行性的极限
- CI 缓存 — 不要每次都从头来
- 对构建做性能分析 — 靠测量,不靠猜
- 实务检查清单
- 结语
- 参考资料
引言 — 盯着构建进度条的那些时间
开发者一天中花在等待构建上的时间,比想象中要多。改一行代码,等 30 秒、1 分钟,有时甚至几分钟才能看到结果。这种短暂的等待一天累积几十次,专注力就会被打断,节奏也会被打乱。"构建太慢"是常见的抱怨,但能说清这种慢究竟从何而来的开发者,其实并不多。
构建慢背后必然有结构性的原因。编译什么、链接什么、能缓存什么、能并行跑什么,这些都决定了速度。本文把构建的每个阶段拆开,找出时间究竟漏在哪里,然后依次讲增量构建与缓存、单体仓库策略、CI 缓存,以及如何实际测量一次构建。目标是把"感觉慢"这种模糊的印象,转变成"瓶颈在这里"这样具体的诊断。
构建不是单一的动作
首先要弄清楚的是,"构建"这一个词,其实是一条多阶段的流水线。以 C/C++ 这类编译型语言为例,大致要经过下面这些阶段。
源代码 (.c, .cpp)
|
| 1. 预处理 (包含头文件、展开宏)
v
预处理后的源码
|
| 2. 编译 (源码 -> 目标文件)
v
目标文件 (.o)
|
| 3. 链接 (目标文件 + 库 -> 可执行文件)
v
可执行文件
每个阶段的性质不同,变慢的原因也不同。所以在诊断"构建慢"时,第一个问题永远是"哪个阶段慢"。编译慢和链接慢是完全不同的问题,解法也不一样。
编译 vs 链接 — 不同的瓶颈
编译是把每个源文件独立翻译成机器码目标文件的阶段。它的核心性质是按文件独立:编译 a.cpp 和编译 b.cpp 互不相关,所以多个文件可以在多个核心上并行编译。编译变慢的典型原因如下:
- 巨大的头文件:在 C++ 中,头文件会在每个源文件里被重新预处理一遍。如果几百个文件都包含了一个很重的头文件,同样的内容就会被解析几百次。
- 模板与元编程:C++ 模板每次实例化都会生成代码,这给编译器带来了很大的负担。
- 优化级别:像
-O2、-O3这样的高优化级别需要更多的分析,因而更慢。
链接是把所有已编译的目标文件和库合并成最终可执行文件的阶段。它和编译决定性的不同在于,链接本质上是一个全局性的工作。只有所有目标文件都准备好才能开始,而解析和排布符号的过程大体上是单线程进行的。所以链接很难并行化,在大型项目里常常成为最后的瓶颈。
这个区别在实务中很重要,原因是这样的:增加核心数会让编译变快,但对链接的帮助不大。所以大型项目要么引入更快的链接器(比如 lld、mold 这类并行链接器),要么采用减少链接本身的策略(增量链接、动态链接)。如果"不管加多少核心,构建都不会等比例变快",那么链接往往就是元凶。
冷构建 vs 热构建
谈构建速度时必须区分的一对概念是冷(cold)与热(warm)。
- 冷构建:从什么都没有的状态把整体从头构建一遍。刚拿到仓库时,或者清空了全部构建产物之后的第一次构建都属于这种情况。
- 热构建:在上一次构建的产物还在的状态下,只重新构建改变了的部分。
冷构建天生就慢,因为一切都要从头来。真正重要的是热构建。开发过程中,一天要重复几十次"改一点代码再构建",这个反复构建的过程快不快,决定了生产力。让热构建变快的核心技术,正是接下来要看的增量构建与缓存。
一个常见的误区是只看冷构建时间来评价一套构建系统。开发者一整天实际经历的是热构建,所以"只改一个文件后重新构建要花多久"才是重要得多的指标。
增量构建 — 没变的东西不重做
增量构建(incremental build)的原理很简单:没变的东西不重新做。 构建系统知道每个产物依赖哪些输入(依赖图),如果输入没变,就直接沿用之前的产物。
传统的 make 是靠文件时间戳来判断的。如果源文件的修改时间晚于目标文件,就认为"变了",重新编译。
a.cpp (修改于 10:05) --> a.o (生成于 10:03)
=> a.cpp 更新,因此重新编译出 a.o
b.cpp (修改于 09:50) --> b.o (生成于 10:03)
=> b.o 更新,因此跳过 b.o
增量构建要正确工作,依赖图必须准确。这里常见的陷阱是头文件依赖:a.cpp 包含了 config.h,如果 config.h 变了,a.cpp 也得重新编译。如果构建系统不知道这层关系,就会出现"改了头文件却没生效"的 bug。所以编译器会同时生成一份依赖信息,记录每个目标文件依赖哪些头文件,构建系统再读取这份信息来补全依赖图。
时间戳方式也有它的局限。哪怕只是打开文件又原样保存(内容完全没变),时间戳也会变化,从而触发不必要的重新构建。所以现代构建系统正朝着用内容哈希取代时间戳来判断变化的方向发展 — 内容如果实际相同,就不重新构建。这一点也和接下来要看的缓存联系在了一起。
编译器缓存 — ccache 与 sccache
如果说增量构建是"跳过项目内没变的部分",那么编译器缓存则更进一步:把一次编译的结果保存下来,同样的输入再次出现时,不再编译,而是直接取出保存好的结果。
代表性的工具是 ccache。ccache 把编译器包了一层,编译请求到来时,它会计算输入(预处理后的源码、编译选项、编译器版本等)的哈希。如果之前见过同样的哈希,就立刻返回保存好的目标文件。
编译请求
|
v
计算输入哈希 (源码内容 + 选项 + 编译器)
|
+--> 缓存命中 (hit) --> 立即返回已保存的 .o (不编译)
|
+--> 缓存未命中 (miss) --> 实际编译,并把结果存入缓存
这种方式的优势在于,即便是冷构建也能受益。哪怕清空了所有构建产物,ccache 的缓存依然还在,因此重新构建时大部分都能直接从缓存里取出。在多个分支之间来回切换、反复编译同样文件的场景下,这一点尤其有帮助。
sccache 把 ccache 的思路做了扩展。它不仅能把缓存放在本地磁盘,还能放到远端存储(比如云存储)上,这样整个团队或 CI 就能共享同一份缓存 —— 一个人编译出的结果,会被另一个人当作缓存命中直接拿到。这在 Rust 生态里尤其常用。
要让缓存正确工作,缓存键必须准确。编译器版本、选项、源码内容,乃至所包含的头文件,都必须反映在键里,这样才能避免在条件不同的情况下取出错误缓存这种事故。缓存的准确性,取决于"把什么放进了键里"。
任务缓存 — Turbo、Nx、Bazel
如果说编译器缓存缓存的是"一次编译",那么更上层的构建工具缓存的是"整个构建任务"。这里我们来看三个在 JavaScript 单体仓库(monorepo)和大规模多语言项目中被广泛使用的工具。
Turborepo 和 Nx 主要是面向 JavaScript/TypeScript monorepo 的工具。它们的核心是对每个任务(构建、测试、lint 等)的输入做哈希,并缓存结果。如果构建了包 A,而它的输入(源码、依赖、配置)一个都没变,就不会重新执行,而是直接还原之前的输出 —— 甚至连控制台日志都会被缓存下来,"仿佛刚刚才执行过"一样重放出来。
turbo run build
|
v
计算每个包的输入哈希
|
+--> 哈希相同 (cache hit) --> 还原已保存的输出,跳过执行
|
+--> 哈希变化 (cache miss) --> 实际构建,并缓存输出
Bazel 是 Google 打造的大规模构建系统,把这个思路推到了极致。Bazel 的哲学是可复现性(hermeticity)。它要求完整声明每个构建动作的输入(声明之外的东西一律不能碰),再用这些输入的哈希,确定性地缓存输出。由于它保证了输入相同则输出必然相同,本地、CI,乃至整个团队都能安全地共享同一个远程缓存。Bazel 甚至能把构建动作本身分发到远端多台机器上并行执行。
三个工具的共同原理只有一条:对输入做哈希,相同的输入复用已保存的输出。 这和编译器缓存是同一个思路,只是把粒度从文件级别提升到了任务或目标级别。规模越大,"不重做"这件事的价值就越大。
单体仓库的问题 — 该重新构建什么
单体仓库(monorepo)是把多个项目放进同一个仓库的方式。它有利于代码共享和一致性管理,但从构建的角度看,也带来了一个独特的问题:一处小小的改动,会导致重新构建什么?
在一个有几百个包相互依赖的 monorepo 里,只要改一个共享库,所有依赖它的包都会受到影响。如果天真地把所有东西都重新构建一遍,一个小改动也会耗费巨大的时间;反过来,如果什么都不重新构建,改动就不会生效。核心在于,只精确地重新构建"受影响的部分"。
shared-utils (已改动)
/ | \
app-web app-api lib-auth
|
app-admin
改动 shared-utils 后:
受影响的 -> app-web、app-api、lib-auth、app-admin (重新构建)
不受影响的 -> 其余的包 (跳过)
这被称为"只构建/测试受影响的集合(affected)"。Nx 的 affected 命令、Turborepo 的过滤器,都会把这一步自动化:分析依赖图,只挑出从改动文件可到达的那些包来构建和测试。多亏了这一点,即便是在巨大的 monorepo 里,也只需要验证"自己动过的部分及其影响范围",CI 时间因而能大幅缩短。
单体仓库真正的难点,在于把这张依赖图维护得准确。图画得比实际范围更宽,就会产生不必要的重新构建;画得更窄,就会漏掉受影响的部分,让一个已经损坏的构建蒙混过关。所以 monorepo 构建工具的好坏,最终取决于"它对依赖图的了解有多准确"。
并行性的极限
"给更多核心,构建就会更快"这句话大体是对的,但并非没有极限。并行构建存在一些根本性的限制。
第一,依赖链。如果 B 依赖 A 的产物,那么 A 完成之前 B 就无法开始。这样的链条一旦拉长,不管核心再多,也必须依序走完这条链。这条最长的依赖链被称为关键路径(critical path),它决定了构建时间的下限 —— 不论并行到什么程度,都不可能比关键路径更快。
第二,阿姆达尔定律(Amdahl's law)。如果构建里有一部分无法并行化(比如前面提到的链接),这一部分就会拖慢整体速度。不管能把可并行的部分做得多快,串行的那部分始终存在,给整体的提升设了一个上限。
1 个核心: ████████████████████ (100 秒)
4 个核心: █████ + 串行部分 (可并行部分缩到 1/4,
但串行部分原样保留)
=> 串行部分(链接等)越大,并行化带来的收益就越小
第三,资源争用。即便增加核心,如果磁盘 I/O 或内存带宽已经到达上限,那就会成为新的瓶颈。尤其是大量读写目标文件的构建,磁盘很容易成为瓶颈。所以把并行度盲目提到核心数上限并不总是最优解,必须实际测量,找出最佳点。
CI 缓存 — 不要每次都从头来
在本地,之前的构建产物会保留下来,让热构建成为可能,但 CI 不一样。CI 通常是从一个干净的环境开始的,如果没有任何对策,每次都是冷构建。所以让 CI 变快的关键,在于把缓存在多次会话之间保留下来。
CI 缓存大致分为两层。
- 依赖缓存:
node_modules、包管理器的缓存、已编译好的第三方库等,这些基本不怎么变化的东西。如果每次都重新下载或重新构建,会造成巨大的时间浪费,所以用锁文件(lockfile)的哈希作为键来缓存 —— 依赖没变的话,就整体还原。 - 构建缓存:把前面看到的 ccache/sccache 缓存,以及 Turbo/Nx/Bazel 的任务缓存,在 CI 各次会话之间共享。用上远程缓存后,多个 CI 任务和多个开发者就能共用同一份缓存。
CI 缓存里最重要的是缓存键的设计。键如果太窄(比如提交哈希),就会每次都缓存未命中,形同虚设;如果太宽,就会漏掉变化,用上过时的结果。实务中常见的做法是,用锁文件的哈希作为主键,再用分支或操作系统作为辅助键,做适度的复用。
还有一点要注意,缓存本身的还原和保存也要花时间。如果缓存太大,下载和解压所花的时间可能会把缓存节省下来的时间吃掉。所以需要权衡"什么值得缓存",重点缓存那些重新生成代价高的东西。
对构建做性能分析 — 靠测量,不靠猜
到目前为止我们看了构建变慢的多种原因,但在实际项目中,哪个才是元凶不能靠猜,必须靠测量。构建性能分析的核心问题就是这一个:时间到底花在了哪里?
有几种实用的方法。
- 分阶段计时:分别看预处理、编译、链接各花了多久。如果链接占了大头,那就是"加核心也没用"的信号。
- 按文件统计编译时间:找出哪些源文件耗时特别长。通常是少数用了很重的头文件或模板的文件,主导了整体耗时。
- 构建图可视化:许多现代构建工具会把每个任务的开始-结束时间画成时间线,从中能一眼看出关键路径和无法并行化的区间。
- 检查缓存命中率:看 ccache 或任务缓存的命中率。命中率低,说明缓存键设错了,或者缓存没有被正确共享。
编译器本身也提供性能分析选项。比如有的编译器能输出一份报告,列出每个编译阶段(解析、模板实例化、优化)各花了多少时间。看了这份报告,就能得出"这个文件把一半时间花在了模板实例化上"这样具体的诊断。
性能分析的原则只有一条:在让构建变快之前,先精确地找到慢在哪里。绝大多数构建里,时间都集中在少数几个瓶颈上。用数据找到那些瓶颈并瞄准它们,比漫无目的地这里优化一点、那里优化一点要有效得多。
实务检查清单
面对一次慢构建时,可以按顺序检查以下事项。
- 哪个阶段慢? 先分清是编译还是链接。如果是链接,考虑换用更快的链接器。
- 是热构建慢,还是冷构建慢? 如果热构建慢,增量构建的依赖图可能不准确。
- 有没有用编译器缓存? 光是引入 ccache/sccache,往往就能让反复构建大幅变快。
- 如果是 monorepo,是不是只构建了受影响的部分? 确认一下是不是在把所有东西都重新构建了一遍。
- 并行度合适吗? 测量一下核心数、磁盘、内存里到底谁是瓶颈。
- CI 是不是每次都在跑冷构建? 看看依赖缓存和构建缓存有没有在会话之间保留下来。
- 缓存键对不对? 用命中率验证它是不是太宽或太窄。
- 有没有测量过? 在猜测之前,先用性能分析把瓶颈用数据确认下来。
结语
构建慢不是命中注定,而是一个可以被诊断出来的问题。"构建"由性质不同的编译和链接两个阶段组成,每个阶段都可以靠增量构建和缓存跳过相当大的一部分。让热构建保持快速,通常比缩短冷构建时间更重要;而在 monorepo 里,"只重新构建受影响的部分"这种精确性正是核心。并行性很强大,但受关键路径和串行区间的限制;在 CI 里,把缓存在会话之间保留下来是决定性的一步。
比什么都重要的原则是测量。构建时间通常集中在少数几个瓶颈上,用性能分析精确地找到它们并加以针对,能以很小的努力换来很大的改善。下次再盯着构建进度条发呆时,不妨把"为什么这么慢"从一句模糊的抱怨,变成一个具体的问题。答案大多就在数据里。
参考资料
- ccache 官方文档: https://ccache.dev/
- sccache (Mozilla): https://github.com/mozilla/sccache
- Bazel 官方网站: https://bazel.build/
- Turborepo 文档: https://turborepo.com/docs
- Nx 文档: https://nx.dev/
- mold — 快速链接器: https://github.com/rui314/mold
- Amdahl's law (Wikipedia): https://en.wikipedia.org/wiki/Amdahl%27s_law