当一个仓库里的包从 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. 选型矩阵
| 维度 | Bazel | Nx | Turborepo |
|---|---|---|---|
| 语言覆盖 | 多语言(需 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 用最少的配置覆盖了大部分前端单仓场景,代价是语言边界与远程执行的缺失。
无论选哪个,真正决定收益的是三件事:任务图是否被正确表达、缓存键是否只含确定性输入、命中率是否被持续监控。把这三点做好,构建时间才会从「随仓库规模线性增长」变成「随改动规模增长」,团队的日常节奏才会真正轻下来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。