如何调试 Node.js的内存泄露

在产品应用程序中,内存泄露是很常见的。幸运的是通常不难发现它们。 接下来是一个练习的演练,这个练习是Igor Soarez 和我最近在 WDCNZ 上所教授 Node.js 性能专题课程的一部分。

问题

我们有一个服务运行在一个反向波兰表示法计算器(RPN,Reverse Polish Notation)的产品上,这个产品是基于 WebSockets 实现的。在这个程序的生命周期里,内存使用看上去不断地增长,尤其明显的是当我们使用这个服务时,内存使用会出现一个尖峰。

这个服务的源代码可以在我们 Github 的 calc-server 下找到。你可能仅通过检查这段很短的代码就可以找到这个内存泄露,但我们的想法是不阅读代码就能确定这个泄露。

快速地看一下 client.js 以便知道客户端是如何使用我们的服务。

在继续确认问题和分析程序前,我们先在本地安装和运行服务器。 我们利用 heapdump 来分析 Node 程序里的堆,以便找到一个解决方法。

确认诊断结果

检查内存的使用

在产品中,你可以使用一个应用程序性能管理(APM,Application Performance Management)方案去监控 RAM 使用,如果出现问题它会提醒你。

在这个练习中,我们将结合使用古老的 ps 和 top,以及一些用来引发问题的负载检测方法,以便我们证实问题的存在(检查是代码中的哪些改动造成这样的预期效果)。

使用 ps 来检查进程的内存使用情况:

(使用 pgrep -lfa node,你可以找到 Node.js 进程的 PID)

examining memory usage of a Node process with ps

这告诉我们进程的驻留集大小和虚拟内存大小。(译者注:RSS,即进程所使用的非交换区的物理内存)

RSS 用来表示这个进程当前正在使用的 RAM 大小。包含所有的栈和堆内存,也会包含共享库的内存,只要那些库的页面实际上是在内存中。

VSZ 表示这个进程上有多少内存可以用。包含交换区的内存和所有的共享库。VSZ 包括 RSS,而且通常比较大。

我们可以使用 top 来观察内存使用的实时情况:

examining memory usage of a Node process with topOSX 说明:OSX 即使在有大量空闲 RAM 可用时,也会积极地压缩它认为“不活动”进程的内存页面。这可能会导致对于一个空闲的 Node.js 进程, ps 和 top 显示内存占用较小,一旦这个进程重新开始做事情,内存占用就会激增。

(在Node 进程中调用 process.memmoryUsage 函数也可以测量 RAM 使用情况,但是我们不准备使用这种方法。)

测量内存使用的题外话

现代操作系统的内存管理是相当复杂的,对于“我的进程使用了多少内存”这个问题,并没有一个简单的答案。

这里我们要寻找的是确认内存使用在负载时仍然在增长 —— 而不是使用内存的准确数量。

扩展阅读:

确认增长

下一步就是要在服务器上增加一些负载来确认内存的增加。我们使用 Mingigun,一个简单却强大的负载测试工具(实际上由你自己开发)来做这件事。

我们的负载测试脚本(包含在 test.json 这个repo中)每秒将创建10个新的用户会话,一共持续120秒的时间。每个用户都调用我们的服务去进行两个数字的加法操作:

运行下面脚本,安装 Minigun:

然后运行它:

当测试运行的时候,使用 top 监控你的 cal-server。我们应该看到在两分钟里内存使用量稳定地增长。

在我电脑上,内存使用量在 Node 运行后,从 14 MB 增加到 38 MB。重新多次运行这个脚本,内存使用量增加到 110 MB。好吧,休斯顿,我们的确有个问题。

题外话:读者的练习

我们能确认存在一个内存泄露吗?会不会仅仅因为系统还有很多空闲的内存,所以垃圾收集器就不再进行收集?

我们可以通过如下步骤强制运行垃圾收集来加以确认:

  1. 使用 –expose-gc 标记运行服务器:node –expose-gc server.js 这让 JS 代码中 gc() 函数可用,可以强制进行收集。
  2. 用 process.on 创建一个 SIGUSR2 的处理程序,process.on会调用gc()。
  3. 重新运行负载测试,通过下面命令让进程运行垃圾收集 kill -SIGUSR2 $(pgrep -lfa node | grep server.js | awk ‘{print $1}’),看会有什么不一样。

堆分析

heapdump 模块让我们对内存中的对象进行快照。接着我们可以使用 Chrome 开发工具来仔细查看它们以便找到内存泄露的对象类型,这样将帮助我们查明应用程序中有问题的代码。

我们采取如下步骤:

  1. 在程序启动后使用 heapdump —— 这作为我们的基准快照
  2. 运行一个负载测试程序来引起内存的增加
  3. 再一次进行堆快照。这个快照和基准快照的差别就是那些被挂起的对象不能被 GC 回收再利用。

用 heapdump 进行快照

首先,我们用 npm install heapdump 安装 heapdump,server.js 里会使用到它(或者在应用程序的 index.js 里)。

接着我们可以发送 SIGUSR2 给 Node 进程,这样将会把一个 heap 快照写到进程的工作路径下(一个名字类似 heapdump-706203888.138768.heapsnapshot的文件)

在这个练习中,我有三个快照:(1)服务器刚启动的;(2)负载测试过程中的;(3)负载测试结束后的。

说明

某些版本的 Node 或 Io.js,和 Chrome 有一些已知的兼容性问题,开发工具不能正确计算保留的大小和完整显示保留树。我使用的是 Node 0.12.7 和 Chrome 42,如果你偶尔遇到类似问题你可以升级 Node 或者使用 nvm。

另一个可能需要注意的问题是,当进行快照时,你系统中的可用 RAM 需要有 2 个 heap 大小,否则你将看到空的 heap 快照文件或者 内存耗尽的消息(OOM,Out of Memory)。

使用 Chrome 开发工具

一旦我们有了快照,就可以使用开发工具进行分析。

Chrome DevTools memory profile

一旦加载上,我们就可以观察堆。

像保留数量这些不同的术语,可以参考如下内容:

heap snapshot in Chrome DevTools

我们想使用比较视图来进一步定位内存使用量增加的原因。

heap snapshot difference

这个视图告诉我们和刚开始的快照相比,我们有 813 个新的 smalloc 类型对象,它们一共占用了 12.4 MB。

我们对于“保留数量”和“# 新的” 这两列很感兴趣 —— 这个例子中我选择关注 smalloc,因为这些对象和内存增长有关。

object's retaining tree

深入研究这些对象的其中一个,我们可以推出 cleanup() 函数是用来监听 SIGINT 事件的,涉及到一个叫做 clients 的数组,它是保存WebSocket 连接的。SIGINT (和 SIGTERM)是让进程退出的信号,是由进程的管理者发出的,类似 Upstart 或者 init(或者在终端按下 Ctrl+C)。这个例子里,cleanup() 函数在进程退出前断开所有连接的 WebSocket 客户端。我们猜测在进行堆快照时,服务器有 813 个活动的 WebSocket 连接。

负载测试结束后,看看堆是什么样子的:

heap snapshot difference after load-test

糟了,看上去不妙。WebSocket 连接的引用数目已经增加到1649个,这只有在仍有客户端连接到服务器的时候才算合理,但是因为(a)在我们快照前,负载测试已经结束;(b)在快照被写入前,GC 已经执行了,我们知道在代码中(我们可以开始查看了)有些断开连接的引用没有被去除。

修正这个问题就作为一个练习留给读者。:)

备注:如果Node.js 性能是一个深入你内心的主题,你可能会感兴趣知道 Igor Soarez 和 我碰巧正在写一本这方面的书。请前往 nodeperformance.com 登记预览(暂定在这个秋天)。

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

1 2 收藏 评论

关于作者:至秦

Linux,Networking 个人主页 · 我的文章 · 53 ·  

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部