
如果你维护过万人规模的代码仓库大概能体会这种痛苦一个稍微大点的全量构建吃掉几十个G内存增量构建偶尔也因为缓存键设计不合理而近乎全量重跑最后大家养成了“不行就 clean 一下”的肌肉记忆。Meta在2023年开源Buck2时我第一时间把源码拉下来做了完整走读。这篇内容不是产品发布会通稿也不是PPT级别的架构复述而是基于facebook/buck2仓库的源码级尽调从crate划分、DICE增量计算引擎、Action/Artifact抽象、执行调度到远程执行协议逐层拆解Buck2为什么敢称自己是“下一代高性能跨语言构建引擎”。同时我会站在企业选型角度给出这套源码在工程取舍、迁移成本和成熟度上的真实评价。1. 为什么Meta要推倒重来Buck1时代的架构红利与债务1.1 Buck1的辉煌与天花板先说背景。Buck1是Meta在2013年开源的Java构建系统核心卖点是“单一仓库下的大规模并行构建”那个年代能用“规则动作”模型把C/Java/Android/iOS统一到一个构建系统里已经相当超前。我在早期项目里用过Buck1它的声明式BUCK文件、显式依赖列表、并行任务调度在当时的构建工具里属于第一梯队。但历史包袱也很明显。随着Meta内部仓库膨胀Buck1的架构天花板逐渐暴露全局构建图在内存中常驻Java堆动辄几十GGC暂停时间肉眼可见。增量模型以文件级/目标级为粒度依赖传播一旦不精确就会整棵子树失效重算。规则逻辑大量写在Java层语言扩展等于改Java代码跨团队协作成本越来越高。并行调度依赖JVM线程模型在几百核的构建机器上很难吃满CPU。这些问题其实不是“修一修”能解决的。Buck1的根子是“一个胖daemon 全局状态 大型规则接口”在这个地基上做精细化增量计算复杂度会指数级上升。Meta内部曾经花了不少精力优化Buck1但效果越来越像在给老房子加电梯。1.2 Buck2立项时的三条核心设计原则到了2019年左右Meta决定直接重写一个构建引擎这就是Buck2。源码里的设计思路非常明确我用三句话概括在仓库中反复出现的核心原则第一“能延迟计算的绝不提前计算”。Buck2把“构造构建图”和“执行构建”彻底分离很多在Buck1里必须在图构建阶段就完成的计算被推迟到真正需要的时候。这个设计直接降低了启动时间和内存峰值。第二“构建系统内核不知道任何语言”。Buck2的Rust内核只处理通用抽象Target、Action、Artifact、Configuration。C、Rust、Python、Java这些语言在Buck2里并不是内核的一部分而是通过Starlark规则集“外挂”进去的。这意味着跨语言支持不是靠内置一堆语言插件而是靠一套足够通用的“图展开”模型。第三“用数据库思维管理构建状态”。Buck2把构建过程中的中间计算结果、依赖关系、配置状态全部纳入一个增量计算引擎DICE而不是像传统构建系统那样只缓存最终产物。源码里大量使用版本化键、事务、分片存储这种设计让它可以在增量计算精度上做到非常细。这三条原则不是拍脑袋想出来的它们分别对应了Buck1在内存、扩展性、增量精度三个方向上的痛点。看懂这一点你就能理解为什么Buck2的源码布局会是现在这个样子。2. 源码仓库第一眼crate划分就是架构宣言2.1 核心crate职责地图把facebook/buck2仓库拉下来之后我第一件事是看crate目录。Rust工作空间的模块划分本身就代表了架构边界Buck2这一点做得非常清楚。我整理了一张核心模块表建议第一次读源码的读者直接对照这张表看crate路径职责关键抽象buck2_core路径、TargetLabel、配置、基础数据类型TargetLabel, Configurationbuck2_interpreterStarlark解释器与BUCK文件加载.bzl规则加载buck2_nodeTarget定义与依赖图节点计算TargetNode, ConfiguredTargetNodebuck2_build_api提供给规则层使用的构建APIAction, Artifact, AnalysisResultbuck2_dice动态增量计算引擎DiceTransaction, DiceKeybuck2_executeAction执行、本地/远程调度ActionRunner, BuckEventbuck2_serverdaemon进程与前端请求处理Command协调, 事件流buck2_query类Buck1/Bazel的query查询构建图查询buck2_bxlBuck eXtension Language脚本化构建查询BXL脚本运行时这张表传递了一个非常重要的信号buck2_core不依赖buck2_executebuck2_execute也不依赖任何具体语言规则。依赖方向几乎是从底层基础类型指向高层执行规则层通过buck2_build_api这个“接口面”跟内核交互。这种单向依赖让整个系统可以很容易地裁剪和替换组件。相比之下Buck1里Java层的Rule接口和引擎层深度耦合改一处动不动就要跟着改一串。2.2 追踪一条构建请求的源码路径光看表不够我建议你实际走一遍代码路径。当你在命令行执行buck2 build target时发生的典型链路是buck2可执行文件启动一个轻量客户端检查本地daemon是否存活如果没存活就拉起buck2_server守护进程。daemon通过文件系统watch机制获取仓库状态加载BUCK文件交给buck2_interpreter解析成Starlark环境。对每个目标节点做“配置化”configuration生成ConfiguredTargetNode这一步已经开始走DICE。分析阶段analysis把ConfiguredTargetNode展开成Action图每个Action声明明确的输入Artifact、输出Artifact、命令行和运行环境。Action图进入buck2_execute调度器本地并发执行或通过远程执行协议发给远端集群。所有输出经过内容寻址存储CAS最后materialize到本地路径。我在源码里比较关注的是第4步和第5步之间的缓冲。Buck2在“分析”和“执行”之间设置了显式的接缝分析阶段不会真的去跑编译命令只负责生成Action描述。这意味着action的指纹fingerprint可以在不落地执行的情况下被计算和缓存远程执行模式才能真正跑起来。如果一套构建系统在分析阶段偷偷执行了外部命令那远程缓存和分布式执行基本就废了。3. DICE增量计算引擎走读速度快到离谱的根源3.1 DICE到底缓存了什么先给一个反直觉的结论DICEDynamic Incremental Computation Engine主要缓存的不是“编译产物”而是“计算单元的结果”。传统构建系统的缓存通常是文件级或目标级只要输入文件hash没变就直接复用输出文件。这种方式对“单个编译动作”有效但对“构建图本身的生成过程”无能为力。Buck2把整个构建过程中的许多计算步骤都变成了可复现、可依赖跟踪的函数式计算比如“读取某个BUCK文件并解析”“计算某个target的配置化信息”“展开某个配置化目标的Action列表”这些都是DICE里的键值对。我们看buck2_dice的实现核心对象是一个类似事务的句柄DiceTransaction。你可以把它理解成一个小型内存数据库的读写快照。一次构建命令会启动一个事务在这个事务里你会发现每个计算单元都有稳定且可hash的key。计算过程中如果读取了其他key引擎会自动记录依赖边。下次事务再访问同一个key时如果它的依赖没有变化就直接从内存状态里返回缓存结果。如果依赖链上某个key的版本变化只有受影响的下游计算需要重算。这种设计我在实际做性能分析时感受特别深。比如你改了某个头文件传统构建系统可能只是让依赖它的编译单元重跑但如果你的依赖图本身是动态生成的比如某个编译参数影响了后续target展开很多系统就只能重新展开整棵子树。Buck2因为把“BUCK文件读取”“配置计算”“图展开”本身都看作DICE键失效范围可以精确到真正受影响的那些节点。3.2 并发模型没有大锁的增量数据库很多人会以为DICE只是一个Map缓存读完源码你会发现它远不止如此。buck2_dice在并发模型上的设计才是它能支撑高并行度的根本。传统构建工具的daemon包括早期Buck1经常需要一把“大锁”来保护全局构建状态所有分析线程、执行线程都要抢这把锁导致CPU越多收益越不明显。Buck2的DICE为了实现高并发把状态存储和组织方式设计得有点像“分段数据库”不同key的计算可以并行进行互相只通过显式依赖发生联系。一个key的计算如果依赖其他key计算调用会异步等待相当于在依赖图中形成自然的调度点。DICE内部对内存数据做分片尽量减少热点竞争。每次命令使用事务版本隔离避免读写交错导致状态不一致。从源码角度看这意味着分析阶段和执行阶段都有机会真正并行起来。一个大型仓库的构建过程中A目标还在等头文件变更检测B目标已经在DICE里重算配置C目标已经进入本地执行队列。构建系统的吞吐量不再被单一全局状态锁限制。我自己解读DICE源码时的一个体会是它本质上是一个“以函数依赖为核心的内存计算系统”只不过键值不是普通数据而是构建领域的Target和Action。如果你写过增量计算框架会觉得这套模型非常熟悉。3.3 为什么这种设计对“跨语言”场景如此关键跨语言构建最大的痛点不是“能跑多种语言”而是“不同语言规则之间的依赖能被统一建模”。在Buck2里不管你是C的cxx_library还是Rust的rust_library最终都会被展开成同样的Action/Artifact模型并通过DICE参与同一套增量计算。这意味着一个Python规则产出的文件可以成为C规则的输入C规则的输出又可以驱动Java规则的分析。语言之间的边界在“动作图”这一层被抹平了剩下的只有显式声明的Artifact依赖和Action依赖。企业里常见的“脚本生成代码再编C”这种场景在Buck2里就是一个普通Action依赖另一个Action不需要什么特殊桥接。DICE给跨语言带来的另一个好处是失效传播规则统一了。你不用为每种语言维护一套缓存失效逻辑所有语言规则产生的结果都遵守相同版本的依赖追踪协议。这就是Buck2能把C、Rust、Python、OCaml等差异巨大的语言栈放在一个仓库里统一调度的根本原因。4. Action与Artifact跨语言抽象的关键一跳4.1 从Target到Action的展开过程在Buck2源码里Target是“用户声明的构建单元”Action是“真正能在机器上执行的最小命令单元”Artifact是“Action输入输出的文件/目录抽象”。三者之间差着一个关键阶段analysis分析。我走读代码时特别喜欢看这个转换过程。用户在一个BUCK文件里写cxx_binary( name hello, srcs [main.cpp], deps [:lib], )这个声明在DICE中会被解析为一个TargetNode。随后Buck2结合当前configuration平台、编译模式、flags生成ConfiguredTargetNode再进入analysis阶段调用cxx_binary这个Starlark规则最终返回一组Action。这些Action描述的不是“hello这个target”而是“在什么环境下用什么命令行、把哪些输入Artifact变成哪些输出Artifact”。slide这个逻辑我拿到本地试的时候发现一个很重要的细节Action的标识和缓存不依赖target名字而是依赖Action本身的声明内容包括输入文件hash、命令行、环境变量、依赖Action的输出。这就让远程缓存变成了可能——只要两台机器的Action声明一致哪怕target名字不同、目录路径不同也可以复用同一份远程缓存结果。4.2 Starlark规则层与Rust内核的边界Buck2选择了StarlarkBazel使用的Python子集方言作为规则语言并且在仓库里维护了自己的一套Rust实现也就是starlark-rust。为什么不用Python如果你去读buck2_interpreter的实现会发现几个很现实的原因性能稳定。规则解析和执行的JIT优化比较好控制不会被Python解释器全局锁拖后腿。确定性。Starlark禁止大部分系统调用和文件写操作规则代码只能通过Buck2提供的API表达意图这就保证了同一个规则在不同机器上行为一致。沙箱简单。没有任意文件访问和网络访问的规则代码对远程执行和缓存命中来说是前提条件。增量友好。Starlark表达式的结果可以变成DICE里的值参与依赖追踪。规则层和内核层的边界在buck2_build_api crate里定义得特别严格。规则作者能碰到的API是有限的通过AnalysisContext、ActionRegistry这些接口注册Action和声明Artifact。规则层不能直接操作执行器内部不能直接读写本地文件系统。这种边界带来的好处是内核可以放心大胆地对Action做内容寻址、缓存和远程调度因为它确定规则代码不会偷偷干“出格”的事。4.3 远程执行与内容寻址缓存企业分布式构建的地基如果说单机增量是Buck2的基本功那远程执行就是企业级构建的放大器。Buck2实现了Remote Execution APIREAPI流程上可以这样理解本地引擎把Action的完整描述发送给远程调度服务。远程服务根据Action的digest查CAS如果发现类似Action执行过就直接拿缓存输出。否则在远端机器上执行命令输出回传CAS本地再做materialize。源码里我对materialize阶段印象很深。Buck2不会简单地把文件从CAS复制到工作区它会处理符号链接、文件权限、目录结构、并发安全等一系列细节。对于iOS构建场景输出往往是.app或.ipa这种目录型ArtifactBuck2对这类“树形Artifact”有专门处理确保远程执行之后本地结构完全一致。对跨语言企业堆栈来说远程执行的价值在于构建不再依赖具体某台开发机的环境。C需要Linux工具链iOS需要MacPython需要特定解释器版本只要远程执行集群里配备了不同的执行平台你就能根据target的platform属性把合适Action调度到合适机器上。这个能力在源码里是完整的执行平台与配置系统的配合不是简单的外壳。5. 源码实证下的横向对比Buck2与Bazel、Buck1的取舍5.1 增量计算模型Skyframe与DICE的差异很多做构建系统的人会拿Buck2和Bazel对比因为Bazel也有自己的增量计算框架Skyframe。从源码看两者确实共享不少哲学把构建状态建模为依赖图、每个节点都可以缓存和失效、上层API以函数式风格为主。差异点需要仔细品Bazel的Skyframe里节点类型和图的组织方式更接近“文件系统/包/Target/动作”的层级。它做得非常好但有些状态下沉在Java对象里。Buck2的DICE更明确地把“配置configuration”当作第一等公民。如果你改动了一个全局编译flagBuck2能精细判断哪些ConfiguredTargetNode受影响而不是对整个包范围做大面积失效。DICE的事务API是显式的。这意味着外部工具比如一个IDE插件或BXL脚本可以开一个事务做复杂的查询和修改而不必担心破坏构建主事务的一致性。我没有说Skyframe比DICE差的意思两者是不同约束条件下的产物。但Buck2在“配置感知的增量计算”上确实走得更深这对企业里大量使用“同一份代码多配置构建”比如debug/release、不同CPU架构、不同平台flag的场景受益很大。5.2 运行时与内存模型JVM与Rust的长期博弈Buck1和Bazel都建立在JVM之上Buck2选择Rust这个选择本身就是一种宣言。从工程实践角度看JVM系构建工具在长时间daemon运行下内存会被class加载、JIT编译、GC分代等机制慢慢侵蚀。仓库越大JVM堆参数调优就越像一门玄学。Buck2用Rust后内存布局更可控没有GC暂停长时间运行下的内存曲线更平稳。并发调度方面Rust的async运行时Buck2使用tokio可以支撑大规模并发任务而JVM的线程模型在几百线程之后就开始面临上下文切换和锁竞争成本。另一个容易被忽视的点是启动速度。轻量客户端常驻daemon的模式在Buck1里也有但JVM daemon的启动时间一直很痛。Buck2的daemon冷启动明显更轻。如果你们公司的构建机器是几十核甚至上百核的高配服务器JVM构建工具的GC暂停和堆压力会随仓库规模非线性增长。Buck2在这类场景下的内存模型优势我在读源码和做小规模实验时都有明显体感。5.3 公开性能数据怎么读才不被带偏Meta官方公开的benchmark里有一个很亮眼的结论在Meta内部的大规模移动端/C构建场景下Buck2相比Buck1在增量构建上有约2倍提升某些全量构建场景提升更明显。但我要提醒一句这些数字来自Meta的仓库规模和硬件环境不代表你的项目一定能拿到同等收益。你应该关注的是性能背后的机理性原因Buck2减少了重复计算DICE缓存的是函数结果不只是文件。Buck2对“配置变化”的失效粒度更细多配置构建不会互相污染缓存。Buck2没有JVM GC和内存锁瓶颈在极端大仓库下吞吐更稳定。如果你只是个几万行代码的中小型项目换Buck2可能感觉不到2倍差距因为你的构建瓶颈可能不在图展开和增量计算上而在单条编译链路本身。这个判断我在后面迁移建议里会再展开。6. 企业级源码尽调的结论什么团队适合上车6.1 源码里看到的成熟度信号从“尽调”角度我不只看架构设计还会评估代码质量、工程化完整度和社区生态。几个观察Rust代码整体风格统一模块边界清晰核心crate的文档注释质量高。starlark-rust是独立、可复用的Rust库社区里已经有人单独使用它做其他DSL解析任务这说明规则层不是一次性代码。Buck2提供了比较完整的测试覆盖除了单元测试还有大量集成测试包括远程执行的模拟测试。开源协议方面buck2仓库使用的是Apache-2.0具体以仓库LICENSE文件为准对企业集成来说比较友好。GitHub上issue响应活跃官方团队确实在持续维护。更难得的是源码里能看到它在重生产环境实战过的痕迹。比如对符号链接、文件权限、并发目录操作的处理这些细节不是写demo的人会想到的一定是在Meta超大仓库里跑过之后才补上的。6.2 迁移前必须想清楚的五件事虽然我对Buck2评价偏高但企业落地之前有几个现实问题必须面对规则生态迁移成本。Buck1/Bazel现有的规则集不会自动变成Buck2规则。C、Rust这类常用语言有官方rules但内部自研语言、老旧的构建脚本需要自己用Starlark重写。远程执行基础设施。Buck2单机模式已经很好但要发挥最大价值需要部署REAPI兼容的远程执行集群和CAS存储。这不是一个晚上能搞定的。平台覆盖确认。Buck2在Linux/macOS上成熟度最高如果你有大量Windows构建需求或交叉编译场景必须先在目标平台上做全面验证。团队学习成本。BUCK文件、Starlark规则、BXL脚本、configuration模型这些概念跟传统Makefile/CMake习惯差别很大团队需要时间适应。仓库规模与收益匹配。Monorepo规模越大、并发构建需求越高Buck2的收益越明显。如果仓库只有几十个模块常规构建系统可能更省心。6.3 试用过程中我踩过的具体坑最后分享几个我实际试用中遇到的坑给打算动手的读者一点心理准备首次构建比想象中慢。因为DICE没有任何预热第一次构建要完成全部解析、分析和执行感觉比传统工具的全量构建还慢。这个阶段千万别急着下结论第二次构建才是真相。配置变化引发的“看似无效”的全量重算。如果你在命令行频繁切换--flag或修改全局配置DICE可能触发大范围失效这会让人怀疑增量能力。实际使用中应该尽量减少零散flag把它们收拢进稳定的配置维度。自定义规则时漏声明provider依赖会得到比较隐晦的错误。建议一开始就“显式优于隐式”把所有要传给下游的数据都通过provider传不要图省事。daemon日志才是调试主战场。Buck2命令行输出的错误信息很简洁很多根因在daemon的日志和事件流里。遇到诡异问题第一反应去看daemon日志而不是猜。文件watch异常会导致莫名失效。如果编辑器或版本控制工具产生大量瞬时文件事件Buck2会重新加载受影响BUCK文件。我在一些网络文件系统上遇到过watch不稳定可以考虑把仓库放到本地SSD再跑。我的整体判断是Buck2是一套为“大规模、多语言、高并发”而生的构建引擎它的架构思路和工程完成度在开源构建系统里属于第一梯队。但它不是银弹更适合有一定基础设施能力、仓库规模大到开始痛恨传统构建工具的团队。如果你正好在这样的团队我建议别急着全量迁移先用一个小仓库把DICE、远程执行、BXL这些概念跑通再逐步扩大边界。源码读得越细你会越明白它每个设计选择背后的代价也就越知道这笔投入值不值。