追踪 Node.js 中的高内存使用情况

2025-06-05

追踪 Node.js 中的高内存使用情况

在本文中,我将分享追踪和修复 Node.js 中高内存使用率的方法。

内容

语境

最近我收到一张工单,标题是“修复 x 库中的内存泄漏问题”。工单描述中包含一个 Datadog 仪表盘,显示十几个服务内存占用过高,最终因 OOM(内存不足)错误而崩溃,而这些服务都使用了 x 库。

我最近才接触到代码库(不到 2 周),这使得任务具有挑战性并且值得分享。

我开始研究两条信息:

  • 所有服务都使用一个库来导致内存使用率过高,并且它涉及 redis(库的名称中包含 redis)。
  • 受影响的服务列表。

以下是与该票证相关的仪表板:

初始状态仪表板

服务在 Kubernetes 上运行,很明显,服务会随着时间的推移积累内存,直到达到内存限制、崩溃(回收内存)并重新启动。

方法

在本节中,我将分享如何处理手头的任务,找出高内存使用率的罪魁祸首并随后修复它。

理解代码

由于我对代码库还不熟悉,我首先想了解代码,了解相关库的功能以及应该如何使用,希望通过这个过程能更容易地识别问题所在。遗憾的是,没有合适的文档,但通过阅读代码并搜索服务如何使用该库,我能够理解它的要点。它是一个封装了 Redis 流的库,并公开了方便的事件生成和消费接口。花了一天半的时间阅读代码后,由于代码结构和复杂性(大量的类继承和rxjs,我不熟悉),我未能掌握所有细节以及数据流向。

因此,我决定暂停阅读,并在观察代码运行和收集遥测数据的同时尝试发现问题。

单独复制该问题

由于没有可用的分析数据(例如连续分析)来帮助我进一步调查,我决定在本地复制该问题并尝试捕获内存配置文件。

我发现了几种在 Node.js 中捕获内存配置文件的方法:

由于不知从何入手,我决定运行我认为该库中最“数据密集”的部分——Redis 流生产者和消费者。我构建了两个简单的服务,分别用于从 Redis 流中生成和消费数据,然后捕获内存配置文件并比较不同时间范围内的结果。不幸的是,在对服务进行几个小时的负载测试并比较配置文件后,我并没有发现这两个服务的内存消耗有任何差异,一切看起来都很正常。该库暴露了许多不同的接口和与 Redis 流交互的方式。我意识到,要复现这个问题会比我预想的要复杂得多,尤其是在我对实际服务领域知识有限的情况下。

所以问题是,我如何才能找到正确的时刻和条件来捕获内存泄漏?

从暂存服务中捕获配置文件

如前所述,捕获内存配置文件最简单、最便捷的方法是持续分析受影响的实际服务,而我没有这个选项。我开始研究如何至少利用我们的临时服务(它们也面临着同样的高内存消耗),这样我就可以毫不费力地捕获所需的数据。

我开始寻找一种方法,将 Chrome DevTools 连接到其中一个正在运行的 Pod,并捕获一段时间内的堆快照。我知道内存泄漏发生在暂存阶段,所以如果我能捕获这些数据,我希望至少能发现一些热点。令我惊讶的是,有一种方法可以做到这一点。

执行此操作的过程

  • SIGUSR1通过向 pod 上的节点进程发送信号来启用 pod 上的 Node.js 调试器。
kubectl exec -it <nodejs-pod-name> -- kill -SIGUSR1 <node-process-id>
Enter fullscreen mode Exit fullscreen mode

有关 Node.js 信号的更多信息,请参阅Signal Events

如果成功,您应该会看到来自服务的日志:

Debugger listening on ws://127.0.0.1:9229/....
For help, see: https://nodejs.org/en/docs/inspector
Enter fullscreen mode Exit fullscreen mode
  • 通过运行以下命令在本地公开调试器正在监听的端口
kubectl port-forward <nodejs-pod-name> 9229
Enter fullscreen mode Exit fullscreen mode
  • 将 Chrome Devtools 连接到您在前面步骤中启用的调试器。访问chrome://inspect/后,您应该会在目标列表中看到您的 Node.js 进程:

Chrome 开发者工具

如果没有,请确保您的目标发现设置正确

发现设置

现在,您可以开始捕获一段时间内的快照(具体时间取决于内存泄漏发生所需的时间)并进行比较。Chrome DevTools 提供了一种非常便捷的方法。

您可以在记录堆快照中找到有关内存快照和 Chrome Dev Tools 的更多信息

创建快照时,主线程中的所有其他工作都会停止。根据堆内容,创建快照可能需要一分钟以上的时间。快照是在内存中构建的,因此它可能会使堆大小翻倍,最终填满整个内存,导致应用程序崩溃。

如果您要在生产中拍摄堆快照,请确保拍摄快照的进程可以崩溃而不会影响应用程序的可用性。

来自 Node.js 文档

回到我的情况,选择两个快照进行比较并按增量排序,我得到了下面所看到的内容。

增量比较

我们可以看到,最大的正增量发生在string构造函数中,这意味着服务在两次快照之间创建了大量字符串,但它们仍在使用中。现在的问题是,这些字符串是在哪里创建的,以及是谁在引用它们。幸好,捕获的快照也包含了这些信息Retainers

保持器

在深入研究快照和持续缩减的字符串列表时,我注意到一个类似于 ID 的字符串模式。点击它们,我可以看到引用它们的链式对象——也就是Retainers。它是一个从类名调用的数组sentEvents,我从库代码中认出了这个类名。好了,我们找到了罪魁祸首,一个唯一不断增长的 ID 列表,我当时以为它们从未被释放过。我多次捕获了快照,这是唯一一个不断重新出现热点的地方,并且增量很大。

验证修复

有了这些信息,我不再试图理解整段代码,而是专注于数组的用途,比如何时填充以及何时清除。代码中只有一个地方提到了pushing向数组中添加元素,另一个地方提到了从数组中popping移除元素,这缩小了修复范围。

可以安全地假设数组在应该清空的时候没有被清空。忽略代码细节,基本情况如下:

  • 该库公开了用于消费、生成事件或生成和消费事件的接口。
  • 当它同时消费和生产事件时,它需要跟踪进程本身产生的事件,以便跳过这些事件,避免重复消费它们。sentEvents在生产事件时,会填充该值;在尝试消费时,则会清除该值,跳过这些消息。

你能看出这是怎么回事吗?当服务仅使用库来生成事件时,它sentEvents仍然会被所有事件填充,但没有用于清除它的代码路径(消费者)。

我修补了代码,使其仅在生产者/消费者模式下跟踪事件,并部署到暂存区。即使在暂存区负载下,补丁也明显有助于降低高内存占用率,并且没有引入任何回归问题。

结果

当补丁部署到生产环境时,内存使用量大幅减少,服务可靠性得到提高(不再出现 OOM)。

每个 Pod 的内存使用情况和 OOM

一个很好的副作用是处理相同流量所需的 pod 数量减少了 50%。

豆荚减少

结论

对于我来说,这是一个很好的学习机会,可以让我跟踪 Node.js 中的内存问题并进一步熟悉可用的工具。

我认为最好不要详细讨论每个工具的细节,因为这值得单独发帖,但我希望对于任何有兴趣了解更多有关该主题或面临类似问题的人来说,这都是一个很好的起点。

文章来源:https://dev.to/gkampitakis/tracking-down-high-memory-usage-in-nodejs-2lbn
PREV
API 设计中的幂等性
NEXT
边做边学 Tauri - 第一部分:简介和结构