追踪Node.js中导致高内存使用的罪魁祸首
在这篇文章中,我来分享我追踪并解决Node.js高内存占用问题的方法。
目录- 上下文
-
方法
-
理解代码
-
重现问题
-
捕获暂存环境的性能数据
- 验证修复是否有效
- 结果
- 结论
最近我收到一张标题为“修复库x中的内存泄漏问题”的任务单。描述里提到一个Datadog仪表板,显示十几个服务因内存使用过高导致崩溃,并遭遇了OOM(内存不足)错误,这些服务都使用了同一个x库。
我最近才开始了解这个代码库(大约一周左右),这使得任务变得很具挑战性,也让这个经历值得分享。
我开始基于两条信息进行工作:
- 有一个被所有服务共用的库导致了较高的内存使用,该库使用了 redis(库名含 redis)。
- 受影响的服务如下:
以下是与该票证关联的控制面板:
服务在Kubernetes上运行,很明显地,随着时间的推移,服务占用的内存不断增加,直到达到内存限制,然后会因为耗尽内存而崩溃,重启后继续运行。
来试试这个方法吧在这节里,我将分享我是如何应对手头的任务的方式,找出高内存使用的原因并解决它。
读懂代码
由于我刚接触这个代码库,我首先想了解代码以及问题相关的库是如何工作的和如何使用。不幸的是,没有合适的文档,但我通过阅读代码和搜索服务是如何使用该库的,我大致了解了它的用途。这是一个围绕 Redis 流构建的库,它提供了一些方便的接口来生成和消费事件。花了一天半的时间阅读代码后,我仍然无法完全理解所有细节以及数据是如何流动的,这主要是因为代码结构复杂,尤其是有很多类继承和 rxjs,我对这个不是很熟悉。
所以我决定暂停阅读一会儿,尝试在观察代码运行时发现问题并收集一些遥测数据。
在隔离环境中重现问题
由于没有可用的剖析数据(比如持续剖析)来帮助我进一步研究,我决定在当地复现问题并尝试捕获内存剖析数据。
在 Node.js 中,我找到了几种获取内存使用情况分析的方法。
没有线索可循,我决定运行我认为最“数据密集”的部分,即redis流的生产端和消费端。我构建了两个简单服务,分别用于生成和消费redis流中的数据,并捕获内存配置文件并随时间比较结果。不幸的是,几个小时的负载生成后,比较了配置文件的结果,我没有发现任一服务的内存消耗有任何差异,一切看起来都正常。该库提供了多种与redis流交互的方式和接口。这让我意识到,要重现问题比我预期的要复杂得多,特别是因为我对这些服务的领域特定知识有限。
所以问题就是,我该怎样找到合适的机会和方法来发现内存泄漏。
从暂存系统抓取配置资料
如前所述,捕获内存配置信息最简单和最便捷的方式是实现在受影响的实际服务上进行持续监控,但这个选项并不在我的选择范围内。我尝试利用我们的预发布服务(它们也有同样的高内存消耗问题),以便以最小的努力捕获我所需要的数据。
我开始寻找一种方法,将 Chrome DevTools 连接到正在运行的一个 pod 并捕获一段时间的堆快照。我知道内存泄漏发生在 staging 环境中,所以如果我能捕获这些数据,我希望至少能找到一些热点区域。没想到,原来真的有办法做到这一点。
这样做,步骤如下:
- 通过向你的 pod 中的 node 进程发送
SIGUSR1
信号来开启 Node.js 调试器。
kubectl exec -it <nodejs-pod-name> -- kill -SIGUSR1 <node-process-id>
运行此命令可以发送 SIGUSR1 信号给指定的 Node.js 进程。
点击进入全屏,点击退出全屏
更多关于Node.js信号的信息,请参阅Signal Events
如果一切顺利,你应该能看到来自你服务的日志:
调试器正在监听这个地址:ws://127.0.0.1:9229/...
如需帮助,请访问:https://nodejs.org/en/docs/inspector
全屏 退出全屏
运行以下命令来在本地公开调试器监听的端口以便进行调试。
运行命令 kubectl port-forward <nodejs-pod-name> 9229
,这将允许您将本地端口9229转发到名为<nodejs-pod-name>的Node.js Pod上。
切换到全屏;退出全屏
- 将 Chrome Devtools 连接到你在前面步骤中开启的调试器。访问
chrome://inspect/
,你应该看到目标列表中有你的 Node.js 进程:
否则,确保您的目标发现的设置正确无误
现在你可以开始捕获随时间变化的快照(间隔时间根据内存泄漏出现的时间来确定),并进行比较。Chrome 的开发者工具提供了非常方便的方式来实现这件事。
您可以在堆快照记录页面找到更多关于Chrome开发者工具和内存快照的信息。
在创建快照的过程中,所有在主线程中的其他工作都会暂停。这可能需要超过一分钟甚至更长。快照是在内存中构建的,因此可能会使堆的大小翻倍,导致内存耗尽并最终导致应用崩溃。
如果你打算在生产环境中获取堆快照,确保该进程在崩溃时不会影响到应用程序的可用性。
[在Node.js文档中得知]( https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot#warning)
所以回到我的情况里,选择了两个快照来进行比较,按照差异排序,得到了下面的结果,就像你看到的一样。
我们可以看到最大的积极变化发生在string
构造函数上,这意味着在这两个快照之间,服务创建了大量的字符串,但这些字符串仍然在被使用。现在的问题是这些字符串是在哪里创建的,又被谁引用的。幸运的是,快照中还包含了这方面的信息,称为Retainers
(保留者)。
![牙齿保持器]( https://imgapi.imooc.com/6760d30209f207f808000361.jpg)
在查看快照和那个不断增加的字符串列表时,我注意到一些字符串类似于ID。点击它们后,我可以看到引用这些字符串的链对象——也就是所谓的Retainers
。这是一个名为sentEvents
的数组,其类名我可以从库代码中认出来。于是我们找到了罪魁祸首,一个只会不断增加的ID列表,我假设这些ID从未被释放。我捕捉了多个时间点上的快照,而这个地方始终是热点问题所在,并且其增长量一直为正。
验证一下是不是修好了
有了这些信息后,我无需试图完全理解代码,而是需要关注数组的目的及其填充和清空的时间。只有一个地方向数组中push
项,另一个地方从数组中pop
项,这缩小了需要修复的部分。
可以假设数组在应该被清空的时候并没有及时清空。不细究代码细节,我们大致可以这样理解:
- 该库提供了用于消费事件、生成事件或同时生成和消费事件的接口。
- 当它同时消费和生成事件时,它需要跟踪自身生成的事件,以便跳过这些事件,避免重新消费。生成事件时,
sentEvents
会被填充;消费时,消息会被跳过并清除sentEvents
里的对应记录。
你能看出这种情况会如何发展吗?当服务仅使用库来生成事件时,sentEvents
仍然会被填充所有事件,但没有任何代码路径(消费者)来清空它。
我修复了代码,使其仅在生产者-消费者模式下跟踪事件,并部署到了 staging 环境。即使在 staging 环境中的负载下,也很明显地看到这个修复降低了高内存使用,并且没有引入任何新的问题。
结果如下补丁部署到生产环境后,内存使用量显著下降,服务的稳定性也得到了增强(不再出现内存溢出(OOM))。
还有一个不错的额外好处就是,处理相同流量所需的pod数量减少了一半。
最后的总结这对我来说是一个很好的学习机会,让我了解如何在Node.js中跟踪内存问题,并更好地了解可用的工具。
我认为没有必要详细介绍每种工具,因为那样需要另外写一篇博文。但我希望这能为对此感兴趣或面临类似问题的任何人提供一个好的开始,我希望能帮助他们。
共同学习,写下你的评论
评论加载中...
作者其他优质文章