构建系统与远程缓存:Bazel、Nx 与 Turborepo 的取舍

从任务图、增量构建与远程缓存三层能力出发,对比 Bazel、Nx 与 Turborepo 的核心模型:Bazel 的内容寻址与远程执行、Nx 的项目图与计算缓存、Turborepo 的轻量管线,并给出缓存键设计、存储隔离、失效策略与选型矩阵和落地路径。

当一个仓库里的包从 5 个涨到 50 个,构建时间往往不是线性增长,而是悄悄变成团队每天都要面对的一块「时间税」:改一行前端代码,却要重跑整个后端的类型检查和测试。构建系统存在的意义,就是把「每次全量重算」变成「只重算真正变化的部分」,再用缓存把跨机器、跨分支的重复计算也一并省掉。

本文不打算罗列工具特性,而是从构建系统的三层能力(任务图、增量、缓存)出发,讲清 Bazel、Nx 与 Turborepo 三者在模型上的根本差异,以及远程缓存该怎么设计、缓存键怎么取、什么时候该上 Bazel、什么时候用 Turborepo 就够了。

1. 构建慢的根因:重复计算

1.1 从「重跑一次」到「重算一遍」

传统脚本式构建(make、npm run build:all)的根本问题在于:它只知道「要做哪些步骤」,不知道「哪些步骤的结果已经存在」。于是一次构建的执行单元是整条流水线,而不是流水线里真正失效的那几个节点。

把构建拆开看,一次典型的单仓构建包含四类工作:

工作类型典型耗时占比是否可增量
依赖安装(npm ci / pip install)20%~35%可(按 lock 文件)
代码生成(protobuf / GraphQL codegen)5%~15%可(按输入文件)
编译与打包(tsc / webpack / go build)25%~40%可(按源文件与依赖)
测试(unit / integration)15%~35%可(按受影响模块)

四类工作有一个共同点:输入不变,输出就不该重算。构建系统的全部价值,就是把这句话工程化。

1.2 构建系统的三层能力

判断一个构建系统是否「现代」,看它有没有这三层能力,缺一层就会出现明显的效率断层。

第一层:任务图(Task Graph)
  显式声明任务之间的依赖关系,据此决定执行顺序与并行度
  缺失症状:任务按文件顺序串行跑,无法并行

第二层:增量(Incrementality)
  根据输入指纹判断某个任务是否需要重跑
  缺失症状:改一个文件,全量重跑

第三层:缓存(Cache)
  把任务的输出按输入指纹存起来,跨运行、跨机器复用
  缺失症状:换个 CI runner 就重新算一遍

make 只做到第一层,而且依赖靠文件时间戳判断(mtime),在 Git 切换分支、CI 克隆仓库时会大面积误判失效。Gradle 做到了三层,但强绑定 JVM 生态。Bazel、Nx、Turborepo 则都做到了三层,差别在于模型的严格程度与跨语言的能力边界。

1.3 缓存命中率才是核心指标

评估构建系统时,最该盯的不是「单次构建多快」,而是缓存命中率。

命中率 = 命中缓存的任务数 / 总任务数

命中率高 → 每次改动只重算少数节点 → 时间随改动规模增长,而非随仓库规模增长
命中率低 → 缓存形同虚设,反而增加上传下载开销

一个 50 包的单仓,日常改动的命中率应该在 85% 以上;如果只有 50%,说明缓存键设计有问题(见第 5 节)。

2. Bazel:内容寻址与远程执行

2.1 核心模型

Bazel 的模型最严格,也最昂贵:它要求把构建过程描述成封闭、确定性、可复现的一组动作(action),每个动作的输入输出都用内容哈希(content hash)寻址,而不是用文件路径或时间戳。

Bazel 的三条铁律:
  1. 声明式:所有输入必须在 BUILD 文件里显式声明
  2. 封闭性:动作只能访问声明的输入,不能读环境变量、不能联网、不能读绝对路径
  3. 确定性:相同输入必须产生逐字节相同的输出

满足三条 → 动作结果可以安全地在任意机器上复用(远程缓存/远程执行)
违反一条 → 缓存可能返回错误结果,Bazel 会直接报错而非静默降级

这种严格性带来一个直接后果:Bazel 能跨语言统一缓存。C++、Go、Java、TypeScript、Python 的动作只要遵守同样三条铁律,就可以共用一套远程缓存与远程执行集群。

2.2 BUILD 与依赖声明

# services/api/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")

go_library(
    name = "api",
    srcs = glob(["*.go"], exclude = ["*_test.go"]),
    importpath = "example.com/monorepo/services/api",
    deps = [
        "//libs/config:config",
        "//libs/logging:logging",
        "@com_github_gorilla_mux//:mux",
    ],
    visibility = ["//visibility:public"],
)

go_test(
    name = "api_test",
    srcs = glob(["*_test.go"]),
    embed = [":api"],
    deps = ["@com_github_stretchr_testify//assert"],
)

go_binary(
    name = "server",
    embed = [":api"],
    visibility = ["//visibility:public"],
)

注意 glob 的用法:Bazel 允许 glob,但它会在加载阶段展开为确定列表,因此仍然是封闭的。真正危险的是在动作里读环境变量或访问未声明的文件——这会破坏缓存正确性。

2.3 远程缓存与远程执行

Bazel 的远程缓存基于 gRPC 的 Remote Execution API(REAPI),任何实现了该协议的存储后端都能接入:bazel-remote、BuildBuddy、Buildbarn、原生 GCS/S3 适配器。

# .bazelrc:接入远程缓存
build --remote_cache=grpcs://cache.internal:8980
build --remote_timeout=600
build --remote_upload_local_results=true

# 严格模式:缓存未命中时不要静默重跑(便于发现缓存问题)
build --experimental_guard_against_concurrent_changes

# 远程执行(把动作本身发到集群执行,而不只是复用结果)
build --remote_executor=grpcs://exec.internal:8980
build --remote_instance_name=projects/platform/instances/default
build --jobs=200

远程缓存与远程执行是两件事,很多人会混淆:

能力作用命中时的收益
远程缓存(Remote Cache)复用结果跳过整个动作的执行
远程执行(Remote Execution)把动作发到集群跑本地不用等,且并行度不受本机核数限制

2.4 代价与边界

Bazel 的严格性不是免费的:

  • 声明成本高:每个包都要写 BUILD 文件,glob 之外的动态依赖(如运行时扫描目录)需要改造成显式声明。
  • 生态绑定:非主流语言的 ruleset 维护质量参差不齐,遇到问题往往要自己写 Starlark。
  • 迁移成本大:从 npm/Maven 迁移到 Bazel 通常需要数周到数月,且期间要维护双构建。

如果仓库是单一语言、且没有跨语言统一缓存的诉求,Bazel 的收益往往抵不过它的复杂度。这时更适合看 Maven 与 Gradle 构建优化 里的 JVM 生态方案。

3. Nx:单仓任务图与计算缓存

3.1 项目图与 affected

Nx 从 Angular CLI 起家,如今是 TypeScript/JavaScript 单仓的主流选择。它的核心是项目图(Project Graph):Nx 静态分析每个项目的 package.json、tsconfig.json 与源码 import,自动推断项目之间的依赖关系。

# 查看项目图
nx graph

# 只构建受当前改动影响的项目(affected)
nx affected -t build --base=origin/main --head=HEAD

# 只跑受影响的测试
nx affected -t test --base=origin/main

affected 是 Nx 最有价值的能力:它把「改了什么文件」映射到「哪些项目受影响」,从而把全量任务收缩成增量任务。这与 Turborepo 的 monorepo 实践 思路一致,但 Nx 的图推断更细(能识别类型依赖、隐式依赖)。

3.2 计算缓存

Nx 的缓存叫计算缓存(Computation Cache),键由任务名、项目名、输入文件哈希、环境变量、依赖任务的输出哈希共同决定。

// nx.json
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true,
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/tsconfig.spec.json"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  }
}

三个关键字段:

  • inputs:定义「什么算输入」。^production 表示依赖项目的生产输入也参与哈希。
  • outputs:定义「缓存什么产物」。漏写会导致命中后产物丢失,出现「缓存命中但构建失败」的诡异现象。
  • dependsOn:^build 表示先构建所有依赖项目,构成任务图的边。

3.3 Nx Cloud 的远程缓存与分布式执行

本地计算缓存只在本机生效。要让 CI 与开发者共享缓存,需要接入 Nx Cloud(或自建 remote cache 服务)。

# 接入 Nx Cloud(远程缓存 + 分布式任务执行)
nx connect

# CI 中启用分布式执行:把任务分发到多台 agent
NX_CLOUD_DISTRIBUTED_EXECUTION=true NX_CLOUD_ACCESS_TOKEN=*** nx affected -t build test lint

分布式执行(Distributed Task Execution, DTE)会把 affected 任务图切成若干份分发给多台 agent,最后汇总。它与远程缓存的区别同样重要:缓存是「复用结果」,DTE 是「并行分摊计算」。

4. Turborepo:轻量编排与远程缓存

4.1 turbo.json 管线

Turborepo 的定位是「轻量」:它不推断项目图,而是靠工作区(workspace)的 package.json 依赖声明来构造任务图;也不强制声明输入输出,而是靠约定加少量配置。

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["$TURBO_DEFAULT$", "!**/*.md"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
# 全量构建
turbo run build

# 只跑受影响的包(Turborepo 会比对 base 分支的 git 差异)
turbo run build --filter='...[origin/main]'

# 指定包及其依赖
turbo run test --filter='web...'

--filter='...[origin/main]' 就是 Turborepo 的 affected 等价物:方括号里的 ref 表示「相对这个 ref 有变化的包及其依赖者」。

4.2 缓存键与 hash

Turborepo 的缓存键由 git 文件哈希、任务定义、环境变量、依赖任务的哈希组成,可以用 --dry=json 观察:

turbo run build --dry=json | jq '.tasks[] | {taskId, hash, cache}'
{
  "taskId": "web#build",
  "hash": "a1b2c3d4e5f6",
  "cache": { "status": "MISS", "timeSaved": 0 }
}

一旦某个包的 hash 变了,所有依赖它的下游任务都会重算——这就是为什么「改一个底层工具包会导致大面积重建」。合理的做法是把底层工具包的变更频率控制住,并把不参与产物的文件(文档、测试)从 inputs 里排除。

4.3 局限

Turborepo 的轻量是优点也是边界:

  • 只服务 JS/TS 生态:非 Node 生态的构建需要自己包一层脚本,缓存粒度粗。
  • 不强制封闭性:任务可以随意读环境变量、访问网络,缓存正确性靠开发者自觉。
  • 不支持远程执行:只能复用结果,不能把计算分发出去(这一点与 Bazel 差距明显)。

如果仓库是纯前端/Node 单仓、团队规模中等,Turborepo 的性价比最高;如果有多语言、有远程执行诉求,就该认真评估 Bazel。

5. 远程缓存的设计要点

5.1 缓存键怎么取

无论用哪个系统,缓存键的组成决定了命中率与正确性的平衡。

键的组成部分是否必须说明
输入文件内容哈希必须用内容而非 mtime,避免切分支误判
任务命令与参数必须命令变了结果就不同
依赖任务的输出哈希必须传递式失效的基础
工具链版本(node/go/jdk)必须编译器版本影响产物
环境变量白名单按需只纳入真正影响产物的变量
操作系统与架构按需产物跨平台不通用时必须纳入
反面教材:把时间戳、随机数、CI run id 放进键
  → 每次都 miss,缓存等于没有
反面教材:环境变量一个都不纳入
  → 本该失效的场景复用了旧产物,出现「本地好、CI 坏」或反之

5.2 存储与隔离

远程缓存的存储选型:
  自建:bazel-remote(轻量,支持 S3/GCS 后端)、Buildbarn
  托管:Nx Cloud、BuildBuddy、Turborepo Remote Cache(Vercel)

隔离维度:
  按仓库隔离   → 不同仓库的哈希空间不应混淆
  按分支隔离   → main 与 feature 分支共享缓存通常安全,但 release 分支建议隔离
  按权限隔离   → 只读/读写 token 分离,PR 来自 fork 时用只读

对于容器镜像层面的缓存,机制类似但落地在 registry 上,可参考 Docker 远程构建缓存 的做法。

5.3 失效与可观测

# Bazel:查看缓存命中统计
bazel build //... --profile=profile.json
# 分析 profile.json 可得到各动作的缓存命中率

# Nx:查看任务缓存命中
nx run-many -t build --verbose

# Turborepo:dry run 看每个任务的 cache 状态
turbo run build --dry=json | jq '[.tasks[] | .cache.status] | group_by(.) | map({(.[0]): length}) | add'

必须把缓存命中率作为 CI 的常规指标上报,否则缓存退化(命中率从 85% 掉到 40%)往往无人察觉。

6. 选型矩阵

维度BazelNxTurborepo
语言覆盖多语言(需 ruleset)JS/TS 为主,可扩展JS/TS
任务图显式声明,最严格自动推断 + 配置workspace 依赖
增量粒度文件级动作项目/任务级包/任务级
远程缓存原生 REAPI,可自建Nx Cloud / 自建Vercel / 自建
远程执行支持支持(Nx Agents)不支持
学习曲线陡中平缓
迁移成本高(周~月)中(天~周)低(小时~天)
适合规模大型多语言单仓中大型 JS/TS 单仓中小型 JS/TS 单仓

6.1 何时不该上 Bazel

以下场景上 Bazel 往往是负收益:

  • 单语言、单构建工具,且构建时间已经在 5 分钟以内。
  • 团队没有专人维护构建基建,BUILD 文件会逐渐腐化。
  • 大量动态依赖(运行时扫描目录、反射加载),封闭化改造成本高于收益。
  • 产物需要频繁的、非声明式的后处理(如手动打补丁)。

6.2 迁移的过渡策略

阶段一:先在 CI 引入远程缓存(Turborepo/Nx 最易落地),不改本地开发流程
阶段二:把 affected 分析接进 CI,让 PR 只跑受影响任务
阶段三:统一工具链版本,消除「本机 hash 与 CI 不同」导致的 miss
阶段四:(如需)再把核心多语言模块迁到 Bazel,其余保留原工具

7. 落地路径与常见坑

7.1 分阶段推进

1. 度量基线:记录当前 CI 平均构建时长与缓存命中率
2. 引入任务图:先让任务可并行,再谈缓存
3. 设计缓存键:从「内容哈希 + 工具链版本」最小集合开始,按需加白名单
4. 接远程缓存:先只读,观察命中率与正确性,再开放写入
5. 收敛 inputs:把文档、测试、快照从 inputs 中排除,提升命中率
6. 定期审计:每月看命中率趋势与缓存存储成本

7.2 常见坑

坑现象对策
键含时间戳/随机值永远 miss只用内容哈希与确定性输入
漏声明 outputs命中后产物缺失显式列出所有产物目录
环境变量未纳入复用了错误产物白名单纳入影响产物的变量
inputs 过宽改文档也重建排除非产物文件
缓存无隔离跨仓库污染按仓库/分支/权限隔离
只缓存不并行提升有限任务图 + 远程执行
无命中率监控退化无人知上报命中率并设阈值告警

7.3 一句话原则

构建提速 = 任务图摊平并行 + 内容哈希判定增量 + 远程缓存复用结果。
三者缺一,收益都会大打折扣。

小结

Bazel、Nx、Turborepo 不是「谁取代谁」的关系,而是三种严格程度的取舍。Bazel 用封闭性换来跨语言统一缓存与远程执行,代价是声明成本与迁移周期;Nx 用项目图与计算缓存平衡了能力与易用性,是 TS/JS 单仓的主力;Turborepo 用最少的配置覆盖了大部分前端单仓场景,代价是语言边界与远程执行的缺失。

无论选哪个,真正决定收益的是三件事:任务图是否被正确表达、缓存键是否只含确定性输入、命中率是否被持续监控。把这三点做好,构建时间才会从「随仓库规模线性增长」变成「随改动规模增长」,团队的日常节奏才会真正轻下来。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「devops」更多文章

  1. 值班工程与告警疲劳治理
  2. 基础设施代码测试:Terratest、Kitchen 与 InSpec
  3. 发布列车与版本节奏治理