将后端从 Rust 迁移到 Go,以及为何构建时间是决定性因素
Rust 给了我们很少需要的安全性,以及一个我们每天都在与之斗争的编译循环。以下是附有数据的真实权衡,它促使一项服务转向了 Go。
我们将一个生产环境的后端服务从 Rust 重写为了 Go,决定性因素并非运行时速度、内存占用或安全性,而是编译循环。对于一个每小时可能要修改十次的服务而言,能多快看到变更效果是其生死攸关的关键。本文将全面详述我们放弃了什么、得到了什么,以及我们如何决定将系统的哪些部分保留在 Rust 中。
以下内容并非对 Rust 的抱怨。Rust 完全兑现了它的承诺。我们的观点更具体一些:Rust 最擅长的事情,并非这个特定服务最需要的东西,而它所要求的代价,却是我们每次编辑时都必须付出的。
反馈循环占据了工作的大部分内容
大多数后端工作不是从一个空白文件开始编写新代码。而是修改一行代码,运行它,读取错误信息,然后再修改它。连续重复十次、二十次、五十次。如果这个往返过程需要四十秒,那么一上午的调试时间大部分都花在了等待上。如果只需要四秒,你就能一直专注于问题本身,并且你对正在测试的内容的短期记忆也能得以保留。
后一部分比原始的秒数更重要。存在一个阈值,大约在十秒左右,一旦超过这个时间,你就不再等待构建完成,而是开始切换上下文。你会按 Alt-Tab 切换窗口,会去读一条消息,然后你的思路就断了。当你回来时,你必须在脑海里重新构建你刚才在做什么的整个画面。四秒的构建时间能让你安坐在椅子上。而四十秒的构建则把每一次测试都变成了一次小小的中断,并且中断带来的影响不是相加,而是相乘的。
对于一个需要不断修改的服务来说,编译器不是一个后台工具。它是你一天中使用最频繁的部分。我们一直将其速度视为一个小麻烦,而实际上它决定了我们所有工作的节奏。
我们发布的 bug 并非 Rust 所能防止的 bug
Rust 的类型系统和借用检查器消除了整整几类缺陷:数据竞争、释放后使用、迭代器失效、空指针解引用。这些都是真实存在、代价高昂的 bug,在合适的代码库中,这种保证几乎值得任何代价。
我们讨论的这个服务并不是那样的代码库。它是一个 I/O 密集型的胶水代码:HTTP 处理器、数据库调用、一个翻译队列,输入一些 JSON,输出一些 JSON。看看过去几个月里在生产环境中实际出问题的 bug,列表总是一致的:
- 一个带有错误筛选器形态的查询,返回了错误的行。
- 一个 JSON 字段在一个地方是可选的,而在另一个地方是必需的。
- 分页中的差一错误。
- 一个时区假设在本地是正确的,但在部署的区域是错误的。
这些 bug 没有一个是内存安全问题。Rust 会编译通过所有这些代码。我们为一个针对数据竞争的保证付出了代价,但我们的服务几乎没有共享可变状态可供竞争,因为几乎所有东西都是基于单次请求且无状态的。这份保险是真实的,但我们投保的风险是我们并不承担的风险。
时间到底去哪儿了
痛苦的并非冷构建。你可能一天只需要为此付出一次代价,而且还可以趁机去喝杯咖啡。痛苦的是修改一行代码后的增量构建,因为这是你整天都要付出的代价。
以下是其大致情况,以范围而非精确基准测试的形式呈现,因为确切数字取决于你的机器和依赖关系图:
| 变更 | Rust(增量) | Go |
|---|---|---|
| 编辑一个处理函数体 | 数十秒 | 约一秒 |
| 改动一个共享类型 | 大规模重新编译 | 数秒 |
| 添加一个依赖 | 数分钟 | 数秒 |
| 完整的全新构建 | 多分钟 | 数秒 |
有两个因素导致了 Rust 的这些数据。第一个是单态化(monomorphization):泛型代码会为其使用的每个具体类型重新编译一份,这会生成快速的二进制文件,但构建速度很慢。第二个是依赖关系图。引入几个依赖过程宏(procedural macros)的 crate,你的大部分构建时间就会花在展开和编译你从未读过的代码上。对一个广泛使用的类型进行一行代码的更改,就可能使缓存的一大部分失效,并引发长时间的重新编译。
再次强调,这并非 Rust 的问题。单态化是其运行时快速的原因。过程宏是其人体工程学设计出色的原因。这些都是刻意的权衡,优先考虑生成的二进制文件而非编译循环。对于一个我们整天都在编辑的服务来说,我们站错了权衡的边。
Go 语言舍弃了什么,以及为什么这些取舍是值得的
Go 是一门更小、更直白的语言,转向它意味着会失去一些实实在在的东西。只有坦诚地面对这些损失,才能让这种取舍显得可信。
我们失去了真正的和类型。Go 对标签联合体的解决方案是接口加上类型切换(type switch),但编译器并不会检查其穷尽性。我们失去了 ? 运算符,取而代之的是感觉上每三行就会出现的 if err != nil。我们失去了借用检查器,它本可以在编译时捕获一类并发错误,结果我们不得不转而依赖竞争检测器和代码审查。Go 的泛型姗姗来迟,其表达能力至今仍不如 Rust 的 trait。
作为这些损失的交换,我们得到了以下这些:
- 一个足够快的编译器,快到你不再需要为构建过程费心。编辑-运行-读取的循环时间降到了会打断思绪的阈值以下,并一直保持在该水平。
- 对大多数事情,都有一种显而易见的做法。大致上只有一种写循环的方式,一种处理错误的方式,一种由无人争议的工具强制执行的格式化标准。这种统一性的重要性超乎想象,尤其是在大量代码由 AI 助手编写或编辑时,因为这样一来,看似合理但不常见的结构就更难混入其中。
- 一个标准库,它已经包含了我们需要的 HTTP 服务器、JSON、上下文传播和超时功能,而无需为每个功能引入一整套依赖树。
事实证明,冗长的错误处理所带来的代价比预想的要小,原因很明确:它具有局部性和可略读性。
// The whole error style in one function. Boring to write, fast to read.
func (s *Server) renderHome(ctx context.Context, lang string) (*Page, error) {
posts, err := s.store.ListPublishedByLang(ctx, ListQuery{Lang: lang, Limit: 20})
if err != nil {
return nil, fmt.Errorf("list posts for %s: %w", lang, err)
}
count, err := s.store.CountPublishedByLang(ctx, lang)
if err != nil {
return nil, fmt.Errorf("count posts for %s: %w", lang, err)
}
return s.buildPage(lang, posts, count), nil
}
每个出错点都紧挨着产生它的调用,并被包裹在上下文中,指明了失败的具体操作。当生产环境出现问题时,错误字符串读起来就像一个记录着“当时发生了什么”的堆栈。相比之下,缓慢的构建过程不具有局部性。它会因每次变更而阻塞整个团队,而且你无法通过略读来跳过它。
迁移过程的“无聊”是刻意为之的
我们没有进行“大爆炸”式的重写。该服务在一个接口后面被拆分,我们一次迁移一个有界的部分,并保持 Rust 版本持续运行,直到 Go 版本能够处理相同的流量并产生相同的输出。对于迁移来说,枯燥乏味是我们的目标。那些有意思的地方,往往是 bug 藏身之处。
有两点让这次移植工作变得简单直接。首先,数据模型没有改变,改变的只是围绕它的代码,因此我们可以用同一个数据库来比较两种实现,并对它们的响应进行差异比较。其次,Go 的标准库覆盖了我们需要的功能范围,所以大多数处理程序(handler)的移植都近乎是机械式的翻译,而不需要重新设计。而那些非机械性的部分,主要是错误处理和并发,恰恰是值得我们花时间手动、审慎处理的部分。
我们仍会选择 Rust 的场景
这是一种权衡,而非定论,而将其视为定论正是人们两次都选错工具的原因。在合适的场景下,Rust 能数倍地赚回其编译时间成本:
- 一个 CPU 密集型的核心,其中每一次内存分配和每一个分支都至关重要。
- 包含真正的共享可变状态和真实并发的代码,其中数据竞争是实际存在的风险,而非理论上的可能。
- 解析器、编解码器、数值计算热点路径,以及任何一个微小的内存错误都可能发生且会造成灾难性后果的场景。
我们正是将这些部分保留为 Rust 实现。媒体转码和一些数值计算热点例程从未迁移,因为对于它们而言,编译器在每一次运行中都物有所值。围绕它们的服务层,即主要等待网络和数据库的部分,则是 Go 的用武之地。
我们定下的规则
选择语言应匹配漏洞风险的实际所在,而不是你所期望的所在。
对于安全关键的 CPU 密集型代码,要依靠编译器,让它捕捉人类会遗漏的错误。对于你整天都在编辑的 I/O 密集型服务代码,主要风险并非数据竞争,而是缓慢的反馈循环,它让你无法发现眼前的逻辑漏洞。此时,就应该赢回这个循环。
正是“漏洞风险究竟在哪里”这一个问题,促使我们将此服务迁移到 Go,并将其核心热点保留在 Rust 中。对于你的系统,答案不会相同。但问题应该是相同的。