小Cの已经记不起来的博客

大型 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 是三层东西,混在一起排查起来非常痛苦。

常见问题:缓存总是不命中

如果命中率低得离谱,先别怀疑工具,看看是不是输入里混进了每次都变的东西。常见的有:

我们一般会先看工具的 dry run 或 summarize 输出,确认这次 miss 到底是因为哪个文件、哪个环境变量。别只看“命中率”一个数字,命中率低不一定有问题,错误命中才是大问题。

常见问题:缓存命中了,但结果是错的

这个比不命中危险得多。典型原因就几类:环境变量没进 key、生成代码的源文件没进 key、依赖包版本变了但锁文件没体现、平台信息缺失、缓存被不可信的分支写脏了。

我现在的习惯是,凡是“构建时读取、运行时表现”的东西,都列出来问一遍:它在缓存键里吗?比如 GraphQL schema、OpenAPI 描述文件、protobuf 定义、feature flag 的默认值、CDN 域名、Sentry DSN。只要它参与构建,就别让它当隐形输入。

一套比较稳的落地顺序

我的建议是别一上来就追求 90% 命中率。先把依赖图和输入输出声明做对,让错误构建尽量暴露;再开本地缓存,观察一段时间;最后接远程缓存,并且给 PR 和主分支做权限隔离。上线后盯几个指标:命中率、恢复耗时、缓存体积、因为缓存导致的回滚次数。最后一个指标比前三个都重要。

缓存这东西,快是真的快,坑也是真的坑。比较稳的心态是:先假设它会出错,然后想办法让出错的时候能被发现。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放