与其使用 Rust 重写整个应用,Discord 的 Staff Engineer Lily Mara 建议,将动态语言(如 Python)中最消耗资源的函数逐步迁移到 Rust,并在同一进程中连接两种实现。Mara 在 InfoQ 发布的一场演讲中介绍了这一方法,结合了她在构建分布式系统方面的经验:每天向 Discord 用户发送数百亿条通知;她还撰写了 Refactoring to Rust 一书。
核心思想并不是仅仅因为 Rust 更快就用一种语言替换另一种语言,而是找出经过优化后能产生最大影响的狭小系统部分。这种方法可以缩小改动范围,将新行为与旧行为进行对比测试,并在难以保证结果一致的情况下保留原有实现。
为什么不从全面重写开始?
Mara 认为,使用更新的语言完全重建旧系统的热情可以理解,但这会带来很大的实际风险。重写项目可能会错过期限,变得比预期更加复杂,或者重新引入旧系统此前已经解决的错误。此外,旧代码并不总是因为年代久远而复杂;它可能反映了由实际使用和难以迁移到新项目的组织知识逐渐积累形成的限制与细节。
演讲还提醒,不应把性能问题归结为编程语言本身。性能瓶颈可能出现在数据库架构、查询和缓存模式,或服务设计中,而不是代码行的执行成本上。因此,采用 Rust 并不意味着重写会自动解决架构瓶颈。
什么是通过 FFI 进行重构?
该方法使用 Mara 所称的 FFI refactoring,即用另一种语言重写一个函数或一小部分函数,再通过语言互操作接口将其连接到现有应用。在演示示例中,Flask 应用继续接收 HTTP 请求并解码 JSON,然后将数据传递给一个用 Rust 编写的统计函数,再将结果返回 Python 并发送响应。
这种连接依赖 C 接口,实际上 C 接口是许多系统和语言之间的通用语言。在 Python 的情况下,PyO3 项目提供了用于创建可从 Python 导入的模块的工具,而 Maturin 可用于构建该模块。pyfunction 和 pyclass 等编程属性会将 Rust 中的函数和数据结构转换为可从 Python 调用的接口。
选择合适的函数并衡量影响
该实践建议寻找调用频繁或单次调用成本较高的函数。有些函数每次执行的成本相对较低,但会在每个请求中运行,例如位于 API 处理器前面的验证逻辑。另一方面,也可能存在不常执行但成本极高的操作。判断标准应是对累计 CPU 时间的影响,而不是某个函数看起来很慢的主观印象。
在统计示例中,通过 Python 测量同一个函数时,Rust 实现的速度快了略高于 100 倍,尽管测试是通过 Python 解释器运行两个应用的。但在测量完整 HTTP 处理器时,包括 Flask、JSON 序列化和反序列化在内,提升仅约为 15%。原始函数的执行时间约为 86 微秒,单次来看很短,但在大规模重复执行时可能转化为重要成本。
局部测量与整体测量之间的差距是演讲最重要的经验之一:记录函数加速还不够,还必须进行能够代表实际使用路径的整体测试,并在需要时纳入网络或 Web 框架以及数据序列化。
功能兼容性、测试与限制
示例的重新实现显示,Python 统计库与 Rust 库的结果存在差异。其中一个库精确计算四分位数,而另一个库对超大数据集使用适当的估算;此外,小数舍入也出现了细微差异。因此,不应假定替换库后会自动保持相同的行为。
如果必须保持一致,可以寻找其他库,或者在 Rust 中重新实现原始算法,也可以将敏感部分保留在 Python 中。按函数粒度进行迁移的优势在于,可以在同一进程中混合使用两种语言,而不必将组件拆分成独立服务,从而增加网络通信和新的运营成本。
测试包括直接的 Rust 测试、针对 Flask 处理器的现有 Python 测试、比较两种实现结果的测试,以及在适用时进行的属性随机测试。不过,加入原生代码会带来运营复杂性:开发环境需要 Rust 编译器,或需要与操作系统和架构兼容的二进制文件;部署会更加复杂,也可能出现新的错误。
从实践角度看,该方法为改进现有系统中选定的部分提供了一条相对低风险的路径,但并不能免除架构分析、兼容性测试和整体测量的必要性。只有当改进反映在完整的服务路径中,而不是孤立的函数 benchmark 中时,真正的收益才算得到证明。