原文地址:blog.discord.com/why-discord…
发布时间:2020年2月5日 - 10分钟阅读
Rust正在成为各种领域中的一流语言。在Discord,我们已经看到Rust在客户端和服务器端的成功。例如,我们在客户端将其用于Go Live的视频编码管道,在服务器端将其用于Elixir NIFs。最近,我们通过将一个服务的实现从Go切换到Rust,极大地提高了该服务的性能。这篇文章解释了为什么我们要重新实现该服务,如何实现,以及由此带来的性能提升。
读取状态服务
Discord是一家专注于产品的公司,所以我们先从一些产品背景开始。我们从Go切换到Rust的服务是 "读取状态 "服务。它的唯一目的是跟踪你已经阅读的频道和信息。每次你连接到 Discord,每次发送消息,每次阅读消息,都会访问 Read States。简而言之,"阅读状态 "是在热门路径上。我们希望确保Discord在任何时候都能感觉到超快的速度,所以我们需要确保Read States是快速的。
在Go的实施中,Read States服务没有支持其产品要求。它在大多数时候都很快速,但每隔几分钟我们就会看到大的延迟峰值,这对用户体验很不利。经过调查,我们确定这些峰值是由Go的核心功能造成的:其内存模型和垃圾收集器(GC)。
为什么 Go 没有达到我们的性能目标
为了解释为什么Go没有达到我们的性能目标,我们首先需要讨论数据结构、规模、访问模式和服务的架构。 我们用来存储读取状态信息的数据结构被方便地称为 "读取状态"。Discord有数十亿的读取状态。每个用户、每个频道都有一个读取状态。每个读取状态都有几个需要原子化更新的计数器,并经常重置为0。例如,其中一个计数器是你在一个频道中有多少@mentions。
为了获得快速的原子式计数器更新,每个读取状态服务器都有一个最近使用最少的读取状态(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能解决这些延迟高峰。因此,我们承担了将Read States移植到Rust的任务,希望能够证明Rust是一种服务语言,并改善用户体验。
Rust中的内存管理
Rust的速度快得惊人,内存效率高:没有运行时或垃圾收集器,它可以为性能关键的服务提供动力,在嵌入式设备上运行,并容易与其他语言集成。
Rust没有垃圾收集器,所以我们认为它不会有Go那样的延迟峰值。
Rust使用了一种相对独特的内存管理方法,包含了内存 "所有权 "的概念。基本上,Rust记录了谁可以读取和写入内存。它知道程序什么时候在使用内存,一旦不再需要就立即释放内存。它在编译时执行内存规则,使得运行时的内存错误几乎不可能出现。编译器会照顾到这一点。
因此,在Rust版本的读取状态服务中,当用户的读取状态从LRU缓存中被驱逐时,它将立即从内存中释放出来。读取状态的内存不会坐在那里等待垃圾收集器来收集它。Rust知道它不再被使用并立即释放它。没有任何运行时过程来决定它是否应该被释放。
异步Rust
但是Rust的生态系统有一个问题。在这个服务被重新实现的时候,Rust稳定版对于异步Rust并没有一个很好的说法。对于一个网络服务来说,异步编程是一个要求。有几个社区库启用了异步Rust,但它们需要大量的仪式,而且错误信息极其晦涩难懂。
幸运的是,Rust团队正在努力使异步编程变得简单,而且在Rust的不稳定的夜间频道中也可以使用。
Discord从来都不害怕拥抱那些看起来很有前途的新技术。例如,我们是Elixir、React、React Native和Scylla的早期采用者。如果一项技术是有前途的,并能给我们带来优势,我们不介意处理流血边缘的固有困难和不稳定性。这是我们用不到50名工程师迅速达到2.5亿多用户的方法之一。
拥抱Rust nightly中的新异步功能是我们愿意拥抱新的、有前途的技术的另一个例子。作为一个工程团队,我们决定值得使用Rust的夜间版,并且我们承诺在稳定版完全支持async之前,在夜间版上运行。我们一起处理了出现的任何问题,目前Rust稳定版支持异步Rust.⁵赌注得到了回报。
实施、负载测试和发布
实际的重写是相当直接的。它开始是一个粗略的翻译,然后我们在有意义的地方进行了精简。例如,Rust有一个很好的类型系统,对泛型有广泛的支持,所以我们可以扔掉那些仅仅由于缺乏泛型而存在的Go代码。另外,Rust的内存模型能够推理出跨线程的内存安全,所以我们能够丢弃Go中需要的一些手动跨线程内存保护。
当我们开始进行负载测试时,我们立即对结果感到满意。Rust版本的延迟和Go版本一样好,而且没有延迟峰值!这让我们很高兴。
值得注意的是,在编写Rust版本时,我们只考虑了非常基本的优化。即使只是进行了基本的优化,Rust也能比手工调整过的Go版本更出色。这是一个巨大的证明,与我们对Go的深入研究相比,用Rust编写高效程序是多么容易。
但我们并不满足于简单地匹配Go的性能。在进行了一些分析和性能优化后,我们能够在每个性能指标上击败Go。在Rust版本中,延迟、CPU和内存都要好一些。
Rust的性能优化包括。
- 在LRU缓存中改用BTreeMap而不是HashMap,以优化内存使用。
- 将最初的指标库换成使用现代Rust并发的指标库。
- 减少了我们所做的内存拷贝的数量。
满意之余,我们决定推出该服务。
因为我们进行了负载测试,所以推出的过程相当顺利。我们把它放到了一个单一的金丝雀节点上,发现了一些缺失的边缘案例,并修复了它们。之后不久,我们就把它推广到了整个车队。
下面是结果。
Go是紫色,Rust是蓝色。
提高缓存容量
在服务成功运行了几天后,我们决定是时候重新提高LRU缓存的容量了。在Go版本中,如上所述,提高LRU缓存的上限导致了更长的垃圾回收。我们不再需要处理垃圾回收,所以我们认为我们可以提高缓存的上限,从而获得更好的性能。我们增加了盒子的内存容量,优化了数据结构以使用更少的内存(为了好玩),并将缓存的容量增加到800万个读取状态。
下面的结果不言自明。请注意,现在的平均时间是以微秒为单位的,而最大@提到的时间是以毫秒为单位的。
不断发展的生态系统
最后,Rust的另一个优点是它有一个快速发展的生态系统。最近,tokio(我们使用的异步运行时)发布了0.2版本。我们进行了升级,它免费为我们提供了CPU的好处。下面你可以看到CPU从16号左右开始持续降低。
结束的想法
在这一点上,Discord在其软件堆栈的许多地方都在使用Rust。我们把它用于游戏SDK、Go Live的视频捕捉和编码、Elixir NIFs、几个后端服务等等。
当开始一个新项目或软件组件时,我们考虑使用Rust。当然,我们只在有意义的地方使用它。
除了性能之外,Rust对工程团队来说还有很多优势。例如,它的类型安全和借贷检查器使它很容易在产品需求改变或发现新的语言知识时重构代码。另外,它的生态系统和工具也非常好,而且背后有大量的动力。
如果你能走到这一步,你可能对Rust感到兴奋,或者已经兴奋了很久。如果你想专业地使用Rust来解决有趣的问题,你应该考虑在Discord工作。
另外,一个有趣的事实是:Rust团队使用Discord来协调。甚至还有一个非常有用的Rust社区服务器,你可以发现我们时常在里面聊天。点击这里查看。
- 前往1.9.2版本。编辑:图表来自1.9.2。我们试过1.8、1.9和1.10版本,没有任何改进。从Go到Rust的最初移植是在2019年5月完成的。
- 说白了,我们不认为你应该用rust重写一切,只是因为。
- 引用自www.rust-lang.org/
- 当然,除非你使用不安全的。
- areweasyncyet.rs/