技术8/26/2026·约 4 分钟

二十三个产品,一个爆炸半径

我们的构建图用「加载全部 23 份产品声明」来推导一张跨产品身份表。因为 Bazel 按文件而非按符号失效,改一个产品的依赖就会重跑所有兄弟产品的测试——而把导出的符号收窄,一点用都没有。

一张正确而无用的表

我们一个仓库里有 23 个产品。某个时刻我们需要一张跨产品的表——不管某个产品做什么都恒为真的那几个事实:它住在哪个目录、用什么 slug 发布、bundle 标识是什么。

有一个显而易见的做法。每个产品本来就声明了全部这些,包括它在售卖期间会改的一切。于是加载这些声明,把需要的几个字段投影出来:

IDENTITIES = [
    {field: getattr(decl, field) for field in ("dir", "slug", "bundle_id")}
    for decl in ALL_PRODUCT_DECLARATIONS
]

它是正确的。它在人所说的意义上是 DRY 的。而它正在悄悄花掉构建系统里最重要的那个性质:爆炸半径。

原因只关于构建图的一件事:Bazel 失效一个 .bzl 文件时是整体失效,不是按符号失效。 从文件里读一个字段仍然意味着加载那个文件,而那个文件的内容从此就是你的依赖的一部分。

于是:

一张通过加载全部 23 份产品声明推导出来的表,会把每个产品都放进每个其他产品的爆炸半径——哪怕消费者只从它读一个字段。

下一个显而易见的动作是把投影收窄:导出一个只含那三个字段的专用符号,让表去加载那个。它没有用,而理解为什么才是值得留下的部分:

收窄导出的符号没有用。只有收窄 load 闭包才有用。

那个窄符号仍然住在加载了一切的那个文件里。只要推导过程够得到那些声明,失效也就够得到。失效的单位是文件,所以你能收窄的只有文件集合。

它到底花了多少

这不是一个我们出于原则而预先防住的理论问题。它表现为物理机器上的墙钟时间。

改一个产品的 kernel 依赖,会失效所有兄弟产品的 UI 测试 bundle——那是 mac mini 机队上 25 个套件全部重跑,因为机队就是跑失效的那一侧。贵的不是编译。贵的是被失效的那个东西,是一套要启动模拟器、驱动真实界面的测试。

于是失效形状不是「构建变慢了」,而是「一个终端类 app 的依赖改动,现在要花掉另外 22 个 app 的整套 UI 测试」。这是图的一个性质,所有人都看得见——而且,让它格外值得担心的是,它完全沉默。没有东西报错。套件不 flaky。只是在有人碰错产品的那几天,它多花四十分钟。

这个代价的有趣之处在于:不专门去找它就看不见它。没有人会注意到一个「比应该的慢」的测试套件。所有人都会注意到一个失败的测试套件。

按变化速率切分,而不是按方便

修法是换一个问法,而不是「我怎么避免重复这些字段?」。有效的那个问题是:哪些事实在变、以多快的速率变。

永久身份——产品所在的目录、它发布用的 slug、它出货用的 bundle 标识——在一个字面声明、什么都不加载的文件里。其余三个事实在产品还在市面上的时候不会变;改掉一个是一次事件,不是一个修订。

产品在售卖期间修订的一切——kernel 依赖、权限、分析配置、发布节奏、版本号——留在那个产品自己的声明里,从那里读。

于是失效变成构造上正确,而不是靠小心:

  • 改一个共享 scheme——bundle 标识约定、slug 格式——会正确地失效所有产品,因为每个产品确实都依赖它。
  • 改别的东西——某一个产品的权限——只失效那一个产品。

这正是你当初想要那张推导表的结果,而它来自那次切分,不是来自那次推导。那张窄表不是宽表的一个更窄版本;它装的是另一组事实,那组事实是按「集合的失效行为恰好是你想要的」挑出来的。

这里有代价,值得说明:同一个事实从此住在两个地方,而两个地方可能不一致。那是真实的风险,说它不存在正是这种设计烂掉的方式。

所以让不一致变得又响又局部

被复制的字段恰好是那些永久的,这意味着漂移在构建期就可检测——如果你去查。每个产品自己的包断言它的声明与注册表条目仍然一致。

位置才是关键。断言跑在漂移了的那个产品内部,所以不一致会让那个产品自己的构建失败,而不是在某个无关的地方失败。这是一段延迟的发现和一次当下就能归因的失败之间的区别。

规则是:改一个 scheme 要两处编辑——注册表和产品——并且正确地失效所有兄弟产品;改别的东西只要一处,且只失效它自己的 owner。这两半由同一个机制强制,而让前一半可被接受、正是后一半的功劳。

可外推的部分

这个形状比具体的修法更值钱,因为它适用于任何「构建图 / 依赖图 / 缓存的失效粒度比文件更细」的地方:

在你要从若干份逐项声明里推导出一张共享表之前,先弄清楚你的工具失效什么。如果它失效的是文件而不是符号,那么这次推导就花掉了每一项的独立性。

这与「每个编译单元都 include 的那个头文件」、或「每个文件只为了一个常量而 import 的那个 barrel 模块」是同一个失效。抽象是免费的、失效是全部的,而这两件事在你写下那一行的现场永远都看不见。

诊断办法毫不炫目但可靠:问一句,某个你依赖的文件改动一个字节时,工具会重算什么?如果答案是「这个文件」,那你的依赖就是这个文件,再窄的导出符号也改变不了它。把数据写两遍,在一个什么都不依赖的文件里,再加一条能抓住副本漂移的断言。

这比那个优雅版本多了重复,而它正是它能工作的原因:被重复的那几行是永远不变的那几行,所以它们永远不花你一次重建。

小结

在围绕它做设计之前,先弄清楚你的工具失效什么。按文件还是按符号的粒度,是「一张共享表花掉一个产品的独立性」和「不花」之间的差别。我们的抽象在每一个维度上都是对的,唯独在唯一重要的那个维度上错,而抽象出错的标准方式就是这样。

然后按变化速率切分共享数据,而不是按方便。永久事实属于一个零依赖的声明;一个东西在市面上期间会修订的一切,属于它自己。这让失效无需小心就正确——scheme 改动就该失效所有人,别的都不该。

接受那份重复,并让漂移变得便宜。两份永久事实的副本,好过一份把 23 个产品塞进同一个爆炸半径的推导副本。这份重复之所以可接受,正是因为那些事实从不改变,所以副本从不花你一次重建。

并且把一致性检查放在错误发生的地方。跑在漂移产品内部的那个检查,把一次分叉变成那里的加载期失败;跑在某个中心位置的检查,把它变成一次没人能归因的失败;而根本不存在的检查,会把它变成一个在生产里错误的 bundle 标识。

继续阅读