Rust正在成为许多领域的一流语言。在Discord,Rust在客户端和服务器端都取得了成功。他们通过将服务的实现从Go切换到Rust,极大地提高了服务的性能。这篇文章解释了Discord为什么将服务用Rust重新实现,它是如何实现的,以及由此带来的性能改进。
读取状态服务
Discord是一家专注于产品的公司,所以将从一些产品背景开始。从Go切换到Rust的服务是“Read States”服务。它的唯一目的是跟踪已阅读的频道和消息。每次连接到Discord,每次发送消息和每次读取消息时都会访问Read States服务。
在Go实现中,Read States服务不支持其性能需求。大多数时候它都是快速的,但每隔几分钟就会看到巨大的延迟峰值,这不利于用户体验。经过调查,确定峰值是由于Go的核心特性导致的:内存模型和垃圾收集器(GC)。
为什么Go没有达到我们的性能目标
为了解释为什么Go不能达到性能目标,首先需要讨论服务的数据结构、规模、访问模式和体系结构。用来存储读状态信息的数据结构被简单地称为“Read States”。Discord有数十亿的Read状态。每个通道中每个用户都有一个Read State。每个Read State都有几个计数器,这些计数器需要自动更新,并且经常重置为0。例如,其中一个计数器是统计你在一个通道中被@了多少次。
为了获得快速的原子计数器更新,每个Read States服务器都有一个最近最少使用(Least Recently Used, LRU)的Read States缓存。每个缓存中有数百万个用户,每个缓存中有数千万个读状态,每秒有数十万次缓存更新。
为了实现持久性,使用Cassandra数据库集群来支持缓存。在缓存键退出时,将读状态提交给数据库。还在将来每次更新Read State时安排30秒的数据库提交,每秒有数以万计的数据库写入。
在下面的图片中,可以看到Go服务的峰值示例时间:响应时间和系统cpu。注意到,大约每2分钟就会出现一次延迟和CPU峰值。
那么为什么是2分钟的峰值呢?
在Go服务中,在缓存键退出时,内存不会立即释放。垃圾收集器经常运行以查找没有引用的内存,然后释放它。换句话说,不是在内存用完后立即释放,而是内存挂起一段时间,直到垃圾收集器可以确定它是否真的用完。在垃圾收集期间,Go必须做很多工作来确定哪些内存是空闲的,这可能会减慢程序的速度。这些延迟峰值确实像是垃圾收集对性能的影响,但Discord编写的Go代码非常高效,并且分配很少,应该没有制造很多垃圾。
在深入研究Go源代码之后,Discord了解到Go强制每2分钟运行一次垃圾收集。换句话说,如果垃圾收集在2分钟内没有运行,不管堆增长如何,go仍然会强制进行垃圾收集。
Discord认为可以调整垃圾收集器,使其更频繁地发生,以防止出现大的峰值,因此在服务上实现了一个端点,以便动态地更改垃圾收集器GC Percent。不幸的是,无论如何配置GC百分比,都没有任何变化。这是因为分配内存的速度不够快,不足以强制更频繁地进行垃圾收集。
Discord不断深入研究,发现峰值之所以巨大,并不是因为有大量准备释放的内存,而是因为垃圾收集器需要扫描整个LRU缓存,以确定内存是否真的没有引用。这样较小的LRU缓存会更快,因为垃圾收集器需要扫描的东西更少。因此,Discord向服务添加了另一个设置,以更改LRU缓存的大小,并更改架构,使每台服务器具有许多分区的LRU缓存。
Discord是对的,LRU缓存越小,垃圾收集产生的峰值就越小。不幸的是,减小LRU缓存的代价是增加了读取的延迟时间。这是因为如果缓存较小,则用户的Read State不太可能在缓存中。如果不在缓存中,就必须加载数据库。在对不同的缓存容量进行了大量负载测试之后,Discord找到了一个看起来不错的设置。虽然不是完全满意,但也还可以,而且还有更重要的事情要做,所以Discord让服务这样运行了很长一段时间。
在那段时间里,Rust在Discord的其他部分取得了越来越多的成功。因此,Discord想要创建完全用Rust构建新的Read State服务所需的框架和库,希望Rust能够修复这些延迟峰值。
Rust中的内存管理
Rust没有垃圾收集,所以Discord认为它不会有与Go相同的延迟峰值。Rust使用了一种相对独特的内存管理方法,它包含了内存“所有权”的概念。基本上,Rust跟踪谁可以读写内存。它知道程序何时在使用内存,并在不再需要内存时立即释放内存。在编译时强制内存规则,使得运行时内存错误几乎不可能发生,不需要手动跟踪内存。编译器会处理它。
因此,在Rust版本的Read States服务中,当用户的Read State从LRU缓存中被驱逐时,它将立即从内存中释放。Rust知道它不再被使用,并立即释放它。没有运行时进程来确定是否应该释放它。
实现、负载测试和启动
实际的重写相当直接。它开始是一个粗略的语言转换,然后在有意义的地方进行了精简。例如,Rust有一个很好的类型系统,广泛支持泛型,所以可以抛弃那些仅仅因为缺乏泛型而存在的Go代码。此外,Rust的内存模型能够推断跨线程的内存安全,因此能够抛弃一些在Go中需要的手动跨线程内存保护。当Discord开始负载测试时,他们立即对结果感到满意。Rust版本与Go版本一样好,并且没有延迟峰值!
值得注意的是,在编写Rust版本时,只对优化进行了非常基本的思考。即使只是进行了基本的优化,Rust也能够胜过手动调整的Go版本。这是一个巨大的证明,用Rust编写高效的程序是多么容易。经过一些分析和性能优化,Discord能够在每一个性能指标上击败Go。延迟、CPU和内存在Rust版本中都更好。
Rust性能优化包括:
1,在LRU缓存中使用BTreeMap而不是HashMap以优化内存使用。
2,将最初的度量库替换为使用Rust并发性的度量库。
3,减少内存拷贝的数量。
Discord进行了负载测试,所以发行过程相当无缝。把它放在一个金丝雀节点上,发现了一些缺失的边缘情况,并修复了它们。不久之后,Discord将其推广到整个开发团队。
以下是对比结果:Go是紫色,Rust是蓝色。
提高缓存容量
服务成功运行了几天后,Discord决定是时候重新提高LRU缓存容量了。在Go版本中,提高LRU缓存的上限会导致更长的垃圾收集。现在不再需要处理垃圾收集,因此可以提高缓存的上限,从而获得更好的性能。Discord增加了内存容量,优化了数据结构以使用更少的内存,并将缓存容量增加到800万个Read States。
总结
Discord在其软件技术栈的许多地方使用Rust,将其用于游戏SDK、Go Live视频捕获和编码、Elixir nif、几个后端服务等等。当开始一个新的项目或软件组件时,Discord优先考虑使用Rust。当然,只在有意义的地方使用它。
除了性能,Rust对工程团队还有很多优势。例如,它的类型安全和借用检查器使得在产品需求发生变化或发现有关语言的新知识时重构代码变得非常容易。此外,生态系统和工具都非常出色。