Go 为什么无法满足咱们的需求,咱们首先需求讨论数据结构、规模、拜访模式以及服务架构。
咱们用来存储读取状况信息的数据结构被简洁地称为“Read State”。Discord 稀有十亿的 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 在 Discord 的其他地方越来越成功,于是咱们一致决议要彻底根据 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 版别中运用。
Discord 历来都不惧怕承受那些看起来很有出路的新技能。例如,咱们是 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 号左右开始就一直很低。
最终的考虑
现在,Discord 在其软件栈的许多地方都在运用 Rust。咱们将它用于游戏 SDK、Go Live 的视频捕获和编码、Elixir NIFs 以及其他几个后端服务等等。
当开始一个新项目或软件组件时,咱们都会考虑运用 Rust。当然,咱们只在有含义的地方运用它。
除了功用之外,Rust 关于工程团队还有许多优点。例如,假如产品需求发生了变化,或许发现了关于该言语的新知识,Rust 的类型安全性和借用查看器(borrow checker )使代码重构变得十分简略。除此之外,Rust 的生态系统和东西都是十分优异的,它们背后有强大的驱动力。
转载请注明:SuperIT » 为什么 Go 无法满足我们的目标