我们最近看到我们的一个 Rust 项目(一个axum
服务)在内存使用方面表现出一些奇怪的行为。我最不希望 Rust 程序出现奇怪的内存配置文件,但我们就是这样。
该服务将以“平坦”内存运行一段时间,然后突然跳到一个新的平台。这种模式会重复几个小时,有时会在负载下重复,但并非总是如此。令人担忧的是,一旦我们看到急剧增加,内存就很少会回落。就好像记忆丢失了,或者偶尔“泄漏”了一样。
在正常情况下,这种“阶梯式”配置文件看起来很奇怪,但在某一时刻,内存使用量会不成比例地攀升。无限制的内存增长可能会导致服务被迫退出。当服务突然退出时,这会降低可用性......这对业务不利。我想深入了解并弄清楚发生了什么。
通常,当我想到程序中意外的内存增长时,我会想到泄漏。不过,这似乎有所不同。通过泄漏,您往往会看到更稳定、更有规律的增长模式。
通常这看起来像一条向右上方倾斜的线。那么,如果我们的服务没有泄漏,那么它在做什么呢?
如果我能够确定导致内存使用量激增的条件,也许我可以缓解正在发生的情况。
我有两个迫切的问题:
从历史指标来看,我可以看到在长期平坦时期之间出现类似的急剧增长模式,但以前从未出现过这种增长。要知道生长本身是否是新的(尽管“阶梯”模式对我们来说是正常的),我需要一种可靠的方法来重现这种行为。
如果我可以强制“步骤”显现出来,那么当我采取措施抑制内存增长时,我将有一种方法来验证行为的变化。我还能够回溯我们的 git 历史并寻找服务没有表现出看似无限增长的时间点。
我在运行负载测试时使用的尺寸是:
发送到服务的 POST 正文的大小。
请求率(即每秒请求数)。
并发客户端连接数。
对我来说,神奇的组合是:更大的请求主体和更高的并发性。
在本地系统上运行负载测试时,存在各种限制因素,包括可用于运行客户端和服务器本身的处理器数量有限。尽管如此,在适当的情况下,即使总体请求率较低,我仍然能够在本地计算机的内存中看到“阶梯”。
使用固定大小的有效负载并批量发送请求,并在它们之间短暂休息,我能够一次一步地重复提高服务的内存。
我发现有趣的是,虽然我可以随着时间的推移增加记忆力,但我最终会达到收益递减的点。最终,增长将会有一些(仍然远高于预期)上限。多玩一会儿,我发现通过发送具有不同有效负载大小的请求可以达到更高的上限。
一旦我确定了我的输入,我就能够回顾我们的 git 历史,最终了解到我们的生产恐慌不太可能是我们最近的变化的结果。
触发此“阶梯”的工作负载的细节特定于应用程序本身,尽管我能够在玩具项目中强制出现类似的图表。
#[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }
事实证明,你不需要太多就能到达那里。我设法从带有接收 JSON 主体的单个处理程序的axum
应用程序中看到类似的急剧(但在本例中小得多)的增长。
虽然我的玩具项目中的内存增长远没有我们在生产服务中看到的那么显着,但这足以帮助我在下一阶段的调查中进行比较和对比。当我尝试不同的工作负载时,它还帮助我在较小的代码库中实现更紧密的迭代循环。有关我如何运行负载测试的详细信息,请参阅自述文件。
我花了一些时间在网上搜索可能描述类似行为的错误报告或讨论。反复出现的一个术语是堆碎片,在阅读了有关该主题的更多内容后,它似乎符合我所看到的内容。
一定年龄的人可能有过这样的经历:DOS 或Windows上的碎片整理实用程序在硬盘上移动块以合并“已用”和“空闲”区域。
对于这种旧的 PC 硬盘驱动器,不同大小的文件被写入磁盘,然后被移动或删除,从而在其他使用区域之间留下可用空间的“漏洞”。当磁盘开始填满时,您可能会尝试创建一个不太适合这些较小区域之一的新文件。在堆碎片场景中,这将导致分配失败,尽管磁盘碎片的故障模式会稍微不那么严重。在磁盘上,文件需要被分割成更小的块,这使得访问效率大大降低(感谢wongarsu
的更正)。磁盘驱动器的解决方案是对驱动器进行“碎片整理”(碎片整理),以便将这些开放块重新排列到连续空间中。
当分配器(负责管理程序中的内存分配)在一段时间内添加和删除不同大小的值时,可能会发生类似的情况。间隙太小并且分散在整个堆中可能会导致分配新的“新鲜”内存块以容纳不适合的新值。但不幸的是,由于内存管理的工作方式,“碎片整理”是不可能的。
碎片的具体原因可能是多种原因:使用serde
进行 JSON 解析、 axum
中框架级别的某些内容、 tokio
中更深层次的内容,甚至只是给定系统的特定分配器实现的一个怪癖。即使不知道根本原因(如果有这样的事情),该行为在我们的环境中也是可以观察到的,并且在一个简单的应用程序中可以重现。 (更新:需要更多调查,但我们非常确定这是 JSON 解析,请参阅我们对 HackerN ews 的评论)
如果这就是进程内存发生的情况,该怎么办?似乎很难改变工作负载来避免碎片。解除项目中的所有依赖项以可能在代码中找到碎片事件发生方式的根本原因似乎也很棘手。那么,可以做什么呢?
Jemalloc
来救援jemalloc
自称旨在“[强调]避免碎片和可扩展的并发支持”。并发确实是我的程序问题的一部分,而避免碎片就是游戏的名称。 jemalloc
听起来可能正是我所需要的。
由于jemalloc
是一个分配器,它首先会不遗余力地避免碎片,因此希望我们的服务能够运行更长时间,而无需逐渐增加内存。
更改程序的输入或一堆应用程序依赖项并不是那么简单。然而,更换分配器是微不足道的。
按照https://github.com/tikv/jemallocator自述文件中的示例,只需很少的工作即可对其进行试驾。
对于我的玩具项目,我添加了一个 Cargo 功能,可以选择替换jemalloc
的默认分配器并重新运行我的负载测试。
在模拟负载期间记录驻留内存显示了两个不同的内存配置文件。
如果没有jemalloc
,我们会看到熟悉的阶梯轮廓。使用jemalloc
,我们看到内存在测试运行时反复上升和下降。更重要的是,虽然加载期间和空闲期间jemalloc
的内存使用量之间存在相当大的差异,但我们不会像以前那样“失势”,因为内存总是会回到基线。
如果您碰巧在 Rust 服务上看到“阶梯式”配置文件,请考虑试用jemalloc
。如果您碰巧有一个会导致堆碎片的工作负载, jemalloc
可能会给出更好的总体结果。
另外,玩具项目存储库中包含一个benchmark.yml
可与https://github.com/fcsonline/drill负载测试工具一起使用。尝试更改并发性、主体大小(以及服务本身中的任意处理程序睡眠持续时间)等,以查看分配器的更改如何影响内存配置文件。
至于现实世界的影响,当我们推出到jemalloc
切换时,您可以清楚地看到配置文件的变化。
服务过去常常显示平坦的线条和大的步长,通常与负载无关,但现在我们看到一条更加参差不齐的线条,更紧密地跟随活动工作负载。除了帮助服务避免不必要的内存增长的好处之外,这一更改还让我们更好地了解服务如何响应负载,因此总而言之,这是一个积极的结果。
如果您有兴趣使用 Rust 构建强大且可扩展的服务,我们正在招聘!请查看我们的职业页面以获取更多信息。
如需了解更多此类内容,请务必在Twitter 、 Github或RSS上关注我们,了解Svix webhook 服务的最新更新,或加入我们社区 Slack上的讨论。
也发布在这里。