本文介绍我们在没有中断生产运营情况下是如何将生产系统的第1层服务从Ruby迁移到Rust?
在物流算法团队中,我们有一个名为Dispatcher的服务,其主要目的是以最佳方式向司机提供订单。对于每个司机,我们建立了一个时间轴,我们可以预测司机在某个时间点的位置; 知道这一点,我们可以更有效地向司机推荐订单。 构建每个时间线涉及相当多的计算:使用不同的机器学习模型来预测事件将花费多长时间:断言某些约束,计算分配成本。计算本身很快,但问题是我们需要做很多这样的事情:对于每个订单,我们需要检查所有可用的司机以确定最好指派给哪位。 Dispatcher的第一个版本主要是用Ruby编写的:这是公司的首选语言,并且当时我们的规模足够大。然而,随着Deliveroo不断增长,订单和乘客的数量急剧增加,我们看到调度过程开始花费的时间比以前长得多,我们意识到,在某些时候,不可能在一个时间限制内一步到位I调度一些区域了。我们也知道,如果我们决定实施更高级的算法,这将更限制我们,因为需要更多的计算时间。 我们尝试的第一件事是优化当前代码(缓存一些计算,试图找到算法中的错误),这没有多大帮助。很明显Ruby在这里是一个瓶颈,我们开始研究替代方案。
为什么使用Rust? 我们考虑了一些如何解决调度速度问题的方法:
- 选择具有更好性能特征的新编程语言并重写Dispatcher
- 确定最大的瓶颈,重写代码的这些部分,并以某种方式将它们集成到当前代码中
有几种选择我们如何将用另一种语言编写的部分代码集成到Ruby中:
- 构建外部服务并提供与之通信的API
- 构建原生扩展
- 它具有很高的性能(与C相当)
- 它是内存安全的
- 它可以用来构建动态库,可以加载到Ruby中(使用extern "C"接口)
如何使Ruby与Rust交互? 有几种不同的方法可以从Ruby调用Rust:
- 使用extern "C"接口在Rust中编写动态库,并使用FFI调用它。
- 编写动态库,但使用Ruby API注册方法,这样就可以直接从Ruby调用它们,就像任何其他Ruby代码一样。
- 它有一些看起来像在Rust中编写Ruby的宏,这对我们来说比我们感到舒服的时候更神奇
- 强制协议没有很好地记录,并且不清楚如何将非原始Ruby对象传递给Helix方法
- 我们不确定安全性 - 看起来Helix没有调用Ruby方法rb_protect,这可能会导致未定义的行为
#[macro_use] extern crate rutie; use rutie::{Class, Object, RString}; class!(HelloWorld); methods!( HelloWorld, _itself, fn hello(name: RString) -> RString { RString::new(format!("Hello {}", name.unwrap().to_string())) } ); #[allow(non_snake_case)] #[no_mangle] pub extern "C" fn Init_ruby_rust_demo() { let mut class = Class::new("RubyRustDemo", None); class.define(|itself| itself.def_self("hello", hello) ); } |
这是伟大的,如果你需要的是通过一些基本的类型(如String,Fixnum,Boolean如果你需要传递大量的数据,等等),用你的方法,但不是很大。在这种情况下,您可以传递整个Order对象,然后您需要调用该对象上需要的每个字段以将其移动到Rust中:
pub struct RustUser { name: String, address: Address, } pub struct Address { pub country: String, pub city: String, } class!(User); impl VerifiedObject for User { fn is_correct_type<T: Object>(object: &T) -> bool { object.send("class").send("name").try_convert_to::<RString>().to_string() == "User" } fn error_message() -> &'static str { "Not a valid request" } } methods!( // .. some code skipped fn hello(user: AnyObject) -> Boolean { let name = user.send("name").try_convert_to::<RString>().unwrap().to_string(); let ruby_address = user.send("address"); let country = ruby_address.send("country").try_convert_to::<RString>().unwrap().to_string(); let city = ruby_address.send("city").try_convert_to::<RString>().unwrap().to_string(); let address = Address { country, city }; let rust_user = RustUser { name, address }; do_something_with_user(&rust_user); Boolean::new(true) } ) |
#[derive(Debug, Deserialize)]
pub struct User {
pub name: String,
pub address: Address,
}
#[derive(Debug, Deserialize)]
pub struct Address {
pub country: String,
pub city: String
}
class!(HelloWorld);
rutie_serde_methods!(
HelloWorld,
_itself,
ruby_class!(Exception),
// Notice that the argument has our defined type |
您可以看到代码hello_user现在有多简单- 我们不再需要user手动解析。因为它是serde,所以它也可以处理嵌套对象(正如你可以看到的那样)。我们还添加了一个内置的错误处理:如果serde无法“解析”对象,这个宏将引发我们提供的类的异常(Exception在这种情况下),它还将方法体包装在panic::catch_unwind,并且重新在Ruby中将恐慌引发为异常。 使用rutie-serde,我们可以快速,轻松地实现Ruby和Rust之间的薄接口。
从Ruby迁移到Rust 我们想出了一个逐步用Rust替换Ruby Dispatcher的所有部分的计划。我们首先使用Rust类替换,这些类没有依赖于Dispatcher的其他部分并添加功能标志,类似于:
module TravelTime def self.get(from_location, to_location, options) in the real world the feature flag would be more granular and enable you to do an incremental roll-out if rust_enabled? && Feature.enabled?(:rust_travel_time) RustTravelTime.get(from_location, to_location, options) else RubyTravelTime.get(from_location, to_location, options) end end end |
还有一个主开关(在这种情况下rust_enabled?),它允许我们只通过一个功能标记来关闭所有Rust代码。 由于Ruby和Rust类的实现API大致相同,我们能够使用相同的测试来测试它们,这使我们对实现的质量更有信心。
RSpec.describe TravelTime do shared_examples "travel_time" do let(:from_location) { build(:location) } let(:to_location) { build(:location) } let(:options) { build(:travel_time_options) } it 'returns correct travel time' do expect(TravelTime.get(from_location, to_location, options)).to eq(123.45) end end context "ruby implementation" do before do Feature.disable!(:rust_travel_time) end include "travel_time" end context "rust implementation" do before do Feature.enable!(:rust_travel_time) end include "travel_time" end end |
性能改进 当将更大的代码块移动到Rust中时,我们注意到我们正在仔细监视的性能改进。将较小的模块移动到Rust时,我们没有期待太多的改进:事实上,一些代码变得更慢,因为它是在紧密循环中调用的,并且从Ruby应用程序调用Rust代码的开销很小。
在Dispatcher中,调度周期有3个主要阶段:
- 加载数据中
- 运行计算,计算任务
- 保存/发送作业
在这0.8秒中,在计算阶段,在Rust中花费了大约0.2秒。这意味着0.6秒是加载数据和向车手发送任务的Ruby / DB开销。看起来调度周期现在仅快5倍,但实际上,此示例时间内的计算阶段从~3.2秒减少到0.2秒,这是17倍的加速。
请记住,就实现而言,Rust代码几乎是Ruby代码的1:1副本,并且我们没有添加任何额外的优化(如缓存,在某些情况下避免复制内存),因此仍有空间改善。
结论 我们的项目很成功:从Ruby转向Rust取得了成功,大大加快了我们的dipatch流程,并为我们提供了更多的空间,我们可以尝试实现更高级的算法。 渐进式迁移和细致特征标记减轻了项目的大部分风险。我们能够以更小的增量部件交付它,就像我们通常在Deliveroo中构建的任何其他功能一样。 Rust已经表现出了很好的性能,并且缺少运行时使得在构建Ruby原生扩展时可以很容易地将它用作C的替代品。