去年做混合语言仓库的构建系统选型时,我花了整整两周把 Buck2 的源码翻了一遍。当时的场景很有代表性:C++ 服务、Rust 中间件、Java/Objective-C 移动端代码挤在同一个 monorepo 里,传统 Makefile 已经撑不住增量构建,Bazel 的迁移调研又卡在规则维护上。抱着“Meta 敢用 Rust 重写构建引擎,源码里一定有值得学的东西”这个想法,我把 buck2 仓库里核心模块逐个读了过去。这篇东西不是官方文档复述,而是一份源码实证的尽调笔记:从模块划分、Dice 增量引擎、Action/Artifact 模型,到 prelude 跨语言规则层的设计逻辑,最后给出一些企业选型时可以直接用的结论。
1. 重新思考构建引擎:Meta为什么从Buck1走到Buck2
1.1 Buck1时代的真实痛点
Buck1 是 Meta 在 2013 年开源的 Java 构建系统,在 Meta 内部支撑了多年 Android、iOS、后端代码的构建。读过 Buck2 源码再回头看 Buck1 的设计,很容易感受到团队当年真正的痛点在哪里。
首先是正确性。Java 写的 Buck1 为了保证构建速度,大量依赖内存态里的图缓存,一旦有未声明的隐藏依赖混进来,就会出现“上一次构建成功,下一次换个环境就失败”的经典问题。编译动作隔离性不足,进程间共享环境变量,文件系统访问没做严格限制,导致增量构建的结果经常受外部环境影响。这种不确定性在小仓库里感受不明显,但在 Meta 那种体量的 monorepo 里会被无限放大。
其次是性能。Buck1 的 daemon 是 Java 进程,内存占用高,GC 停顿在大规模并发下很致命。构建图越大,单次全局索引和重算的代价就越不可控。Meta 内部的工程师最直观的感受就是:全量构建还好,增量构建经常“慢到怀疑人生”,因为图里一旦有节点失效,冒泡范围往往比预想大得多。
最后是跨语言扩展的代价。Buck1 的核心和语言规则耦合得很紧,想加一种新语言支持,基本要动到核心逻辑,而不是在规则层做扩展。对于一家内部同时存在 C++、Java、Python、OCaml、Rust 的语言生态的公司,这种结构非常伤。
1.2 为什么是“用Rust重写”而不是“继续修”
从 Buck2 源码的底层就能看出来,Meta 对这个系统工程化是有执念的。整个核心用 Rust 编写,底层是 Tokio 异步运行时,大量使用 async/await 来做并发协调。选择 Rust 的原因,拆开来看其实非常务实:
- 无 GC,内存占用比 Java 低一个量级,构建系统的常驻进程可以轻量很多;
- 所有权模型在编译期挡住数据竞争,多人协作改核心代码时的维护负担明显更低;
- 和 C++、Rust 编译工具链交互时,不需要跨语言桥接层,直接调进程、读文件、处理信号都很自然;
- 发布产物是静态编译的单个二进制,部署和 CI 接入都比 JVM 环境简单。
这些点在源码里是有迹可循的:Buck2 的 server 端所有核心状态都收敛在 Rust 的模块边界内,命令分发、事件流、磁盘物化这些模块之间通过明确的接口通信。可以说不完全是“重写”,更像是“重新设计”——把 Buck1 时期积累的正确性教训直接做进了架构里。
1.3 重构后的核心目标排序
通读源码后能看到,Buck2 把设计目标很清晰地排了个序:正确性 > 增量性能 > 语言扩展性 > 交互体验。这个顺序不是猜的,从它对待沙箱和隐藏依赖的严格程度就能看出来——宁愿牺牲一点执行速度,也要用隔离机制把未声明依赖暴露出来。对企业来说这个排序非常重要,后面很多选型决策其实都是从这里推出来的。
2. 源码目录导读:从crate划分判断架构取向
2.1 顶层crate布局
一个构建系统的架构,最直观的镜像就是它的 crate 划分。Buck2 仓库的顶层结构大致是这样的:
| 目录/crate | 主要职责 |
|---|---|
buck2_core | TargetLabel、Configuration、Package 等基础类型定义 |
buck2_node | 构建图节点(unconfigured/configured target node) |
buck2_interpreter | Starlark 解释器集成,把 BUCK 文件转成内部结构 |
buck2_analysis | 规则分析期:执行规则函数、生成 Action |
buck2_execute | 动作执行:本地执行、沙箱、物化、远程执行 |
buck2_build | 构建流程的编排 API |
buck2_query | 查询子命令 |
buck2_bxl | 脚本化构建/查询接口 |
buck2_client | 命令行客户端 |
buck2_server | 后台服务端,承载状态与调度 |
dice/ | 独立封装的动态增量计算引擎 |
starlark-rust/ | Meta 用 Rust 实现的 Starlark 语言运行时 |
prelude/ | 内置语言规则集合 |
第一眼看到这个结构时,我最大的感受是:Buck2 把“语言无关的核心”和“语言相关的规则”切得非常干净。核心部分完全不知道 Rust、C++、Python 是什么,它只知道 Target、Action、Artifact、Configuration 这些抽象概念。每种语言的支持都收敛在prelude/里,以 Starlark 规则的方式存在。
2.2 依赖方向与分层逻辑
阅读源码时我特意梳理了 crate 之间的依赖方向,整体是单向的:
基础类型层(buck2_core)→ 节点层(buck2_node)→ 分析层(buck2_analysis)→ 执行层(buck2_execute)→ 编排层(buck2_build/buck2_server)。
dice和starlark-rust是横切的基础设施,被分析层和节点层共同依赖。这个分层保证了“修改一种语言规则”和“修改构建核心”互不干扰。实际价值在于:企业内部的构建规则往往比开源版本脏得多,如果核心和规则耦合,每次规则改动都要重新编译核心二进制,迭代效率会非常痛苦。Buck2 把规则全部走 Starlark 解释器,改规则不用重新编译核心,这是它适合企业落地的一个隐性优势。
2.3 prelude和starlark-rust之间微妙的分工
看到prelude/下面的rust/、cpp/、java/、python/、ocaml/、apple/这些子目录,基本能判断 Buck2 的跨语言能力不是靠核心堆出来的,而是靠一套足够强的规则语言 + 可复用的规则实现。starlark-rust是支撑这一切的运行时。它不是简单的 Python 子集解释器,而是内置了构建系统需要的类型系统和并发隔离机制。源码里可以看到它提供了 list/dict/struct 等类型,同时严格控制文件系统访问和外部副作用,保证规则代码是可确定性求值的。
3. 一次构建请求在源码里的完整旅程
3.1 从命令行到ConfigTarget
拿一个最简单的命令举例:
buck2 build //main:hello我在源码里顺着客户端入口往 server 端追这条请求,发现它走的路径比想象中长。client 进程先解析 target pattern、加载本地配置,然后把“构建意图”发给 server 进程。server 端接收到请求后,第一件事不是闷头构建,而是把 target pattern 解析成对应的对应的TargetNode,再结合Configuration生成ConfiguredTargetNode。
这个“先配置再构建”的顺序非常重要。Buck2 里配置不是简单的环境变量集合,而是一等公民,参与缓存键和依赖追踪。同一个源 target 在不同配置下是不同节点,比如 debug 版和 release 版用的是同一份 BUCK 文件,但会映射到不同 configured target,各自的 analysis 结果和 action 缓存也不互相污染。
3.2 分析期:从规则函数到Action集合
读取 BUCK 文件后,server 端会通过 Starlark 执行规则函数。比如rust_binary这个规则在prelude/rust/rule.bzl里被定义成一段 Starlark 函数,函数做的事大概是:把传入的srcs列表、依赖库、链接参数拼装成一次编译命令,声明输出可执行文件,最后返回一组 Action。
这个阶段就是buck2_analysiscrate 的核心工作。它产出的是一张“逻辑动作图”,图的节点是 Action,边是 Artifact 之间的依赖关系。值得注意的是,这个阶段不会真的执行任何编译命令,只是把“要做什么”描述清楚。这样设计的好处是:只要 Analysis 阶段结果不变,后面无论执行多少次都可以复用同一张动作图,这是缓存命中的前提。
3.3 执行期:调度、沙箱与物化
动作图生成后进入buck2_execute。执行期会做几件事:
- 检查本地 Action Cache,key 命中就直接跳过;
- 没有命中,就把输入 Artifact 物化到本地磁盘;
- 在沙箱环境里运行命令;
- 把输出文件收集起来,写入本地 CAS/远程 CAS;
- 更新缓存记录。
这整个流程里最容易被忽略的是“物化”这一步。Buck2 的 Materializer 模块负责把那些可能只在缓存或远程存储里存在的 Artifact 同步到本地文件系统。源码里能看到它对文件状态的管理非常细致,目的就是在并发构建时避免同一个文件被重复物化或覆盖。我在读代码时一度觉得这套东西“过度设计”,后来想想,企业级构建失败的案例里,一大半都是输出现场混乱导致的,这种严格是有道理的。
4. Dice引擎:增量构建的真正算力内核
4.1 增量构建的本质难点
如果只是把“构建”做成“执行命令”,那 Buck2 和其他老牌构建系统拉不开差距。它真正值钱的地方是增量能力,而增量能力背后的内核就是 Dice。
增量构建难在哪?举一个简单例子://main:hello依赖了//lib:util,你改了util的源码,构建系统需要重新编译util,同时重新链接hello。但如果hello也依赖另一个不变的模块,那个模块是不需要动的。问题是,真实项目里依赖关系是海量且动态的,构建系统如果不能精确知道“谁依赖谁”,就只能选择整图重算或者整图保守失效,这样增量就退化成全量。
4.2 Dice的求值模型
Dice 全称是 Dynamic Incremental Computation Engine,它的核心思路是:所有计算结果都作为“计算节点”存入版本化存储,每个节点自动记录自己在求值过程中访问了哪些其他节点。
在源码里能看到 Dice 提供了一套声明式计算接口,开发者用类似 derive 宏的方式标记一个异步计算函数,框架会自动追踪它的依赖。关键在这三个特性:
- 缓存:输入不变时直接返回旧结果;
- 依赖追踪:每次求值过程都会记录读过的子节点;
- 版本化失效:某个节点输入变化时,只让“确实依赖了它”的节点失效,其他节点原样保留。
和传统的“手动写 cache invalidation”相比,Dice 最大的优势是把失效这件事自动化了。分析规则、解析 BUCK 文件、计算配置,全部作为计算节点纳入 Dice,因此任何一层的变化都能以最小粒度传递到下游。这让我想起用黑盒 memoization 提升效率的思路,但 Dice 把依赖边暴露成数据结构,系统可以精确控制重建范围。
4.3 与Bazel Skyframe的对照
读 Buck2 时很难不拿 Skyframe 对比。Bazel 的 Skyframe 也是图增量计算模型,在内存中维护节点和依赖边。两者目标一致,风格上有明显差异:
| 维度 | Bazel Skyframe | Buck2 Dice |
|---|---|---|
| 依赖收集方式 | 节点求值时自动记录依赖 | 计算函数执行时显式收集依赖 |
| 失效传播 | 标记脏节点冒泡重算 | 版本化存储,按依赖边定向失效 |
| 缓存粒度 | 节点级 | 节点级 + 投影(部分字段)级 |
| 独立性 | 与Bazel核心耦合 | 独立库,可复用 |
Dice 能独立成 crate 这件事本身就说明它在设计上有更强的模块化倾向。实际给企业带来的好处是:如果有需要自己做构建管线的场景,甚至可以借鉴 Dice 的接口设计来实现一个定制版增量计算层,不必整体引入 Buck2。
5. Action与Artifact模型:构建正确性的地基
5.1 Action是构建的最小事实单位
在 Buck2 的源码里,Action 的结构非常纯粹:一条命令行、一组输入、一组输出、环境变量声明。构建系统不关心你跑的是编译器还是脚本,它只负责严格按描述执行,然后把产出物归属到 Action 上。
这里有一个很关键的设计:Action 的缓存 Key 不是简单的“输入文件路径 + 命令行”,而是对输入内容做摘要后的内容寻址 Key。只要命令没变、输入内容没变,无论执行多少次,Action 的结果都认为是一致的。这是远端缓存和分布式执行的基础——不同机器之间不需要约定时间戳,只需要保证内容一致。
5.2 Artifact的来源和物化
Artifact 在 Buck2 里不是一个“文件路径”那么简单。源码里能看到它区分了 Source Artifact(源码本身)、Build Artifact(构建产物)、Declared Artifact(规则声明的输出)和 Undeclared Artifact(编译过程中实际产生的文件)。
这个区分的意义在于:编译器经常会产生规则作者没预先声明的产出文件,比如 debug 信息文件、临时文件。如果不把这些纳入追踪,下次增量构建就可能读到脏文件。Buck2 通过 depfile 和沙箱监控来发现这类隐藏输出,把它们补充进产物集合。我在读这段时最大的体会是:真正成熟的构建系统,会把“意外”变成“被管理的状态”,而不是赌编译器能严格按声明执行。
5.3 缓存命中率是企业的生命线
源码层面的缓存机制再完美,落到企业里也就是一句话:缓存命中率决定构建快慢。Buck2 本地 Action Cache + 本地 CAS + 远程 REAPI CAS 的三层缓存结构,意味着命中缓存的动作可以完全跳过本地执行。对于 CI 环境,只要远端 CAS 积累了足够多的历史产物,新提交的构建往往只需要执行真正变化的那一小撮动作,其余全都拉缓存。
但要注意一个反直觉的点:远程缓存的效果取决于“构建的确定性”。如果构建动作读入了未声明的环境变量、当前时间、绝对路径,那么每次 Action Key 都会变,远程缓存就形同虚设。Buck2 的沙箱机制就是为了尽可能把这些不确定性暴露出来。企业里如果直接关掉沙箱换速度,短期看起来快了,长期会把缓存命中率拖垮。
6. 跨语言能力的真相:prelude与Starlark规则层
6.1 核心不管语言,规则层统一一切
很多人听到“跨语言构建系统”,第一反应是“它能同时编译多种语言”。但 Buck2 源码给出的答案其实更进一步:核心不直接认识任何具体语言,跨语言能力集中在prelude/的规则层。
也就是说,”能构建 Rust“这件事不是核心提供的,而是prelude/rust/rule.bzl这套 Starlark 代码提供的。只要有人写出足够好的 C++ 规则、Rust 规则、Python 规则,Buck2 核心就能用同一套图模型把它们统一在一起。这也解释了为什么社区可以低成本扩展新语言——不需要改核心,只需要写规则集合。
6.2 用Starlark写规则,意味着什么
Starlark 是 Python 的子集方言,Meta 用 Rust 重写了它的解释器。在源码里看starlark-rust,能感觉到它不是一个玩具解释器,它对类型、作用域、错误信息都做了大量工程化处理。对使用者来说,写规则的门槛比学 Java/Skyframe 插件低很多。
用 Starlark 定义规则的另一个潜在好处是:规则可以做成纯函数,相同的入参总是产生相同的 Action 集合,这正是 Dice 缓存需要的条件。如果规则代码里出现不纯的操作,比如读取环境变量、访问网络,就会破坏可缓存性。所以 Buck2 对 Starlark 层的外部副作用管得很严,这其实是对企业用户的一种保护。
6.3 一个Rust规则的生命周期
我在源码里顺着rust_binary的规则实现走了一遍,它大概做了这几件事:
- 接收
name、srcs、deps、edition等参数; - 根据依赖关系生成
rustc命令行,把依赖库的位置传给编译器; - 声明输出:可执行文件、可能的
.d文件; - 如果依赖里有其他语言产物,规则还要负责把 C ABI、链接参数这些信息传递进来。
这种规则层的抽象让跨语言依赖变得可表达:一个 Rust 二进制的依赖可以是一个 C 库 target,规则会负责在链接阶段把正确的静态库路径传给rustc。这种“语言边界在规则层熔接”的设计,才是 Buck2 在 Meta 内部能支撑海量异构代码库的真正原因。
除了规则本身,buck2 bxl也是源码里值得注意的扩展点。它允许用 Starlark 脚本一次性组合查询、分析和构建步骤,解决 CI 流水线里一些“既要看依赖关系又要触发构建”的复合需求。企业想做定制化工具链时,BXL 比命令行拼接更可靠。
7. 企业级落地:源码尽调留给选型的几条实打实结论
7.1 Buck2适合谁,不适合谁
从源码结构看下来,适合上 Buck2 的团队画像是这样的:
- 多语言混合的较大型代码仓库,增量构建问题已经很明显;
- 团队愿意投入人力维护 Starlark 规则和 toolchain 配置;
- 构建环境和产物需要高度可复现,对正确性要求高于对短期构建速度的要求;
- 已经有远程执行或者至少集中式缓存的基础设施规划。
不适合的也很清晰:单语言小项目用 CMake/Cargo 就能过得很好,强行引入 Buck2 只会增加规则维护成本;团队没有专职构建效能工程师的话,遇到规则问题会非常被动。
7.2 迁移前一定要想清楚的三件事
第一,第三方依赖管理不是免费午餐。Buck2 没有像 Cargo 那样内置的中心依赖解析,第三方库的获取和版本管理需要你在规则层里自己处理。Meta 内部有自己庞大的依赖清单,但企业迁移时需要评估清楚这个成本。
第二,远程执行不是必需品,但没有集中缓存会很难受。单纯的 Buck2 本地增量已经比很多传统系统好,但如果 CI 环境每次都从零开始而没有远程缓存,构建加速效果会被严重削弱。REAPI 兼容的设施是长期最优解,初期可以先做一台 CAS 服务器。
第三,沙箱机制在 Linux 上体验最完整。如果团队主力开发机是 macOS,本地沙箱的支持力度会弱一些,构建最终的可复现性要到 Linux CI 环境里才体现得最充分。
7.3 从源码层面看到的扩展点和风险控制
源码里另一个值得企业关注的点是事件流设计的完备性。buck2_events和相关模块提供了较完整的执行过程可见性,这在排障时非常有用。配合日志审计,可以追溯到每个 Action 的输入摘要、输出摘要、执行耗时。对于动辄上万动作的构建,这种可观测性不是锦上添花,而是必须具备的能力。
风险点也有:Buck2 的更新节奏和执行性能在不同平台上有差异;完全自建规则的风险要高于直接用prelude;如果核心推进了一个 breaking change,规则层可能需要同步适配。控制这类风险的办法是:在企业的 monorepo 里像锁依赖一样锁定 Buck2 版本,并在规则目录里做独立的回归测试。
我自己读完源码后的整体判断是:Buck2 不是“又一个构建工具”,它代表了把构建系统当成分布式数据流系统来设计的思路。企业引入它的价值不在于某个命令快了 10%,而在于获得了一个可以支撑长期扩展、可观测、可增量、可远程化的构建底座。当然,前提是团队有足够意愿为规则层投入,而不是指望装完二进制就自动拥有一切。