在 Heroku 上调试内存泄漏

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

这是 Heroku Ruby 客户最常问我的问题之一:“我如何调试内存泄漏?”

记忆力很重要。如果你没有足够的空间,你最终会使用 交换内存 并真正减慢你的网站。那么,当您认为发生内存泄漏时,您会怎么做?

您最有可能看到的是 Ruby 应用程序的正常内存行为。你可能没有内存泄漏。我们将在一分钟内解决您的记忆问题,但我们必须先介绍一些基础知识。

“你可能没有内存泄漏。但如果你这样做呢? – 通过@codeship
点击鸣叫

当您的应用程序启动时,它会消耗基本数量的 RAM。它在为页面提供服务时继续增长,直到趋于平稳。这条曲线看起来有点像 水平渐近函数

虽然该值确实会随着时间的推移而上升,但它会趋于稳定并最终徘徊在一个稳定点附近。真正的内存泄漏不会趋于平稳,而是会随着时间的推移无限增加。大多数人认为这是内存泄漏的原因是他们只查看了一小部分数据。如果您放大到同一条曲线足够远,它看起来并不打算停止:

如果你的应用程序使用的内存比它应该使用的多,但它不会无限期地继续永远消耗内存,那么你就会出现内存膨胀。

减少进程数:最容易实现的目标

如果您在生产环境中运行 Ruby 应用程序,它需要在并发网络服务器上运行。这是一个能够同时处理多个请求的网络服务器。 Heroku 推荐 Puma 网络服务器。大多数现代并发网络服务器允许您运行多个进程以获得并发性。 Puma 称这些为“工人”,您可以在 config/puma.rb 文件中设置此值:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

每次增加 WEB_CONCURRENCY 值时,您都会获得额外的处理能力,但会以更多内存为代价。如果您的应用程序超出了 RAM 限制并达到交换,最简单的方法就是减少您使用的工作人员数量;比方说,从四个工人变成三个,或者从两个工人变成一个(假设你仍然通过线程获得并行性)。

但是,您会注意到,从四个进程变为三个进程不会将内存使用量减少四分之一。这是因为现代版本的 Ruby 是 写时复制 友好的。也就是说,只要不尝试修改内存,多个进程就可以共享内存。例如:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

当我们运行它时,第一个进程使用数百兆字节的内存。然而,我们的分叉进程非常小:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

这是一个人为的例子,显示分叉进程更小。就 Puma 而言,forked worker 会更小,但肯定不会小 1/788。

用更少的工人运行有缺点——最重要的是,你的吞吐量会下降。如果你不想用更少的工人运行,那么你需要找到对象创建的热点并弄清楚如何绕过它们。

滚动重启

由于您的应用程序会随着时间的推移在内存中缓慢增长,我们可以通过定期重启工作人员来阻止它变得太大。你可以用 Puma Worker Killer 做到这一点。首先,将 gem 添加到您的 Gemfile 中:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

然后在初始化程序中,例如 config/initializers/puma_worker_killer.rb ,添加:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

如果您的内存膨胀非常缓慢,该代码将每 12 小时重新启动您的 worker。如果你的内存增加得更快,你可以设置一个更小的重启持续时间。

这是解决内存膨胀的整体问题的创可贴;它实际上并没有修复任何东西。但是,它可以为您赢得一些时间来真正深入研究您的记忆问题。您应该注意,当重启发生时,您的应用程序的吞吐量会随着工作人员的循环而下降。因此,将重启值设置为非常低的值(如五分钟)是不好的。

这就是我的网络服务器技巧。让我们看看如何真正减少内存使用。

“如何处理内存膨胀与内存泄漏”——来自@codeship
点击鸣叫

引导内存

让 Ruby 应用节食的最简单方法是删除不使用的库。在启动时查看每个库对内存的内存影响很有帮助。为了解决这个问题,我编写了一个名为 derailed benchmarks 的工具。将此添加到您的 Gemfile:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

运行 $ bundle install 后,您可以看到每个库的内存使用情况:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

如果您看到一个库占用了大量您不使用的 RAM,请将其从您的 Gemfile 中删除。如果有一个破坏了您的内存库但您需要它,请尝试升级到最新版本以查看是否有任何内存修复。两个值得注意且简单的修复是:

在 Gemfile 的顶部,添加:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

修剪你的 Gemfile 有助于减少你的起始记忆。您可以将其视为启动应用程序所需的最小 RAM 量。一旦您的应用真正开始响应请求,内存只会增加。要更好地了解这两种类型的内存如何交互,您可以观看我关于 Ruby 如何使用内存的 演讲。

!新号召性用语

运行时更少的对象

在修剪你的 Gemfile 之后,下一步是弄清楚你的大部分运行时分配是从哪里来的。您可以使用托管分析服务,例如 Skylight 的内存跟踪 。或者,您可以尝试使用 Derailed 在本地重现内存膨胀。

一旦你将你的 应用程序设置为由 derailed 启动 ,那么你可以看到当你到达一个端点时你的应用程序的哪些行分配了最多的对象:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

这将使用 memory_profiler 输出一堆分配信息。对我来说最有趣的部分是 allocated memory by location 。这将显示分配了大量内存的行号。

虽然这通常会在某种程度上对应于 allocated objects by location ,但重要的是要注意并非所有对象都是平等分配的。多级嵌套散列将比短字符串占用更多空间。最好专注于减少分配而不是减少对象分配计数。

运行此命令时,您可能会从库中听到很多噪音:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

虽然此处的大部分输出来自 actionpack pg 等 gem,但您可以看到其中一些来自我在 /Users/richardschneeman/Documents/projects/codetriage 上运行的应用程序。这是一个开源应用程序;如果需要,您可以在本地运行它: CodeTriage

如果您想专注于应用程序内部的对象分配,您可以使用 ALLOW_FILES 环境变量进行过滤。


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

此应用程序已经非常优化,但即便如此,我们仍可以进行一些微优化。在第 84 行的 app/models/repo.rb 中,我们看到:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

在这里,我们使用两个字符串来构建 GitHub 路径,例如 "schneems/derailed_benchmarks" 。我们不需要分配这个新字符串,因为我们已经将此信息存储在数据库中名为 full_name 的字段中,该字段已从数据库加载并分配。我们可以通过将其更改为以下代码来优化此代码:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

现在,当我们重新运行基准测试时:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

您可以看到 models/repo.rb:84 行不再存在。在此示例中,我们在每次加载页面时节省 35,520 字节。如果您正在查看对象计数:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

我们正在避免分配 888 个字符串。在这种情况下,更改非常简单,提交优化没有任何不利之处。然而,并非所有基于内存或性能的更改都如此简单。你怎么知道什么时候值得?

我建议在使用 benchmark/ips 和 derailed 之前和之后进行测试。根据我的粗略计算,在配备 8GB RAM 和 1.7 GHz CPU 的 MacBook Air 上,需要大约 70,000 个小字符串分配才能增加 1 毫秒的 Ruby 运行时。诚然,如果您在共享或“云”计算平台上运行,这可能比您的生产服务器拥有的强大得多,因此您可能会看到更多的节省。当对更改有疑问时,请始终进行基准测试。

“当对变化有疑问时,总是基准。” –@schneems
点击鸣叫

您可以使用 PATH_TO_HIT 变量为不同的端点重复运行这些基准测试。


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

从应用程序中最慢的端点开始,然后倒退。如果您不确定哪些端点速度较慢,您可以试用 rack mini profiler

好吧,你可能真的看到了内存泄漏

我知道我之前说过你没有内存泄漏,但你可能会。内存膨胀和内存泄漏之间的主要区别在于泄漏永远不会趋于平稳。我们可以滥用这个特性来暴露内存泄漏。我们将在一个循环中一遍又一遍地访问同一个端点,看看内存会发生什么。如果它趋于平稳,那就是膨胀;如果它永远上升,那就是泄漏。

您可以通过运行命令来执行此操作:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

您可以使用 TEST_COUNT 环境变量增加此测试运行的迭代次数:


 workers Integer(ENV['WEB_CONCURRENCY'] || 2)

这还会在您的 tmp/ 文件夹中的文件中创建一组换行符分隔的值。我喜欢复制这些值并将它们粘贴到 Google 电子表格中。这是一个例子:

请记住始终标记您的轴;具体来说,不要忘记包括单位。在这里我们可以看到主页上的CodeTriage没有泄漏。

但是,如果您的图形确实向上和向右移动,该怎么办?幸运的是,消除内存泄漏与查找和消除内存膨胀是一样的。如果您的泄漏需要很长时间才能变得明显,您可能需要使用更高的 TEST_COUNT 运行 perf:objects 命令,以便您的泄漏信号高于正常对象生成的噪声。

综上所述

即使我们使用内存管理语言和非常好的垃圾收集器,我们也不能完全忽略内存。这是坏消息。好消息是,通常我们可以等到问题突然出现再需要过多关心。

我很高兴有更多的 Ruby 程序员对内存和性能调优感兴趣。随着越来越多的人加快他们的应用程序,Ruby 社区获得了更多更好的制度化知识。以下是我在本文中提到的工具列表:

如果你已经使用了这里提到的所有技巧,但你仍然遇到内存错误 ( R14 ),可能是时候 升级到具有更多内存的更大的测功机 了。如果你转向“性能”dyno,你将拥有自己专用的运行时实例,而且你不必担心“吵闹的邻居”。

“在@Heroku 上调试内存泄漏”——来自@codeship
点击鸣叫

在 Heroku 上调试内存泄漏的 帖子首先出现在 via @codeship 上。