构建失败别急着清缓存:从依赖图反查可疑变更点
很多构建失败其实不需要清缓存重跑——先读依赖图和缓存命中记录,往往能直接锁定上一次成功构建之后的可疑变更点,省掉一轮 5–20 分钟的全量重编。
我见过太多人遇到 undefined reference、头文件找不到、链接顺序错乱这类问题,第一反应是 make clean && make -j。有时候确实能“修好”,但更多时候只是把问题暂时冲走了,下次改点东西又冒出来。清缓存不是调试手段,是放弃现场。真要定位问题,依赖图和构建缓存里留下的信息比一次干净构建有用得多。
先分清两类“缓存失效”和一类“假失效”
构建缓存(build cache)在 Ninja、Bazel、CMake + ccache、sccache 这些体系里,失效机制其实很明确:输入哈希变了才重跑。问题在于,很多人把三类现象混为一谈:
- 真失效:某个编译单元依赖的头文件、宏定义、编译选项确实变了,导致该单元重编,进而触发下游重链。
- 假失效:时间戳或文件元数据变化触发了重编,但内容哈希没变。CMake 的
if(NOT CMAKE_BUILD_TYPE)产物、Ninja 的 restat 规则没配好时常见。 - 传播性失效:一个底层头文件改动,理论上会让几百个目标重编,但实际只有少数目标真正受
#ifdef分支影响——构建系统不关心语义,只认哈希。
分不清这三类,就会做出两种错误动作:要么无脑清缓存,要么无视缓存信息盲目二分查找。正确做法是先问一句:这次失败,缓存里哪些东西变了?
依赖图反查:从失败节点向上找“最近共同祖先”
定位构建失败,最有效的手段不是看报错本身,而是从依赖图里反查。具体思路:找到失败目标(比如 libfoo.so 链接失败),往上追溯它依赖的所有节点,再对照构建缓存/日志,找出上一次成功构建之后,哪些上游节点被重新编译过。
Ninja 生态里这个操作很直接:
# 查看某个目标的依赖链
ninja -t query libfoo.so
# 查看本次构建中实际重跑的命令
ninja -t commands | head -50
# 或结合 .ninja_log 看时间线
ninja -t log
Bazel 更省事,bazel build --subcommands 或者直接查 action graph:
bazel aquery 'mnemonic("CppCompile", deps(//path:target))' --output=textproto
关键不是列出全部依赖,而是找到失败节点与最近一次成功构建之间,被重新编译的那批节点。这批节点里,真正影响失败目标的往往只有几个。比如链接失败 undefined reference to Foo::bar(),而 Foo 的定义在 foo.cpp,那就看 foo.cpp 的编译单元这次有没有被重编。如果没重编,问题多半出在链接顺序或者 ABI 变化;如果重编了,再查它重编的原因——是哪个头文件变了、哪个宏变了。
用缓存命中记录做“时间切片”
构建缓存的价值不止是加速,它还是一份隐式的变更日志。ccache 的 ccache -s 能告诉你缓存命中率,但更有用的是 ccache -o log_file=/tmp/ccache.log 配合 CCACHE_LOGFILE 记录每次查询的输入哈希。sccache 的 SCCACHE_LOG=debug 也能输出类似信息。
实操中我习惯这么做:先找到失败目标对应的编译命令,把它的输入文件列表和哈希拿出来,然后去缓存日志里搜这个哈希最后两次被查询的时间点。如果两次查询之间哈希变了,说明输入确实变了;如果哈希没变但结果不同,那就是缓存污染或者非确定性构建(比如 __DATE__、未初始化的全局变量、-frandom-seed 没固定)。
一个真实例子:某项目 CI 偶发链接失败,本地复现不了。查缓存日志发现,失败那次构建里,config.h 的哈希和成功那次一致,但 version.cpp 的哈希变了——因为 version.cpp 里嵌入了 git describe 的输出,而 CI 的 shallow clone 导致 tag 信息缺失,生成了不同的版本字符串。这个变化本身不会导致链接失败,但它让 version.cpp 重编,进而触发了下游某个不稳定的链接顺序问题。如果当时直接清缓存,这个链路根本看不见。
链接失败优先查“重编集合”而不是报错符号
undefined reference 这类链接错误,报错符号只告诉你“缺了什么”,不告诉你“为什么现在缺了”。真正有用的信息是:这次链接相比上次成功链接,参与链接的目标文件集合变了没有?变了哪些?
拿 CMake + Ninja 举例:
# 查看链接命令
ninja -t commands libfoo.so | tail -1
# 对比两次构建的链接输入
ninja -t query libfoo.so > /tmp/deps_now.txt
diff /tmp/deps_before.txt /tmp/deps_now.txt
如果链接输入集合没变,但某个 .o 文件被重编了,那问题很可能出在ABI 不兼容——比如某个类的成员函数签名变了,但调用方没有被重编(因为调用方的依赖图里没有直接包含那个头文件,或者头文件被 #pragma once 之外的路径绕过了)。这种情况清缓存反而会“修好”,因为它强制所有单元重编,把 ABI 不一致掩盖过去。但下次增量构建还会炸。
反过来,如果链接输入集合变了——多了或少了一个 .o——那就要查构建脚本里条件编译的部分。我遇到过一个案例:CMakeLists.txt 里 if(EXISTS ${CMAKE_SOURCE_DIR}/.git) 决定是否编译一个 git_metadata.cpp,CI 环境里 .git 是文件不是目录(submodule 场景),导致这个源文件时有时无,链接行为随机漂移。
头文件变更的“假阳性”传播
依赖图反查时,最常见的干扰是头文件变更导致的“假阳性”传播。一个底层头文件(比如 types.h)改了注释或者加了一个无关的 inline 函数,构建系统会让所有包含它的单元重编。但真正导致失败的,可能只是其中一个单元。
这时候可以借助编译器的依赖输出做二次过滤。GCC/Clang 的 -MD 生成的 .d 文件里记录了每个编译单元实际打开过的头文件列表。结合 ninja -t deps 或直接解析 .d 文件,可以判断某个重编单元是否真的“看到”了变更的那个头文件:
# 查看某个 .o 实际依赖的头文件
cat build/CMakeFiles/foo.dir/src/foo.cpp.o.d
如果失败目标依赖的某个单元重编了,但它的 .d 文件里并没有那个变更的头文件,说明重编是“无辜”的——它是被其他原因触发的(比如编译选项变了、或者依赖了另一个中间生成文件)。这时候要换个方向查。
增量构建失败的第一现场是“重编列表”,不是报错本身
总结一下方法论:构建失败后,先别动缓存,也别急着看报错细节。先做三件事:
- 找到失败目标,从构建日志里提取它依赖的所有节点;
- 对照上次成功构建,列出被重编的节点集合;
- 验证这个集合里的每个节点,是否与失败目标有真实的语义依赖。
这三步做完,大部分情况下能缩小到 1–3 个可疑文件,再去看报错就有的放矢了。清缓存应该作为最后手段——当你怀疑缓存本身被污染(比如非确定性构建、磁盘问题、工具链升级)时才用。把缓存和依赖图当调试工具用,而不是当障碍物绕过去,这是实战老手和新手在构建问题上的分水岭。
常见问题
怎么快速拿到“上次成功构建”的依赖快照?
如果用的是 Ninja,.ninja_log 里记录了每次构建的时间戳和目标,配合 ninja -t query 可以重建任意时间点的依赖关系。Bazel 用户可以用 bazel build --build_event_json_file 把每次构建的 action graph 存下来,出问题时对比两次 JSON。CMake 项目如果没启用 Ninja 而是 Make,建议至少加 --output-sync=recurse 让日志可读,否则多线程输出乱序会毁掉所有排查线索。
为什么清缓存有时候确实能“修好”链接错误?
因为清缓存强制所有编译单元在同一次工具链、同一套头文件状态下重编,消除了增量构建中 ABI 不一致或中间文件过期的问题。但这不是修复,是重置。如果根因是构建脚本的条件逻辑有缺陷、或者头文件依赖声明不完整,下次增量构建还会复现。清缓存后能跑通,恰恰说明问题出在增量构建的依赖追踪上,而不是代码本身。
有没有办法验证缓存本身是否被污染?
有。拿同一个编译单元,用 ccache/sccache 的调试模式记录输入哈希和输出哈希,连续跑两次看输出是否一致。如果输入哈希相同但输出哈希不同,基本可以确定是非确定性构建。常见的非确定性来源:未初始化的变量、__DATE__/__TIME__ 宏、文件系统遍历顺序(readdir 不排序)、以及未固定的 -frandom-seed。这些都可以通过编译器警告(-Wuninitialized)或构建选项(-Wdate-time、--random-seed)逐一排除。