Rust 生产实践:我们为何重写了信号处理器
我们把信号处理引擎从 Go 重写为 Rust。本文讲述原因、我们的收获,以及实现的 4 倍吞吐量提升。
六个月前,我们决定把信号处理引擎——负责把原始浏览器信号转换为归一化、可哈希特征向量的组件——从 Go 重写为 Rust。这个决定我们并非草率作出。我们的 Go 实现能正常工作,经过测试,也已部署上线。但它已成为整条流水线的瓶颈,而我们需要吞吐量出现阶跃式的提升。以下是事情的经过。
我们为何超出了 Go 的承载范围
我们的信号处理器承担着计算密集型工作:解析 JSON 载荷、对 130 多个信号应用归一化函数、计算专有哈希,以及构建识别哈希向量。在 Go 中,这些工作受限于 CPU,而 Go 的垃圾回收器在规模化时成了问题。每个信号处理周期都会分配中间对象——解析出的 JSON 节点、归一化的字符串值、哈希缓冲区——这些都造成了 GC 压力。
在 3 万事件/秒时,我们的 Go 信号处理器每隔几秒会出现 2-5 毫秒的 GC 停顿。这些停顿尚可接受。到 5 万事件/秒时,GC 停顿增长到 8-15 毫秒,且发生得更频繁。到 8 万事件/秒时——这是我们对第三季度负载的预估——GC 停顿会导致 p99 延迟超出我们的 SLA。我们要么增加服务器(昂贵),要么采用更高效的实现。
为什么选择 Rust
我们评估了三个方案:优化现有的 Go 实现(sync.Pool、arena 分配、GOGC 调优)、用 C++ 重写,以及用 Rust 重写。优化 Go 带来了 30% 的提升,但没有从根本上解决 GC 问题。C++ 因为在一个安全关键系统中存在内存安全顾虑而被排除。Rust 则提供了零成本抽象、没有垃圾回收器,以及在编译期强制保证的内存安全。
Rust 生态对我们所需的一切也都有成熟的库:用于 JSON 解析的 serde、高性能的哈希 crate,以及用于异步 I/O 的 tokio。学习曲线是实实在在的——我们的团队有深厚的 Go 经验,但 Rust 经验有限——不过它的性能特性正是我们所需要的。
重写过程
我们把信号处理器重写为一个独立服务,通过 gRPC 与流水线的其余部分通信。这让我们能够将它与 Go 实现并行部署,并逐步切换流量。这次重写由三名工程师耗时四周完成——两周用于核心实现,两周用于测试、基准测试和边界情况处理。
最具挑战性的部分不是语言本身,而是确保与 Go 实现的行为一致性。我们构建了一个对比测试框架,用相同的输入运行两套实现,并验证它们产出完全相同的输出。在此过程中我们发现了 14 处细微差异——大多与浮点处理、Unicode 归一化和 JSON 解析的边界情况有关。
性能结果
Rust 实现处理信号平均为 0.8 毫秒,而 Go 为 3.2 毫秒——提升了 4 倍。在相同工作负载下,内存占用从 2.1GB 降到 340MB。由于没有垃圾回收器,也就不存在 GC 停顿。在相同吞吐量下 CPU 利用率下降了 60%,意味着每台服务器能处理 4 倍的流量。
在 8 万事件/秒时,Rust 实现将 p99 处理时间维持在 1.4 毫秒且零停顿。这份余量意味着在可预见的将来,我们无需再重新审视信号处理的性能。CPU 和内存占用的降低也直接转化为更低的基础设施成本——我们下线了 12 台信号处理服务器中的 8 台。
经验教训
对我们这个特定场景而言,用 Rust 重写是值得的——一个受 CPU 限制、分配密集、对延迟敏感的工作负载。我们不会把 HTTP 接入层或 ClickHouse 查询服务用 Rust 重写,因为这些组件受 I/O 限制,Go 能高效地处理它们。这里的教训不是"把一切都用 Rust 重写",而是"在零成本抽象与确定性性能最为关键之处使用 Rust"。
最大的惊喜是 Rust 编译器在重写过程中捕获了如此之多的问题。我们 Go 实现中的若干潜在缺陷——共享缓冲区上的竞态条件、哈希计算中的整数溢出,以及畸形输入上的越界访问——在 Rust 中都被当作编译期错误捕获了。这个编译器很严苛,但它以正确性回报了这份严苛。
老实说,第一周很痛苦。Sarah 在白板上记了一笔"与借用检查器搏斗"的计数——在团队停止计数前我们已经达到 47 次。但到第三周,能编译通过的代码就是能正常运行。没有神秘的生产环境 panic,负载下也没有数据竞争。对于任何处于热路径上的代码而言,这个取舍都是值得的。