Build better software to build software better | Engineering at Slack
November 6, 2025 - 18 min read
我们负责交付Quip和Slack Canvas后端的构建流水线。一年前,我们正在追逐一些令人兴奋的想法,以帮助工程师更快地交付更好的代码。但我们有一个巨大的问题:构建需要60分钟。构建速度如此之慢,整个流水线变得不那么敏捷,反馈直到很晚才到达工程师手中。
我们通过结合现代高性能构建工具(Bazel)和经典的软件工程原则解决了这个问题。以下是我们的做法。
思考构建(和代码)性能
想象一个简单的应用程序。它有一个提供API和数据存储的后端服务器,以及一个呈现用户界面的前端。像许多现代应用程序一样,前端和后端是解耦的;它们可以独立开发和交付。
这个构建的依赖图如下所示:
我们用箭头表示构建元素(如源文件和可部署工件)之间的依赖关系,形成一个有向无环图。在这里,我们的后端依赖于一组Python文件,这意味着每当Python文件更改时,我们需要重新构建后端。同样,当TypeScript文件更改时,我们需要重新构建前端——但当Python文件更改时则不需要。
将我们的构建建模为由明确定义的工作单元组成的图,使我们能够应用与加速应用程序代码相同类型的性能优化:
- 减少工作量。 存储已完成的昂贵工作的结果,这样你只需做一次,用内存换取时间。
- 分担负载。 将正在进行的工作分散到更多的计算资源上并行执行,从而更快地完成,用计算资源换取时间。
缓存和并行化
这些技术对大多数工程师来说都很熟悉,通过代码的视角来思考它们有助于巩固它们如何应用于构建系统。以这个Python示例为例:
|
|
计算阶乘可能非常耗时。如果我们需要经常计算,速度会相当慢。但直觉上,我们知道一个数的阶乘不会改变。这意味着我们可以应用策略1来缓存这个函数的结果。然后对于任何给定的输入,它只需要运行一次。
|
|
缓存存储我们的输入(n)并将它们映射到相应的输出(factorial()的返回值)。n是缓存键:它的功能类似于构建图中的源文件,函数的输出对应于构建的工件。
细化我们的直觉,我们可以说这个函数需要具备一些特定的属性才能使缓存生效。它需要是密闭的:它只使用明确给定的输入来产生特定的输出值。并且它需要是幂等的:对于任何给定的输入集,输出总是相同的。否则,缓存就是不健全的,会产生相当令人惊讶的效果。
一旦有了缓存,我们希望最大化命中率:即调用factorial()时从缓存中得到答案的次数比例,而不是通过计算。我们定义工作单元的方式可以帮助我们保持较高的命中率。
为了说明这个想法,让我们看一段更复杂的代码:
|
|
这个函数附带了缓存,但缓存效果不佳。该函数接受两个输入集合:一个图像列表和一个要应用于每个图像的变换列表。如果调用者更改了任何这些输入,例如在列表中添加一个Image,他们也更改了缓存键。这意味着我们在缓存中找不到结果,必须从头开始做所有的工作。
换句话说,这个缓存粒度不够细,因此命中率很低。更有效的代码可能如下所示:
|
|
我们将缓存移动到一个更小的工作单元——将单个变换应用于单个图像——并相应地使用更小的缓存键。我们仍然可以保留更高级别的API;只是不在那里进行缓存。当调用者使用一组输入调用更高级别的API时,我们可以通过只处理我们以前从未见过的Image和Transform组合来满足该请求。其他所有内容都来自缓存。我们的缓存命中率和性能都应该得到提高。
缓存帮助我们减少工作量。现在让我们来看看分担负载。如果我们使用线程将图像处理分散到多个CPU核心上会怎么样?
|
|
让我们找出能够并行运行工作所需的条件,同时也注意一些关键的注意事项。
- 与缓存一样,我们需要严格且完整地定义要并行化的工作的输入和输出。
- 我们需要能够跨某种边界移动这些输入和输出。在这里,它是一个线程边界,但它也可以是一个进程边界或到其他计算节点的网络边界。
- 我们并行运行的工作单元可能以任何顺序完成或失败。我们的代码必须管理这些结果,并清楚地记录它提供的保证。在这里,我们注意到图像可能不按相同顺序返回的注意事项。这是否可以接受取决于API。
我们工作单元的粒度也在我们并行化的有效性方面起作用,尽管这其中有很多细微差别。如果我们的工作单元数量少但规模大,我们就无法将它们分散到尽可能多的计算资源上。任务大小和数量之间的正确权衡因问题而异,但这是我们在设计API时需要考虑的问题。
转向构建性能
如果你是一名软件工程师,你可能已经多次应用这些技术来提高代码性能。让我们将这些原则和直觉应用到构建系统中。
在Bazel构建中,你定义形成有向无环图的目标。每个目标都有三个关键元素:
- 作为此构建步骤依赖项或输入的文件。
- 此构建步骤输出的文件。
- 将输入转换为输出的命令。
(实际情况稍微复杂一些,但现在理解要点就足够了)。
让我们回到最初的那个示例应用程序。
Bazel目标定义可能看起来像这样的伪代码:
|
|
你可以将构建目标看作是定义(但不调用!)一个函数。注意到从上面的Python示例中延续过来的一些主题吗?我们详尽地定义了每个构建步骤的输入(srcs)和输出(outs)。我们可以将一个目标的srcs(以及构建它们的任何目标的传递性srcs,我们稍后会看到更多)视为我们希望缓存的函数的输入。
因为Bazel在沙箱中构建,所以将输入转换为输出的cmds只能接触我们声明的输入文件。而且因为我们的输入和输出只是磁盘上的文件,我们解决了跨边界移动它们的问题:只需复制!最后,我们必须向Bazel承诺:我们的构建步骤的cmds实际上是幂等且密闭的。
当我们做了这些事情,我们就免费获得了一些非凡的能力。
- Bazel自动缓存我们构建操作的结果。当目标的输入没有变化时,使用缓存的输出——无需构建成本!
- Bazel将构建操作分配到我们允许的尽可能多的CPU核心上,甚至跨构建集群中的多台机器。
- Bazel只执行我们想要的输出工件所需的那些操作。换句话说,我们总是为我们想要的输出运行最少量的必需工作。
就像在我们的代码示例中一样,当我们拥有一个由幂等、密闭且粒度适中的构建单元组成的定义良好的依赖图时,我们将从Bazel的魔力中获得最大收益。这些特性使得Bazel的原生缓存和并行化能够提供巨大的速度提升。
理论已经足够多了。让我们深入研究实际问题。
为什么Quip和Canvas更难构建
关键在于:Quip和Canvas比我们之前看的简单示例要复杂得多。这是我们为了理解构建如何工作而绘制的一个真实图表。不用担心阅读所有细节——我们将使用一个示意图来深入探讨问题和我们的解决方案。
当我们分析这个图时,我们发现了一些关键的缺陷,这意味着我们没有获得从Bazel获得速度提升所需的特性:
- 一个有向无环依赖图 → 该图定义不明确,实际上包含循环!
- 幂等、密闭、大小合适的工作单元。 → 构建执行单元非常庞大,并非所有都是幂等的,而且密闭性是一个挑战,因为许多构建步骤会改变工作目录。
- 细粒度的缓存键以保持高缓存命中率。 → 我们的构建是如此的相互关联,以至于我们的缓存命中率为零。想象一下,我们尝试调用的每个缓存“函数”都有100个参数,其中2-3个总是发生变化。
如果我们一开始就把Bazel应用到构建上,那将是无效的。在缓存命中率为零的情况下,Bazel的高级缓存管理无济于事,而Bazel的并行化相比构建代码中已有的临时并行化也几乎不会增加任何好处。为了获得Bazel的魔力,我们首先需要做一些工程工作。
关注点分离
我们的后端代码和构建代码紧密地交织在一起。在没有类似Bazel的构建系统的情况下,我们默认使用应用程序内的框架来编排构建图中的各个步骤。我们使用Python的多进程策略和核心代码库内置的异步例程来管理并行化。Python业务逻辑汇集了Protobuf编译并构建了Python和Cython工件。最后,由更多Python脚本编排的tsc和webpack等工具,将TypeScript和Less转换为Slack Canvas和Quip的桌面和Web应用程序使用的独特前端包格式。
当我们开始解开这个戈耳狄俄斯之结时,我们将注意力集中在后端和构建代码的结合如何扭曲了我们前端构建的图上。以下是该图的一个更易于理解的表示。
请注意我们的前端包“上方”的依赖树有多大。它不仅包括它们的TypeScript源代码和构建过程,还包括整个构建好的Python后端!这些Python源代码和工件是每个前端包的传递性源文件。这意味着,不仅是TypeScript的更改,还有每一个Python更改,都会改变前端的缓存键(我们之前提到的百个参数之一),需要进行昂贵的完整重建。
这个构建问题的关键在于Python应用程序和TypeScript构建之间的那条依赖边。
那条边平均每次构建花费我们35分钟——超过总成本的一半!——因为每次更改都会导致完整的后端和前端重建,而前端重建尤其昂贵。
在处理这个问题的过程中,我们意识到性能成本不仅仅是一个构建问题。它是我们在整个应用程序(后端、前端和构建代码)中普遍未能分离关注点的一个症状。我们存在以下耦合:
- 后端和前端之间
- Python和TypeScript基础设施及工具链之间
- 构建系统和应用程序代码之间
除了使构建性能在量上变得更差之外,这些耦合在我们的软件开发生命周期中也产生了质的影响。工程师无法推断他们对后端所做更改的爆炸半径,因为它也可能破坏前端。或者构建系统。或者两者兼而有之。而且因为我们的构建需要一个小时,我们无法在拉取请求级别运行构建来给他们早期警告。健康信号的缺乏,以及推理潜在后果的困难,意味着我们太频繁地破坏我们的构建和主分支,而这并非我们工程师的过错。
一旦我们通过关注点分离的视角理解了问题,事情就变得清晰:仅靠构建系统的更改我们无法成功。我们必须干净地切断前端和后端、Python和TypeScript、应用程序和构建之间的依赖关系。这意味着我们必须投入比原计划多得多的时间。
在几个月的时间里,我们一丝不苟地梳理了每个构建步骤的实际需求。我们用Starlark(Bazel用于构建定义的语言)重写了Python构建编排代码。Starlark是一种特意受限的语言,其限制旨在确保构建满足Bazel生效的所有要求。在Starlark中构建帮助我们强制执行与应用程序代码的完全分离。在需要保留Python脚本的地方,我们重写了它们,移除了除Python标准库之外的所有依赖:不链接到我们的后端代码,也没有额外的构建依赖。我们省略了所有的并行化代码,因为Bazel会为我们处理。我们将在下面思考分层时重新审视这个范式。
原始构建代码的复杂性使得定义“正确”行为具有挑战性。我们的构建代码大多没有测试。正确性的唯一标准是在特定配置下现有构建系统产生的结果。为了让自己放心,并让我们的工程师建立信心,我们构建了一个用Rust编写的工具,用于比较现有流程产生的工件和我们的新代码产生的工件。我们利用差异来指导我们发现新逻辑不太正确的地方,并进行迭代,再迭代。
当我们最终能够绘制出新的构建图时,这些工作得到了回报:
我们切断了那三个关键的耦合,并排除了Python更改可能破坏构建或改变前端输出的任何顾虑。我们的构建逻辑与它们构建的单元一起位于BUILD.bazel文件中,具有定义良好的Starlark API以及构建代码和应用程序代码之间的清晰分离。我们的缓存命中率大幅上升,因为Python更改不再构成TypeScript构建缓存键的一部分。
这项工作的成果是构建时间的大幅减少:如果前端被缓存,我们可以在短短25分钟内构建整个应用程序。这是一个很大的改进,但仍然不够!
通过分层设计组合性
一旦我们切断了后端与前端之间的耦合,我们更仔细地查看了前端构建。为什么它需要35分钟?在梳理构建脚本时,我们发现了更多关注点分离方面的挑战。
我们的前端构建器非常努力地想要提高性能。它接受一大组输入(TypeScript源文件、LESS和CSS源文件,以及由环境变量和命令行选项控制的各种旋钮和开关),计算需要完成的构建活动,将这些活动并行化到一组工作进程上,并将输出整理为可部署的JavaScript包和CSS资源。这是构建图这一部分的草图。
就像我们在这个项目中发现的许多情况一样,这种策略代表了编写时一系列合理的权衡:在没有更大构建框架的情况下,这是一种实用的尝试,旨在加速构建的一个部分。而且它比不对相同工作进行并行化要快!
这个实现有两个关键的挑战。
- 就像我们上面的
process_image()示例一样,我们的可缓存单元太大了。我们接收所有源文件并生成所有包。如果只有一个输入文件更改了怎么办?这会改变缓存键,我们必须重建所有内容。或者,如果我们想只构建一个包来满足构建过程中其他地方的需求怎么办?我们运气不好;我们必须依赖整个构建过程。 - 我们正在跨进程并行化工作,这很好——除非我们可以跨机器并行化,跨拥有更多CPU核心的机器。如果我们有这些资源可用,我们在这里无法使用它们。而且我们实际上降低了Bazel在其核心功能(并行化独立的构建步骤)上的效率:Bazel和脚本的工作进程在争夺同一组资源。脚本甚至可能在并行化Bazel已经知道不需要的工作!
在这两个方面,我们都很难以新的方式组合构建功能:无论是不同形式的编排多个包构建,还是不同的并行化策略。这是一个分层违规。
如果我们像这里一样,从操作系统到应用层绘制能力层级,我们可以看到构建器的边界横跨了多个层级。它结合了业务逻辑、任务编排框架的重要部分以及并行化。我们真正想要的只是顶层——逻辑——这样我们就可以在新的编排上下文中重新组合它。我们最终得到了一个工作编排器(构建器)内部嵌套另一个工作编排器(Bazel),两个层级争夺同一资源池的份额。
为了更有效,我们真正需要做的就是减少工作。我们删除了大量代码。新版本的前端构建器要简单得多。它不进行并行化。它有一个更小的“API”接口。它接收一组源文件,并构建一个输出包,TypeScript和CSS被独立处理。
这个新的构建器高度可缓存且高度可并行化。每个输出工件都可以独立缓存,仅以其直接输入为键。一个包的TypeScript构建和CSS构建可以并行运行,既可以相互并行,也可以与其他包的构建并行。而且我们的Bazel逻辑可以决定范围(一个包?两个?全部?),而不是试图管理所有包的构建。
再次看到与我们示例process_images() API的共鸣了吗?我们创建了细粒度的、可组合的工作单元,这极大地提高了我们并行化和缓存的能力。我们也分离了业务逻辑和其编排的关注点,使得我们能够在新Bazel构建中重新组合逻辑。
这个变化给我们带来了一些非常好的结果:
- 由于包构建以及TypeScript和CSS构建可以相互独立地缓存,我们的缓存命中率上升了。
- 给定足够的资源,Bazel可以同时并行化所有包构建和CSS编译步骤。这为我们带来了完整重建时间的显著减少。
作为一个额外的好处,我们不再运行自己的并行化代码。我们已经将该责任委托给Bazel。我们的构建脚本只有一个关注点:编译前端包的商业逻辑。这对可维护性来说是一个胜利。
成果与要点
我们让构建快了很多。更快的构建带来更短的工程师周期时间、更快的故障解决以及更频繁的发布。在将这些原则应用到我们的整个构建图之后,我们得到了一个比开始时快六倍的构建。
从定性的角度来看,我们得出了一个关键原则:软件工程原则适用于整个系统。而整个系统不仅仅是我们的应用程序代码。它还包括我们的构建代码、发布流水线、开发和生产环境的设置策略,以及这些组件之间的相互关系。
所以,无论你是在编写应用程序代码、构建代码、发布代码还是所有代码,我们的建议是:分离关注点。思考整个系统。为组合性而设计。当你这样做时,你应用程序的每个方面都会变得更强大——而且,作为一个愉快的副作用,你的构建也会运行得快得多。