在各个领域,Rust 都已经成为一流的语言。最近,我们通过将服务的实现从 Go 切换到 Rust,极大地提升了该服务的性能。这里我阐述了重新实现服务为何是有价值的、该过程是如何实现的以及由此带来的性能提升。
Read States 服务
我们从 Go 切换到 Rust 的服务叫做“Read States”服务。它的唯一目的是跟踪用户阅读了哪些频道和信息。每当用户连接的时候,每当消息发送的时候,每当消息被读取的时候,都会访问 Read States。简而言之,Read States 处于最关键的位置。我们希望能够保证服务器始终让人感觉快捷无比,所以必须要确保 Read States 是非常快速的。
在 Go 的实现中,Read States 无法支持产品的需求。在大多数情况下,它都是很快速的,但是每几分钟我们就会看到很大的延迟峰值,这对于用户体验来说是很糟糕的。经过调查,我们确定峰值是由 Go 的核心特性引起的,也就是其内存模型和垃圾收集器(GC)。
为何 Go 无法满足我们的性能目标
为了阐述 Go 为什么无法满足我们的需求,我们首先需要讨论数据结构、规模、访问模式以及服务架构。
我们用来存储读取状态信息的数据结构被简便地称为“Read State”。服务器 有数十亿的 Read State。每个用户(User)的每个频道(Channel)都有一个 Read State。每个 Read State 都有多个计数器需要自动更新,并且经常会被重置为零。例如,其中有个计数器用来记录你某个频道中被提及了多少次。
为了快速获取原子计数器的更新,在每个 Read State 服务器中都保存了一个 Read State 的最近最少使用(LRU,Least Recently Used)的缓存。每个缓存中都有数百万的用户,每个缓存中又会有数千万的 Read State。每秒钟会有成千上万的缓存更新。
对于持久化来讲,我们使用 Cassandra 数据库集群作为缓存的支撑。在缓存键清除(eviction)的时候,我们会将 Read State 提交到数据库。每当 Read State 更新的时候,我们会将数据库提交调度到未来的 30 秒。每秒钟会有成千上万的数据库写入操作。
在下图中,我们可以看到 Go 服务的峰值采样时间帧的响应时间和 CPU(图表数据基于 Go 1.9.2。我们尝试了版本 1.8、1.9 和 1.10 版本,但没有任何改善。从 Go 到 Rust 的第一次切换是在 2019 年 5 月完成。)。正如我们所看到的,基本每两分钟就会出现延迟和 CPU 峰值。
为何每两分钟会出现峰值?
在 Go 中,当缓存键清除时,内存不会立即释放。相反,垃圾收集器每隔一定的时间就会运行一次,以便于查找不再被引用的内存并释放它。换句话说,Go 并不是在内存用完后立即释放,内存会挂起一段时间,直到垃圾收集器确定它真的是不再需要了。在垃圾收集的时候,Go 必须要做大量的工作来确认哪些内存是空闲的,这可能会降低程序的运行速度。
这些峰值看起来确实是垃圾收集器对性能的影响,但是我们所编写的 Go 代码已经非常高效了,内存分配很少。我们并没有制造太多的垃圾。
在深入研究了 Go 的源码之后,我们了解到至少每两分钟,Go 将强制运行一次垃圾收集。换句话说,如果垃圾收集器已经有两分钟没有运行了,不管堆增加了多少,Go 依然会强制运行垃圾收集。
我们认为可以优化垃圾收集器,使其运行地更加频繁,从而防止出现较大的峰值,因此我们在服务中实现了一个端点,在运行时修改垃圾收集器的 GC 百分比。令人遗憾的是,无论我们如何配置 GC 百分比,都不会发生任何变化。为什么会这样呢?事实证明,这是因为我们分配内存的速度不够快,从而导致无法强制垃圾收集频繁进行。
我们继续深入研究,发现出现如此大的峰值并不是因为有大量待释放的内存,而是因为垃圾收集器要扫描整个LRU 缓存,以便于确定内存是否完全没有被引用。鉴于此,我们认为更小的 LRU 缓存会更快,因为垃圾收集器要扫描的内容会更少。所以,我们在服务上添加了另外一项配置,允许修改 LRU 缓存的大小,并修改了架构,让每台服务器上能有许多的 LRU 缓存分区。
我们是正确的。LRU 缓存越小,垃圾回收的峰值越小。
但是,缩小 LRU 缓存的代价就是第 99 个百分位延迟时间的增长。这是因为,如果缓存比较小的话,用户的 Read State 在缓存中的几率就会降低。如果它不在缓存中,那么我们就需要进行数据库加载。
对不同的缓存容量进行了大量的负载测试之后,我们发现了一个看起来还不错的设置。虽然这不能让人完全满意,但是也是可以接受的,而且当时还有更重要的事情要做,所以我们让服务就这样运行了很长一段时间。
在那段时间里,我们看到 Rust 在其他地方越来越成功,于是我们一致决定要完全基于 Rust 创建用于构建新服务所需的框架和库。这个服务是移植到 Rust 的最佳候选,因为它很小而且是自包含的,但是我们也希望 Rust 能够修复这些延迟峰值的问题。所以,我们接受了将 Read States 移植到 Rust 的任务,希望 Rust 是一门合格的服务语言并且提升用户体验(澄清一下,我认为,你们并不应该为了要使用 Rust,就将所有的服务使用 Rust 重写一遍)。
Rust 中的内存管理
Rust 非常快并且节省内存:它没有运行时和垃圾收集器,能够支撑性能关键型的服务、可以运行在嵌入式设备中并且能够很容易地与其他语言集成(引自 Rust 官网)。
Rust 没有垃圾收集,所以我们认为它不会有与 Go 相同的延迟峰值问题。
Rust 使用了一种比较独特的内存管理方法,其中包含了内存“所有权”的概念。简而言之,Rust 会跟踪谁能够读写内存。它知道程序什么时候使用内存,并在不再需要内存的时候立即释放它。它在编译时强制执行内存规则,这样它根本不可能出现运行时内存错误(当然,除非你使用 unsafe)。我们不需要手动跟踪内存,编译器会处理它。
因此,在 Read States 服务的 Rust 版本中,当用户的 Read State 从 LRU 缓存中清除时,它会立即从内存中释放。Read State 内存不会等待垃圾收集器来收集它。Rust 知道它不会再使用了,并立即释放它。在 Rust 中并没有运行时进程来确定是否应该释放它。
异步的 Rust
但是,Rust 生态系统有一个问题。在这个服务重新实现的时候,Rust 稳定版并没有很好的异步 Rust 功能。但是对于网络服务来说,异步编程是必需的。有一些社区库支持异步 Rust,但是它们需要大量的样板式处理,而且错误消息非常模糊不清。
幸运的是,Rust 团队正在努力使异步编程变得更加简单,并且该功能可以在 Rust 不稳定的 nightly 版本中使用。
我从来都不惧怕接受那些看起来很有前途的新技术。例如,我们是 Elixir、React、React Native 和 Scylla 的早期采用者。如果某项技术很有前途,并能够给我们带来好处,我们不介意处理其固有的困难和不稳定性。这也是我们在不到 50 名工程师的情况下能够快速达到 2.5 亿用户的方法之一。
接受 Rust nightly 版本的异步特性就是我们愿意拥抱新的、有前途的技术的另外一个佐证。作为一个工程团队,我们认为值得使用 Rust nightly 版本,并承诺为 nightly 版本做出提交贡献直到异步功能在稳定环境下得到完全支持。我们一起处理出现的各种问题,此后 Rust 稳定版支持了异步 Rust(参见该网址)。终于苦尽甘来。
实现、负载测试和发布
实际的重写相当简单。首先,我们有一个大致的转换,然后我们把它进行有意义的优化。例如,Rust 有一个很好的类型系统,对泛型提供了广泛的支持,因此我们可以抛弃那些仅仅因为缺少泛型而存在的 Go 代码。另外,Rust 的内存模型能够推断出线程之间的内存安全性,因此我们能够抛弃 Go 中所需要的跨 goroutine 的内存保护。
刚开始进行负载测试时,我们马上就对结果感到非常满意。Rust 版本的延迟和 Go 版本一样好,而且没有延迟峰值!
值得注意的是,在编写 Rust 版本时,我们只对性能优化进行了非常基本的思考。即使只是基本的优化,Rust 也能够超越手动调优的 Go 版本。这深切证明了相对于深入研究 Go,使用 Rust 编写高效的程序有多么的容易。
但我们并不满足于简单地匹配 Go 的性能。经过一些性能分析和性能优化之后,我们能够在每个性能指标上击败 Go。在 Rust 版本中,延迟、CPU 和内存指标都更好。
Rust 版本中的性能优化包括:
在 LRU 缓存中,更改为使用 BTreeMap 取代 HashMap 以优化内存占用。将最初的指标库替换为使用现代 Rust 并发功能的指标库。减少我们正在执行的内存副本的数量。对此感到满意之后,我们决定推出这项服务。
由于我们进行了负载测试,所以发布过程相当顺利。我们把它放到一个金丝雀部署的节点上,查找到一些缺失的边缘情况,并修复了它们。不久之后,我们就把它推广到整个环境之中。
以下是测试的结果,Go 是紫色的线,Rust 是蓝色的线。
提高缓存的容量
在服务成功运行了几天之后,我们决定重新提高 LRU 的缓存容量。如上所述,在 Go 版本中,提高 LRU 缓存上限会导致更长的垃圾收集时间。现在,我们不再需要处理垃圾收集,因此我们认为可以提高缓存的上限并能够获得更好的性能。我们增加了内存容量,优化了数据结构以使用更少的内存 (仅仅为了好玩),并将缓存容量增加到 800 万条 Read States。
下面的结果不言自明。注意,现在平均时间以微秒计算,获取提及数的最大耗时以毫秒计算。
生态系统的演化
最后,Rust 的另一个好处是它有一个快速演化的生态系统。最近,tokio(我们使用的异步运行时) 发布了 0.2 版。我们进行了升级,它免费带来了 CPU 方面的优化。下面你可以看到 CPU 在 16 号左右开始就一直很低。
最后的思考
现在,我在其软件栈的许多地方都在使用 Rust。我们将它用于游戏 SDK、Go Live 的视频捕获和编码、Elixir NIFs 以及其他几个后端服务等等。
当开始一个新项目或软件组件时,我们都会考虑使用 Rust。当然,我们只在有意义的地方使用它。
除了性能之外,Rust 对于工程团队还有许多好处。例如,如果产品需求发生了变化,或者发现了关于该语言的新知识,Rust 的类型安全性和借用检查器(borrow checker )使代码重构变得非常容易。除此之外,Rust 的生态系统和工具都是非常优秀的,它们背后有强大的驱动力。