为什么Discord从Go切换到Rust?


Rust正在成为各种领域的一流语言。在Discord,我们已经在客户端和服务器端看到了Rust的成功。例如,我们在客户端将其用于Go Live的视频编码管道,在服务器端将其用于Elixir NIF。最近,我们通过将服务的实现从Go切换到Rust来极大地提高了服务的性能。这篇文章解释了为什么重新实现服务对我们有意义,它是如何完成的,以及由此带来的性能改进。

读取状态服务
Discord是一家专注于产品的公司,因此我们将从一些产品上下文入手。我们从Go切换到Rust的服务是“读取状态”服务。其唯一目的是跟踪您已阅读的频道和消息。每次您连接到Discord,每次发送消息和每次阅读消息时,都会访问“读取状态”。简而言之,“读取状态”正处于热点中。我们要确保Discord始终都感觉超级敏捷,因此我们需要确保Read State快速。
通过Go实施Read States服务却不支持产品要求。在大多数情况下,速度虽然很快,但是每隔几分钟,我们就会看到大量的延迟峰值,这不利于用户体验。经过调查,我们确定峰值是由于Go的核心功能:其内存模型和垃圾收集器(GC)引起的。

为什么Go无法达到我们的绩效目标
为了解释Go为什么不能达到我们的性能目标,我们首先需要讨论服务的数据结构,规模,访问模式和体系结构。
我们用来存储读取状态信息的数据结构通常称为“读取状态”。可能会拥有数十亿个读取状态。每个用户每个通道有一个读取状态。每个读取状态都有几个需要自动更新的计数器,通常需要将其重置为0。
为了获得快速的原子计数器更新,每个读取状态服务器都具有读取状态的最近最少使用(LRU)缓存。每个缓存中有数百万个用户。每个缓存中有数千万个读取状态。每秒有数十万个缓存更新。
为了保持持久性,我们使用Cassandra数据库集群支持缓存。在删除缓存键时,我们将某个用户的读取状态提交到数据库。每当读取状态被更新时,我们还将在未来30秒内计划数据库提交。每秒有数万次数据库写入。
在Go服务的峰值采样时间范围的响应时间和系统cpu测试中,会注意到,大约每2分钟就会有延迟和CPU峰值。

那么为什么要2分钟峰值呢?
在Go中,在逐出缓存键时,不会立即释放内存。取而代之的是,垃圾收集器会如此频繁地运行以查找没有引用的任何内存,然后将其释放。换句话说,不是在内存用完之后立即释放,而是挂了一段时间,直到垃圾回收器可以确定它是否真正用完为止。在垃圾回收期间,Go必须做很多工作来确定哪些内存可用,这可能会使程序变慢。
这些延迟峰值肯定听起来像垃圾回收性能影响,但是我们已经非常高效地编写了Go代码,并且分配很少。我们并没有创造很多垃圾。
深入研究Go源代码后,我们了解到Go将强制至少2分钟运行一次垃圾收集。换句话说,如果垃圾收集没有运行2分钟,无论堆增长如何,go仍将强制执行垃圾收集。
我们认为我们可以调整垃圾收集器的发生频率,以防止出现大的峰值,因此我们在服务上实现了一个终结点,以动态更改垃圾收集器的GC百分比。不幸的是,无论我们如何配置GC百分比,都没有改变。怎么可能 事实证明,这是因为我们分配内存的速度不够快,无法迫使垃圾回收更频繁地发生。
我们不断进行挖掘,并了解到峰值之所以巨大,并不是因为有大量随时可用的内存,而是因为垃圾收集器需要扫描整个LRU缓存以确定内存是否真正没有引用。因此,我们认为较小的LRU缓存会更快,因为垃圾收集器的扫描量更少。因此,我们在服务中添加了另一项设置以更改LRU缓存的大小,并更改了体系结构以使每个服务器具有多个分区的LRU缓存。
没错 LRU缓存较小时,垃圾回收会导致较小的峰值。
不幸的是,降低LRU缓存的权衡取舍导致了第99个延迟时间的增加。这是因为如果缓存较小,则用户的“读取状态”在缓存中的可能性较小。如果它不在缓存中,那么我们必须进行数据库加载。
在对不同的缓存容量进行了大量的负载测试之后,我们发现了一个设置似乎还可以。虽然不完全满意,但足够满意,所以我们让这种服务运行了一段时间。
在这段时间里,Rust在Discord的其他部分获得了越来越多的成功,我们共同决定要创建在Rust中完全构建新服务所需的框架和库。这项服务体积小且自包含,因此非常适合移植到Rust,但我们也希望Rust可以解决这些延迟峰值。因此,我们承担了将“读取状态”移植到Rust的任务,希望证明Rust是一种服务语言,并改善用户体验。

Rust中的内存管理
Rust速度极快且内存效率高:没有运行时或垃圾收集器,它可以为性能至关重要的服务提供支持,可以在嵌入式设备上运行,并且可以轻松地与其他语言集成。
Rust没有垃圾回收,因此我们认为它不会像Go那样具有相同的延迟峰值。
Rust使用相对独特的内存管理方法,该方法结合了内存“所有权”的概念。基本上,Rust跟踪谁可以读取和写入内存。它知道程序何时使用内存,并在不再需要时立即释放内存。它会在编译时强制执行内存规则,从而几乎不可能出现运行时内存错误。⁴您无需手动跟踪内存。编译器会处理它。
因此,在“读取状态”服务的Rust版本中,当用户的读取状态从LRU缓存中逐出时,它将立即从内存中释放出来。读取状态内存不会等待垃圾回收器对其进行收集。Rust知道它已不再使用,并立即释放它。没有运行时过程来确定是否应释放它。

异步Rust
但是Rust生态系统存在问题。在重新实现该服务时,Rust stable对于异步Rust并没有一个很好的故事。对于网络服务,必须进行异步编程。有一些启用异步Rust的社区库,但是它们需要大量的仪式,并且错误消息非常晦涩。
幸运的是,Rust团队努力使异步编程变得容易,并且在Rust不稳定的夜间频道都可以使用。
Discord从未惧怕采用看起来很有前途的新技术。例如,我们是Elixir,React,React Native和Scylla的早期采用者。如果一项技术很有前途并给我们带来优势,那么我们不介意处理前沿技术的固有困难和不稳定。这是我们用不到50名工程师迅速达到250+百万用户的方法之一。
每晚在Rust中使用新的异步功能是我们愿意接受有前途的新技术的另一个例子。作为一个工程团队,我们认为每晚使用Rust值得,并且我们致力于每晚运行,直到在稳定状态下完全支持异步为止。我们共同处理了出现的任何问题,此时Rust稳定器支持异步Rust。

实施,负载测试和启动
实际的重写相当简单。它起初只是一个粗略的翻译,然后我们将其精简到合理的程度。例如,Rust有一个很棒的类型系统,它对泛型提供了广泛的支持,因此我们可以丢弃仅仅由于缺少泛型而存在的Go代码。同样,Rust的内存模型能够推理出线程间的内存安全性,因此我们能够丢弃Go中所需的一些手动跨goroutine内存保护。
当我们开始负载测试时,我们立即对结果感到满意。Rust版本的延迟与Go一样好,并且没有延迟峰值!
值得注意的是,在编写Rust版本时,我们仅将最基本的思想用于优化。即使仅进行基本优化,Rust仍能胜过超级手动调整的Go版本。与我们对Go进行的深入研究相比,这充分证明了用Rust编写高效的程序是多么容易。
但是我们对简单匹配Go的性能并不满意。经过一些性能分析和性能优化后,我们能够在每个性能指标上击败Go。在Rust版本中,延迟,CPU和内存都更好。
Rust性能优化包括:

  1. 在LRU缓存中更改为BTreeMap而不是HashMap以优化内存使用。
  2. 交换使用现代Rust并发性的初始指标库。
  3. 减少我们正在执行的内存副本数量。

满意后,我们决定推出该服务。
由于我们进行了负载测试,因此发布是相当无缝的。我们将其放到单个金丝雀节点上,找到了一些遗漏的边缘案例,并进行了修复。此后不久,我们将其推广到整个舰队。

提高缓存容量
服务成功运行了几天后,我们决定是时候重新提高LRU缓存容量了。如上所述,在Go版本中,提高LRU缓存的上限会导致更长的垃圾回收。我们不再需要处理垃圾回收,因此我们认为我们可以提高缓存的上限并获得更好的性能。我们增加了包装盒的内存容量,优化了数据结构以使用更少的内存(用于娱乐),并将缓存容量增加到800万个读取状态。
以下结果不言而喻。

总结思想
此时,Discord正在其软件堆栈的许多地方使用Rust。我们将其用于游戏SDK,Go Live的视频捕获和编码,Elixir NIF,若干后端服务等等。
在开始新项目或软件组件时,我们考虑使用Rust。当然,我们只在有意义的地方使用它。
除性能外,Rust对工程团队还具有许多优势。例如,它的类型安全性和借位检查器可以很容易地随着产品需求的变化或发现有关该语言的新知识而重构代码。此外,生态系统和工具非常出色,并且背后蕴藏着巨大的动力。
如果到目前为止,您可能对Rust刚感到兴奋,或者已经有一段时间了。如果您想专业地使用Rust来解决有趣的问题,则应考虑在Discord工作。
还有一个有趣的事实:Rust团队使用Discord进行协调。甚至还有一个非常有用的Rust社区服务器,您可以发现我们不时在聊天。点击这里查看。