大型 Monorepo 中增量构建缓存策略与常见问题
前阵子我们一个 Monorepo 的 CI 时间突然从 40 多分钟掉到了 8 分钟,大家一开始还挺开心,结果某次发版后首页直接白屏。查了半天,发现是构建缓存命中了,但缓存里的 NEXT_PUBLIC_API_URL 还是旧环境的值。从那以后我就明白一件事:增量构建缓存最难的从来不是“开个缓存”,而是你到底能不能说清楚,什么变了、什么没变、什么输出还能用。
下面拿我们比较常见的 pnpm workspace + Turborepo 举例,Nx、Bazel 之类的工具思路也差不多,只是配置方式不一样。
缓存键不是文件修改时间
我见过不少项目一开始用 mtime 或者“最近 5 分钟改过的文件”来判断增量,本地玩玩还行,放到 CI 里基本是灾难。CI 每次都是新 clone,文件时间不可靠;本地又可能因为切分支、git checkout 导致时间乱跳。更稳的做法是内容哈希:源文件、配置文件、锁文件、工具链版本、环境变量、命令参数,全都进缓存键。
比如 Turbo 里可以这样写:
{
"globalDependencies": [
"tsconfig.base.json",
".npmrc",
"patches/**"
],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"src/**",
"package.json",
"tsconfig.json"
],
"outputs": ["dist/**", "!dist/**/*.map"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}
这里别偷懒。只要构建结果依赖某个东西,它就应该进缓存键。比如 NEXT_PUBLIC_* 这种会在构建时被内联的变量,不写进 env,那缓存命中就是随机炸弹。还有 tsconfig.base.json、.npmrc、patches/** 这种根目录文件,看着不像业务代码,但真能影响所有包的输出。
先解决影响范围,再谈缓存
大型 Monorepo 里,增量构建的第一层其实是“哪些包需要重新构建”。工具一般会根据依赖图算出 affected packages,然后只跑这些包以及依赖它们的下游包。这个思路没问题,但前提是依赖图得靠谱。
我们踩过的坑是:有些包通过运行时字符串拼路径、动态 require、或者脚本生成的文件建立了“隐形依赖”,依赖图里根本没有。结果就是 A 包改了,B 包没被标记为受影响,CI 一路绿灯,线上挂掉。后来我们的做法是,凡是生成代码、动态入口、跨包读取文件的地方,都在项目配置里显式声明依赖,别让工具猜。
另外,dependsOn: ["^build"] 这种配置要确认清楚:你是要依赖包的构建产物,还是只要源码?测试任务要不要等构建?这些关系如果配错,缓存再快也没意义。
本地缓存和远程缓存别混为一谈
本地缓存主要解决“我改一个包,不想全量跑”的问题,远程缓存解决的是“CI 之间、同事之间别重复造轮子”。这两个东西可以一起用,但策略不一样。
本地缓存可以相对激进,命中错了大不了 rm -rf 重来。远程缓存就不行,一旦错误产物被写进去,可能污染一堆分支。我们的做法是:主分支允许读写远程缓存,普通 PR 只读,来自 fork 的 PR 默认不写,或者写到独立命名空间。别为了省几分钟,把密钥、内网地址、调试产物一起塞进缓存。
远程缓存的 key 还要带上平台信息。linux/x64 和 darwin/arm64 编出来的原生依赖不是一个东西,Node 版本、包管理器版本、甚至某些编译器的 minor version 都可能影响输出。只共享源码哈希,不共享工具链信息,迟早出事。
输出目录必须可预测
缓存恢复本质上是“把上次的产物解压回来”,所以你得先告诉工具:哪些目录是产物,哪些不是。outputs 写得太宽,会把日志、临时文件、甚至本地配置一起缓存;写得太窄,恢复不完整,构建看起来成功了,运行的时候缺文件。
比较烦的一种情况是输出目录里混着旧文件。CI 上每次都是干净目录,问题不明显;本地连续构建几次,缓存恢复后旧 chunk 还在,dist 越来越大,最后出现“本地正常、CI 正常、换台机器就不正常”的鬼故事。所以缓存恢复前最好保证输出目录是干净的,或者至少让构建工具自己负责清理。
还有,别把 node_modules 和构建产物混在一个缓存里。包管理器的缓存、构建缓存、Docker layer cache 是三层东西,混在一起排查起来非常痛苦。
常见问题:缓存总是不命中
如果命中率低得离谱,先别怀疑工具,看看是不是输入里混进了每次都变的东西。常见的有:
inputs写得太宽,把.git、日志、临时文件、测试快照全包进去了;- 构建脚本往源码目录里写版本号、时间戳、随机 build id;
- lockfile 在 CI 里没有被冻结,每次 install 都产生细微差异;
- 用了绝对路径,换台机器哈希就变;
- 把所有环境变量都塞进缓存键,结果 CI 的
CI=true、RUNNER_*每次都不同。
我们一般会先看工具的 dry run 或 summarize 输出,确认这次 miss 到底是因为哪个文件、哪个环境变量。别只看“命中率”一个数字,命中率低不一定有问题,错误命中才是大问题。
常见问题:缓存命中了,但结果是错的
这个比不命中危险得多。典型原因就几类:环境变量没进 key、生成代码的源文件没进 key、依赖包版本变了但锁文件没体现、平台信息缺失、缓存被不可信的分支写脏了。
我现在的习惯是,凡是“构建时读取、运行时表现”的东西,都列出来问一遍:它在缓存键里吗?比如 GraphQL schema、OpenAPI 描述文件、protobuf 定义、feature flag 的默认值、CDN 域名、Sentry DSN。只要它参与构建,就别让它当隐形输入。
一套比较稳的落地顺序
我的建议是别一上来就追求 90% 命中率。先把依赖图和输入输出声明做对,让错误构建尽量暴露;再开本地缓存,观察一段时间;最后接远程缓存,并且给 PR 和主分支做权限隔离。上线后盯几个指标:命中率、恢复耗时、缓存体积、因为缓存导致的回滚次数。最后一个指标比前三个都重要。
缓存这东西,快是真的快,坑也是真的坑。比较稳的心态是:先假设它会出错,然后想办法让出错的时候能被发现。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。