虽然修复内存泄漏不是程序猿上最值得关注的技能,但当生产中出现问题时确非常有用,最好有所准备!

阅读本文后,您将能够监控、理解和调试Node.js应用程序的内存消耗。

当内存泄漏成为问题时

内存泄漏通常不会被注意到。当有人特别关注生产性能指标时,它们就会成为问题。

在生产应用程序中,内存泄漏的第一个症状是主机的内存、CPU使用率和平均负载随着时间的推移而增加,而没有任何明显的原因。

不知不觉中,响应时间变得越来越长,直到CPU使用率达到100%时,应用程序停止响应。当内存已满,并且没有足够的交换空间时,服务器甚至可能无法接受SSH连接。

但是当应用程序重新启动时,所有的问题都神奇地消失了!没有人知道发生了什么,所以他们转向其他优先事项,但问题周期性地重复出现。

内存泄漏并不总是那么明显,但是当这种模式出现时,就应该寻找内存使用和响应时间之间的相关性。

权宜之计:重启系统

发现和修复Node.js中的内存泄漏需要时间——通常是一天或更长时间。如果在不久的将来,您的待办事项安排中没有足够的时间来调查泄漏,我建议您寻找一个临时解决方案,然后再处理根本原因。(短期内)延迟问题的合理方法是在应用程序达到临界膨胀之前重新启动应用程序。选项可用于在节点进程达到一定内存量时自动重启节点进程。

但这只是权宜之计,最终还需要我们深入研究一下,来找到这些内存消耗的原因。

创建一个有效的测试环境

在测量任何东西之前,请花时间设置一个适当的测试环境。它可以是虚拟机,也可以是云主机,主机配置最好与生产环境完全相同。与在生产环境中运行时完全相同的方式构建、优化和配置代码,以便以相同的方式重现泄漏。理想情况下,最好使用相同的部署构件,这样您就可以确定生产环境和新的测试环境之间没有区别。

适当配置的测试环境是不够的:它也应该运行与产品相同的负载。为此,您可以随意获取生产日志,并将相同的请求发送到测试环境。在调试过程中,我发现了一个HTTP/FTP负载测试器和基准测试工具,在测量高负载下的内存时非常有用。

此外,如果没有必要,请抵制启用开发人员工具或详细日志记录器的冲动,否则您将最终花更多时间在这些开发工具上!

使用V8 Inspector和Chrome Dev Tools访问Node.js内存

我喜欢试用Chrome开发工具。 F12 是我在 Ctrl+C 和 Ctrl+V 之后输入最多的键(因为我主要做堆栈溢出驱动的开发-只是开玩笑)。你知道你可以使用相同的开发工具来检查Node.js应用程序吗? Node.js和Chrome运行相同的引擎 Chrome V8 ,其中包含Dev Tools使用的检查器。

出于演示目的,假设我们有一个最简单的HTTP服务器,其唯一目的是显示它收到的所有请求:

const http = require("http");

const requestLogs = [];
const server = http.createServer((req, res) => {
  requestLogs.push({ url: req.url, date: new Date() });
  res.end(JSON.stringify(requestLogs));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

为了开放检查器,让我们带着 --inspect 标志运行Node.js。这个默认是允许本机调试。

$node --inspect index.js
Debugger listening on ws://127.0.0.1:9229/655aa7fe-a557-457c-9204-fb9abfe26b0f
For help see https://nodejs.org/en/docs/inspector
Server listening to port 3000. Press Ctrl+C to stop it.
  • 1.
  • 2.
  • 3.
  • 4.

如果要远程调试nodejs,以上默认语句是不行的,Chrome连接不上。应使用下面语句:

node --inspect=0.0.0.0:9229 index.js
  • 1.

现在,运行Chrome(或Chromium),并转到以下URI:  chrome://inspect 。这是一个Node.js应用程序的全功能调试器。

查找和修复Node.js内存泄漏:实用指南_内存泄漏

获取V8内存快照

让我们玩一下内存选项卡。最简单的可用选项是获取堆快照。它完成了您所期望的工作:它为被检查的应用程序创建堆内存转储,其中包含有关内存使用情况的大量详细信息。

内存快照对于跟踪内存泄漏非常有用。一种常用的技术包括比较不同关键点的多个快照,以查看内存大小是否增长、何时增长以及如何增长。

例如,我们将拍摄三个快照:一个在服务器启动之后,一个在加载30秒之后,最后一个在另一个加载会话之后。

为了模拟负载,我将使用上面介绍的 siege 实用程序:

$timeout 30s siege http://localhost:3000

** SIEGE 4.0.2
** Preparing 25 concurrent users for battle.
The server is now under siege...
Lifting the server siege...
Transactions:               2682 hits
Availability:             100.00 %
Elapsed time:              30.00 secs
Data transferred:         192.18 MB
Response time:              0.01 secs
Transaction rate:          89.40 trans/sec
Throughput:             6.41 MB/sec
Concurrency:                0.71
Successful transactions:        2682
Failed transactions:               0
Longest transaction:            0.03
Shortest transaction:           0.00
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

以下是我的模拟结果:

查找和修复Node.js内存泄漏:实用指南_内存泄漏_02

这里面有很多东西要看!

在第一个快照上,在处理任何请求之前已经分配了5MB的空间。这完全是意料之中的:每个变量或导入的模块都被注入到内存中。例如,分析第一个快照可以优化服务器启动——但这不是我们当前的任务。

在这里,我感兴趣的是了解服务器内存在使用时是否会随着时间的推移而增长。如您所见,第三个快照有6.7MB,而第二个快照有6.2MB:在这段时间间隔内,已经分配了一些内存。但是哪个函数呢?

我可以通过点击最新的快照(1),改变对比模式(2),选择要对比的快照(3)来比较分配对象的差异。这就是当前图像的状态。

在两个加载会话之间分配了2,682个 Date 对象和2,682个 Objects 对象。不出所料,siege向服务器发出了2,682个请求:这是一个巨大的指标,表明我们每个请求都有一个分配。但是所有的“泄漏”都不是那么明显,所以检查器会告诉你它是在哪里分配的:在系统上下文中的 requestLogs 变量中(它是应用程序的根作用域)。

提示:V8为新对象分配内存是正常的。JavaScript是一个垃圾收集的运行时,所以V8引擎会定期释放内存。不正常的情况是,它在几秒钟后没有收集分配的内存。

Watching Memory Allocation In Real Time实时观察内存分配

测量内存分配的另一种方法是实时查看内存分配情况,而不是拍摄多个快照。要做到这一点,点击记录分配时间线,而请求模拟正在进行中。

对于下面的例子,我在5秒后和10秒内开始增加请求。

查找和修复Node.js内存泄漏:实用指南_nodejs_03

对于第一个请求,您可以看到一个明显的分配高峰。它与HTTP模块初始化有关。但是,如果您放大更常见的分配(如上图),您会再次注意到,占用内存最多的是日期和对象。

使用堆转储Npm包

获取堆快照的另一种方法是使用 heapdump 模块。它的用法非常简单:导入模块后,您可以调用 writeSnapshot 方法,或者向Node进程发送SIGUSR2信号。

只需更新应用程序:

const http = require("http");
const heapdump = require("heapdump");

const requestLogs = [];
const server = http.createServer((req, res) => {
  if (req.url === "/heapdump") {
    heapdump.writeSnapshot((err, filename) => {
      console.log("Heap dump written to", filename);
    });
  }
  requestLogs.push({ url: req.url, date: new Date() });
  res.end(JSON.stringify(requestLogs));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");
console.log(
  `Heapdump enabled. Run "kill -USR2 ${
    process.pid
  }" or send a request to "/heapdump" to generate a heapdump.`
);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

并触发转储:

$node index.js
Server listening to port 3000. Press Ctrl+C to stop it.
Heapdump enabled. Run "kill -USR2 29431" or send a request to "/heapdump" to generate a heapdump.

$ kill -USR2 29431
$ curl http://localhost:3000/heapdump
$ ls
heapdump-31208326.300922.heapsnapshot
heapdump-31216569.978846.heapsnapshot
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

您会注意到运行 kill -USR2 实际上并不会终止进程。 kill 命令,尽管它的名字很吓人,但它只是一个向进程发送信号的工具,默认情况下是 SIGTERM 。对于参数 -USR2 ,我选择发送一个 SIGUSR2 信号,这是一个用户定义的信号。

最后,您可以使用signal方法在生产实例上生成堆转储。但是您需要知道,创建堆快照需要的堆大小是快照时的两倍。

一旦快照是可用的,你可以读取它与Chrome DevTools。只需打开内存选项卡,右键单击侧面并选择加载。

查找和修复Node.js内存泄漏:实用指南_nodejs_04

如果您需要像本文作者Kevin这样的专业全栈JS开发人员来完成您的项目,请联系我们。Marmelab是一家位于法国的小型机构,曾经致力于开发具有许多功能和技术挑战的网页和移动应用。

修复泄漏

既然我已经确定了是什么增加了内存堆,那么我必须找到一个解决方案。对于我的例子,解决方案是不将日志存储在内存中,而是存储在文件系统中。在实际项目中,最好将日志存储委托给其他服务(如syslog),或者使用合适的存储(如数据库、Redis实例或其他)。

这是修改后的web服务器没有更多的内存泄漏:

// Not the best implementation. Do not try this at home.
const fs = require("fs");
const http = require("http");

const filename = "./requests.json";

const readRequests = () => {
  try {
    return fs.readFileSync(filename);
  } catch (e) {
    return "[]";
  }
};

const writeRequest = req => {
  const requests = JSON.parse(readRequests());
  requests.push({ url: req.url, date: new Date() });
  fs.writeFileSync(filename, JSON.stringify(requests));
};

const server = http.createServer((req, res) => {
  writeRequest(req);
  res.end(readRequests());
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

现在,让我们运行与之前相同的测试场景,并度量结果:

$timeout 30s siege http://localhost:3000

** SIEGE 4.0.2
** Preparing 25 concurrent users for battle.
The server is now under siege...
Lifting the server siege...
Transactions:               1931 hits
Availability:             100.00 %
Elapsed time:              30.00 secs
Data transferred:        1065.68 MB
Response time:              0.14 secs
Transaction rate:          64.37 trans/sec
Throughput:            35.52 MB/sec
Concurrency:                9.10
Successful transactions:        1931
Failed transactions:               0
Longest transaction:            0.38
Shortest transaction:           0.01
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

查找和修复Node.js内存泄漏:实用指南_nodejs_05

正如您所看到的,内存增长要慢得多!这是因为我们不再将每个请求的请求日志存储在内存中(在 requestLogs 变量中)。

也就是说,API需要更多的时间来响应:我每秒有89.40个事务,现在我们有64.37个。 读取和写入磁盘是有成本的,其他API调用或数据库请求也是如此。

注意,在潜在修复之前和之后测量内存消耗非常重要,以便确认(并证明)内存问题已经修复。

结论

实际上,一旦内存泄漏被识别出来,修复它是比较容易的:使用已知的和经过测试的库,不要长时间复制或存储重对象,等等。

最难的部分是找到他们。幸运的是,尽管bug很少,当前的Node.js工具都很简洁。现在你知道如何使用它们了!