为什么Discord从Go变成Rust



Rust正在成为广泛领域中的一流语言。我们在Discord的服务器和客户端上都成功使用了它。例如,在客户端上使用Go Live的视频编码管道,在服务器端使用Elixir NIF(本机实现的功能)功能。

我们最近从Go到Rust重写了单个服务的性能,从而极大地提高了它的性能。本文将解释为什么对我们来说重写服务是有意义的,我们是如何做到的以及生产率提高了多少。

读取状态跟踪服务(读取状态)


我们的公司是基于一种产品构建的,所以让我们从某种背景开始,正是我们从Go转移到Rust的过程。这是一个读取状态服务。她唯一的任务是跟踪您阅读的频道和消息。每次您连接到Discord,每次发送消息以及每次阅读消息时,都会访问“读取状态”。简而言之,状态被连续读取并且处于“热路径”上。我们要确保Discord总是很快,所以状态检查应该很快。

Go上的服务实施未满足所有要求。在大多数情况下,它都能快速运行,但每隔几分钟就会出现强烈的延迟,用户会注意到。在检查了情况之后,我们确定延迟是由于Go的关键特性所致:其内存模型和垃圾收集器(GC)。

为什么Go无法达到我们的绩效目标


为了解释Go无法达到我们的性能目标的原因,我们首先需要讨论数据结构,规模,访问模式和服务架构。

为了存储状态信息,我们使用一种数据结构,称为:读取状态。不和谐中有数十亿美元:每个频道的每个用户处于一种状态。每个状态都有几个计数器,这些计数器必须进行原子更新,并且经常重置为零。例如,计数器之一是@mention通道中的数字

为了快速更新原子计数器,每个读取状态服务器都具有最近最少使用(LRU)缓存。每个缓存都有数百万个用户和数千万个状态。高速缓存每秒更新数十万次。

为了安全起见,缓存与Cassandra数据库集群同步。当密钥从缓存中推出时,我们在数据库中输入该用户的状态。将来,我们计划在每次状态更新后30秒内更新数据库。这是每秒数据库中成千上万的记录。

下图显示了Go 1服务在峰值时间间隔的响应时间和CPU负载可以看出,CPU上的延迟和负载爆发大约每两分钟发生一次。



那么每两分钟延迟的增长是从哪里来的呢?


在Go中,当密钥从缓存中推出时不会立即释放内存。取而代之的是,垃圾收集器会定期运行并查找未使用的内存部分。这是很多工作,可能会使程序变慢。

我们服务的定期变慢很可能与垃圾回收有关。但是我们编写了一个非常有效的Go代码,并且分配了最少的内存。应该没有太多的垃圾了。有什么事?

通过遍历Go源代码,我们了解到Go 至少每两分钟强制启动一次垃圾收集。无论堆大小如何,如果GC在两分钟内都没有启动,Go将强制其启动。

我们决定,如果您更频繁地运行GC,则可以避免长时间出现这些峰值,因此我们在服务中设置了一个端点来即时更改GC Percent。不幸的是,GC Percent的配置没有任何影响。怎么会这样事实证明,GC不想启动频率更高,因为我们分配内存的频率不够高。

我们开始进一步挖掘。事实证明,由于释放的内存量巨大,不会发生如此大的延迟,而是因为垃圾回收器扫描了整个LRU缓存以检查所有内存。然后我们决定,如果我们减少LRU缓存,则扫描量将减少。因此,我们在服务中添加了另一个参数以更改LRU缓存的大小,并更改了体系结构,从而将LRU分解为每个服务器上的许多单独的缓存。

事情就这样发生了。使用较小的缓存,可以减少峰值延迟。

不幸的是,减少LRU缓存的妥协性提高了第99个百分位(即,不包括峰值延迟的99%延迟样本的平均值增加了)。这是因为减少高速缓存会降低用户的读取状态将在高速缓存中的可能性。如果不在这里,那么我们必须转向数据库。

在对不同大小的缓存进行大量负载测试之后,我们找到了可接受的设置。尽管不是理想的解决方案,但它是一个令人满意的解决方案,因此我们将服务留给了很长一段时间才能像这样工作。

同时,我们在其他Discord系统中非常成​​功地实现了Rust,因此,我们做出了集体决定,仅在Rust上为新服务编写框架和库。而且,该服务似乎是移植到Rust的理想选择:它很小且具有自治性,我们希望Rust可以延迟解决这些峰值问题,并最终使该服务对用户来说更加愉悦2

Rust中的内存管理


Rust具有令人难以置信的快速和高效的内存:在没有运行时环境和垃圾收集器的情况下,它适用于高性能服务,嵌入式应用程序,并易于与其他语言集成。3

Rust没有垃圾收集器,因此我们决定不会像Go这样的延迟。

在内存管理中,他采用了一种非常独特的方法,即“拥有”内存。简而言之,Rust跟踪谁有权读取和写入内存。他知道程序何时使用内存,并在不再需要内存时立即释放它。Rust会在编译时强制执行内存规则,这实际上消除了运行时内存错误的可能性。4您不需要手动跟踪内存。编译器会照顾这一点。

因此,在Rust版本中,当从LRU缓存中排除“读取状态”时,将立即释放内存。该内存不会停滞,不会等待垃圾回收器。Rust知道它已不再使用,并立即释放它。运行时中没有用于扫描要释放的内存的进程。

异步锈


但是Rust生态系统存在一个问题。在实施我们的服务时,Rust的稳定分支中没有不错的异步功能。对于网络服务,必须进行异步编程。社区已经开发了几个库,但是它们具有不平凡的连接和非常愚蠢的错误消息。

幸运的是,Rust团队努力简化了异步编程,并且它已经可以在不稳定的通道上使用(Nightly)。

Discord从不害怕学习有前途的新技术。例如,我们是Elixir,React,React Native和Scylla的第一批用户之一。如果某种技术看起来很有前途并给我们带来优势,那么我们已经准备好面对不可避免的实施困难和先进工具的不稳定。这就是为什么我们如此迅速地吸引了2.5亿用户,而该州不到50位程序员的原因之一。

从不稳定的Rust通道引入新的异步函数是我们愿意采用一种新的,有希望的技术的另一个例子。工程团队决定实施必要的功能,而无需等待稳定版本的支持。我们与社区的其他代表一起克服了所有出现的问题,现在异步Rust保持稳定的分支。我们的房价已付清。

实施,压力测试和启动


只需重写代码就很容易。我们从粗略的广播开始,然后将其缩小到有意义的地方。例如,Rust有一个出色的类型系统,它广泛地支持泛型(用于处理任何类型的数据),因此我们悄悄丢弃了Go代码,从而弥补了泛型的不足。另外,Rust内存模型考虑了不同线程中的内存安全性,因此我们放弃了保护性goroutines。

负载测试立即显示出极好的结果。事实证明,Rust的服务性能与Go版本的服务性能一样高,但是没有这些增加的延迟

通常,我们实际上没有优化Rust版本。但是,即使使用最简单的优化,Rust仍能胜过经过精心调整的Go版本。这很好地证明了与深入Go相比,编写有效的Rust程序是多么容易。

但是我们不满足于简单的性能要求。经过一些分析和优化,我们在各个方面都超过了Go延迟,CPU和内存-Rust版本中的一切都变得更好。

防锈性能优化包括:

  1. 在LRU缓存中切换到BTreeMap而不是HashMap,以优化内存使用率。
  2. 用支持现代并发Rust的版本替换原始指标库。
  3. 减少内存中的份数。

满意后,我们决定部署该服务。

在进行压力测试时,发射进行得非常顺利。我们将服务连接到一个测试节点,发现并修复了一些临界情况。不久之后,他们将新版本推向了整个服务器园区。

结果如下所示。

紫色图形为Go,蓝色图形为Rust。



增加缓存大小


当该服务成功工作了几天后,我们决定再次增加LRU缓存。如上所述,在Go版本中,无法完成此操作,因为垃圾收集的时间增加了。由于我们不再进行垃圾收集,因此您可以增加缓存,并进一步提高性能。因此,我们增加了服务器上的内存,优化了数据结构以减少内存使用(出于娱乐目的),并将缓存大小增加到800万个“读取状态”状态。

以下结果不言而喻。请注意,现在平均时间以微秒为单位,最大延迟@mention以毫秒为单位。



生态系统发展


最后,Rust拥有一个快速发展的奇妙生态系统。例如,最近我们使用的异步运行时的新版本是Tokio 0.2。我们进行了更新,并且不费吹灰之力就自动减少了CPU的负载。在下图中,您可以看到自1月16日左右以来负荷如何减少。



最后的想法


Discord当前在软件堆栈的许多部分中使用Rust:对于GameSDK,使用Go Live,Elixir NIF,一些后端服务等捕获和编码视频

在开始新项目或软件组件时,我们绝对会考虑使用Rust。当然,只有在有意义的地方。

除了性能之外,Rust还为开发人员提供了许多其他好处。例如,随着产品需求的变化或引入新的语言功能,其类型安全和借位检查器大大简化了重构。生态系统和工具非常出色,并且正在迅速发展。

有趣的事实:Rust团队还使用Discord进行协调。甚至有一个非常有用的Rust社区服务器,有时我们在这里聊天。



脚注


  1. 图表摘自Go 1.9.2版。我们尝试了1.8、1.9和1.10版本,没有进行任何改进。从Go到Rust的初始迁移已于2019年5月完成。[回来]
  2. 为了清楚起见,我们不建议无缘无故地在Rust中重写所有内容。[回来]
  3. 从官方网站报价。[回来]
  4. 当然,除非您使用unsafe[回来]

Source: https://habr.com/ru/post/undefined/


All Articles