为什么 Discord 从 Go 切换到了 Rust

Go

Rust正在成为各领域中的一流语言。在Discord,我们已经看到Rust在客户端和服务器端取得了成功。例如,我们在客户端用于Go Live的视频编码管道,在服务器端用于Elixir NIFs。最近,我们通过将服务的实现从Go切换到Rust,大幅提高了服务的性能。这篇文章将解释我们为什么要重新实现该服务,它是如何实现的,以及由此带来的性能提升。

阅读状态服务 (The Read States service)

Discord是一家专注于产品的公司,所以我们先说一下产品背景。我们从Go转到Rust的服务是“阅读状态服务”。它的唯一目的是记录你阅读了哪些频道和消息。每当你连接到Discord的时候,每发送一条消息,每读取一条消息,都会访问Read States。简而言之,Read States是在热门路径中。我们要确保Discord时时刻刻都有超级爽快的感觉,所以我们需要确保Read States的速度。

在Go实现后,Read States的服务并不支持其产品需求。大部分时间它的时间很快,但每隔几分钟我们就会看到大的延迟尖峰,这对用户体验很不好。经过调查,我们确定尖峰是由于Go的核心功能造成的:它的内存模型和垃圾回收器(GC)。

为什么Go没有达到我们的性能目标

为了解释为什么Go没有达到我们的性能目标,我们首先需要讨论一下服务的数据结构、规模、访问模式和架构。

我们用来存储阅读状态服务的数据结构被方便地称为"Read State"。Discord有数十亿个Read State。每个用户每个通道都有一个读取状态。每个读取状态都有几个计数器,这些计数器需要原子化更新,并且经常被重置为0,例如,其中的一个计数器是一个频道里有多少个@mentions。

为了获得快速的原子化计数器更新,每个阅读状态服务器都有一个最近最少使用的(LRU)的阅读状态缓存。每个缓存中都有几百万个用户。每个缓存里有几千万个Read State。每秒钟有几十万次的缓存更新。

为了持久化,我们用Cassandra数据库集群来备份缓存。在缓存密钥驱逐时,我们会把你的Read State提交到数据库中。每当有一个Read State更新时,我们也会在未来30s内安排一次数据库提交。每秒钟有数万次数据库写入。

在下面的图片中,你可以看到Go服务的响应时间和系统CPU的峰值采样时间帧。¹ 正如你可能注意到的那样,大约每隔2分钟就会出现一次延迟和CPU峰值。

Go

为什么会出现 2 分钟峰值呢?

在 Go 中,在缓存键清除时,内存不会立即释放。相反,垃圾收集器经常运行以查找没有引用的内存,然后将其释放。换句话说,不是在内存用完后立即释放,而是内存挂起一段时间,直到垃圾收集器可以确定它是否真的不再使用。在垃圾收集期间,Go 必须做大量工作来确定哪些内存是空闲的,这可能会降低程序的速度。

这些延迟峰值听起来确实像是垃圾收集对性能的影响,但是我们已经非常高效地编写了 Go 代码,并且只有很少的分配。

在深入研究 GO 源代码后,我们了解到 GO 将强制每隔至少 2 分钟运行一次垃圾收集。换句话说,如果垃圾收集没有运行 2 分钟,无论堆增长如何,Go 仍将强制执行垃圾收集。

我们认为可以更频繁地调优垃圾收集器,以防止出现大的峰值,因此我们在服务上实现了一个端点,动态地更改垃圾收集器 GC 百分比。不幸的是,无论我们如何配置 GC 百分比,都没有任何变化。这怎么可能?事实证明,这是因为我们分配内存的速度不够快,无法强制更频繁地进行垃圾收集。

我们不断地挖掘,并了解到峰值巨大的原因,不是因为有大量的现成的空闲内存,而是因为垃圾收集器需要扫描整个 LRU 缓存,以确定内存是否真正从引用中释放出来。因此,我们认为较小的 LRU 缓存会更快,因为垃圾收集器需要扫描的内容更少。因此,我们向服务添加了另一个设置以更改 LRU 缓存的大小,并更改了架构,使每个服务器具有许多分区的 LRU 缓存。

我们是对的。随着 LRU 缓存更小,垃圾收集会导致更小的峰值。

不幸的是,缩小 LRU 缓存的代价是增加了第 99 个延迟时间。这是因为如果缓存较小,则用户的读取状态出现在缓存中的可能性较小。如果它不在缓存中,则必须执行数据库加载。

在对不同的缓存容量进行了大量的负载测试之后,我们发现了一个看起来不错的设置。虽然不是完全满意,但已经足够了,而且还有更重要的事情要做,我们让服务像这样运行了很长一段时间。

在那段时间里,我们看到 Rust 在其他的地方越来越成功,我们共同决定要创建完全在 Rust 中构建新服务所需的框架和库。这个服务是移植到 Rust 的一个很好的候选,因为它很小而且是自包含的,但是我们也希望 Rust 能够修复这些延迟峰值。因此,我们承担了将读取状态移植到 Rust 的任务,希望证明 Rust 是一种服务语言,并改善用户体验。

Rust 中的内存管理

Rust 中的内存管理速度极快并且内存效率高:没有运行时或垃圾回收器, 它可以驱动性能关键的服务,在嵌入式设备上运行,并且很容易与其它语言集成。

Rust 没有垃圾回收器, 所以我们认为它不会有和以前一样的延迟峰值。

Rust 使用了一种比较独特的内存管理方法,其中包含了内存「所有权」的概念。基本上,Rust 记录的是谁能够读写内存。它知道程序何时在使用内存,一旦不再需要内存,它就会立即释放内存。它在编译时强制执行内存规则,使得运行时内存错误几乎不可能发生。⁴您不需要手动跟踪内存。编译器会处理它。

因此,在 Rust 版的读取状态服务中,当用户的读取状态从 LRU 缓存中逐出时,它会立即从内存中释放出来。读取状态内存不会等待垃圾收集器收集它。Rust 知道它不再使用,并立即释放它。没有运行时进程来确定是否应该释放它。

异步 Rust

但是 Rust 的生态系统有一个问题。在这个服务被重新实现前,Rust 稳定版对于异步 Rust 并没有一个很好的描述。对于网络服务,异步编程是必需的。有一些社区库支持异步 Rust,但是它们需要大量的准备工作,而且错误消息反馈非常迟钝。

幸运的是,Rust 团队正在努力使异步编程变得简单,并且它可以在非稳定版的 Rust 中使用.

Discord 从未害怕接受看似有前途的新技术。例如,我们是 Elixir、React、React Native 和 Scylla 的早期采用者。如果一项技术是有前途的,并给我们带来优势,我们不介意处理其固有的困难和不稳定性。这是我们在不到 50 名工程师的情况下迅速达到 2.5 亿用户的方式之一。

在 Rust nightly 中采用新的异步特性是我们愿意采用新的、有前途的技术的另一个例子。作为一个工程团队,我们认为使用 Rust nightly 是值得的,并且我们致力于在 nightly 上运行,直到稳定版完全支持异步。我们处理了出现的任何问题,此时 Rust 终于稳定支持异步了。我们赌赢了。

实现、负载测试和启动

实际的重写是相当直接的。起初是一个粗糙的翻译,然后我们在有意义的地方进行了精简。 例如,Rust 有一个强大的类型系统,具有对泛型的广泛支持,因此我们可以丢弃仅仅因为缺少泛型而存在的 Go 代码。此外,Rust 的内存模型能够对跨线程的内存安全进行推理,因此我们能够丢弃 Go 中所需的一些手动跨 goroutine 内存保护。

当我们进行负载测试后,我们对结果很满意。Rust 版本的延迟与 Go 的效果一样好,并且没有延迟峰值!

值得注意的是,在编写 Rust 版本时,我们只对优化进行了非常基本的思考。即使只是基本的优化,Rust 也能够超越超手动调优的 Go 版本。 这是一个有力的证明,证明使用 Rust 编写高效程序与我们使用 Go 进行深度优化相比是多么容易。

但我们不满足于仅仅匹配 Go 的性能。经过一些评测和性能优化后,我们能够在每一个性能指标上击败 Go。 Rust 版本的延迟、CPU 和内存性能都更好。

Rust 性能优化包括:

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

满意之后,我们决定推出这项服务。

由于我们进行了加载测试,所以发布过程相当顺利。我们将它放到一个单一的 canary 节点上,找到一些缺失的边缘情况,并修复它们。不久后,我们把它推广到整个项目上。

下面是结果。

Go 是紫色,Rust 是蓝色。

Go

提升缓存容量

服务成功运行几天后, 我们决定重新提升 LRU 缓存容量. 在 Go 版本中, 如上所述, 提高 LRU 缓存的上限导致更长的垃圾收集. 我们不再需要处理垃圾收集, 所以我们认为可以提高缓存的上限并获得更好的性能. 我们增加了机箱的内存容量, 优化了数据结构以使用更少的内存(出于娱乐目的), 并将缓存容量增加到 800 万个读取状态.

下面的结果不言而喻. *请注意, 现在平均时间以微秒为单位, 而最大 @mention 则以毫秒为单位.

Go

进化中的生态系统

最后, Rust 的另一个伟大之处是它有一个快速进化的生态系统. 最近, Tokio (我们使用的异步运行时) 发布了 0.2 版. 我们升级了, 它给了我们的 CPU 空间更多的好处. 下面你可以看到中央处理器从 16 号开始持续降低。

Go

自此

此时, Discord 在其软件堆栈的许多地方使用 Rust. 我们将其用于游戏 SDK, Go Live 的视频捕获和编码, Elixir NIF, 几个后端服务等.

在启动新项目或软件组件时, 我们考虑使用 Rust. 当然, 我们只在有意义的地方使用它.

除了性能, Rust 对于工程团队来说有很多优势. 例如, 它的类型安全和借用检查器使得随着产品需求的变化或对该语言的新了解的发现, 重构代码变得非常容易. 此外, 生态系统和工具都非常优秀, 并且背后有大量的动力.

如果你做到了这一步, 你可能刚刚对 Rust 感到兴奋, 或者已经兴奋了很长一段时间. 如果你想专业地使用 Rust 解决有趣的问题, 你应该考虑来 Discord 工作.

还有一个有趣的事实: Rust 团队使用 Discord 来协调. 甚至还有一个非常有用的 Rust 社区服务器, 您可以发现我们不时地在其中聊天. 单击此处查看.


[1] Go Version 1.9.2. 编辑于: 图形来自 1.9.2. 我们尝试了 1.8, 1.9 和 1.10 版本, 没有任何改进. 从 GO 到 RUST 的初始端口于 2019 年 5 月完成.

[2] 更明确地说, 我们认为您不应该只用 rust 重写其他所有东西.

[3] 引用 https://www.rust-lang.org/

[4] 有问题的话, 肯定是因为您使用了 unsafe.

[5] https://areweasyncyet.rs/

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://blog.discord.com/why-discord-is-...

译文地址:https://learnku.com/go/t/43156

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
讨论数量: 2

只能说 适合的东西,做了适合的事

4年前 评论
appleboy

原文的 comment 有提到,Discord 用 go 1.9 已經是兩到三年前的產品,現在都 1.14 了,在效能上面有很顯著的提升。

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!