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

2025-06-07

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

修复内存泄漏可能不是简历上最耀眼的技能,但当生产出现问题时,最好做好准备!

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

当内存泄漏成为问题时

内存泄漏通常不会引起注意。当人们格外关注生产性能指标时,它们就会成为一个问题。

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

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

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

NewRelic 泄漏完全延迟图

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

NewRelic 泄漏完全延迟图

恭喜!你找到了内存泄漏。现在,好戏开始了。

不用说,我假设你监控你的服务器。否则,我强烈建议你看看New RelicElastic APM或任何监控解决方案。无法衡量的问题就无法解决。

趁还没太迟,赶紧重启

在 Node.js 中查找和修复内存泄漏需要时间——通常需要一天或更长时间。如果您的积压工作无法在近期提供足够的时间来调查泄漏问题,我建议您先寻找一个临时解决方案,稍后再处理根本原因。一个合理的(短期内)延缓问题的方法是在应用程序达到临界膨胀之前重启它。

对于PM2用户,max_memory_restart可以选择在节点进程达到一定内存量时自动重新启动节点进程。

现在我们已经舒服地坐下来,喝上一杯茶,还有几个小时的时间,让我们深入研究一下可以帮助您找到这些占用 RAM 空间的小家伙的工具。

创建有效的测试环境

在测量任何东西之前,请先花点时间搭建一个合适的测试环境。它可以是虚拟机,也可以是 AWS EC2 实例,但需要重复与生产环境完全相同的条件。

为了完美重现泄漏,代码的构建、优化和配置应与生产环境中完全相同。理想情况下,最好使用相同的部署构件,这样就可以确保生产环境和新的测试环境之间没有差异。

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

此外,如果没有必要,请抵制启用开发人员工具或详细记录器的冲动,否则您最终将调试这些开发工具

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

我喜欢 Chrome Dev Tools。是我在F12之后输入最多的键(因为我主要进行 Stack Overflow 驱动开发 - 开个玩笑)。Ctrl+CCtrl+V

你知道吗?你可以使用相同的开发者工具来检查 Node.js 应用程序。Node.js 和 Chrome 运行相同的引擎,Chrome V8其中包含开发者工具使用的检查器。

出于教育目的,假设我们拥有最简单的 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.');
Enter fullscreen mode Exit fullscreen mode

为了公开检查器,让我们使用该--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.
Enter fullscreen mode Exit fullscreen mode

现在,运行 Chrome(或 Chromium),然后转到以下 URI:。chrome://inspect瞧!一个适用于您的 Node.js 应用程序的全功能调试器。

Chrome 开发者工具

拍摄 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
Enter fullscreen mode Exit fullscreen mode

这是我的模拟结果(点击查看完整尺寸):

堆快照比较

有很多东西可以看!

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

我感兴趣的是了解服务器内存在使用过程中是否会随时间增长。如您所见,第三个快照有 6.7MB,而第二个快照有 6.2MB:在此期间,确实分配了一些内存。但究竟是哪个函数分配的呢?

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

两次加载会话之间,恰好分配了 2,682 个Date对象和 2,682 个内存Objects。不出所料,siege 向服务器发出了 2,682 个请求:这说明我们每个请求只分配了一次内存。但并非所有“泄漏”都那么明显,所以检查器会显示内存分配的位置:在requestLogs系统上下文(应用的根作用域)中的变量中。

提示:V8 为新对象分配内存是正常现象。JavaScript 是一个支持垃圾回收的运行时,因此 V8 引擎会定期释放内存。不正常的是,它几秒后仍未回收分配的内存。

实时观察内存分配

测量内存分配的另一种方法是实时查看,而不是拍摄多个快照。为此,请在围攻模拟进行时点击“记录分配时间线” 。

对于以下示例,我在 5 秒后开始围攻,并在 10 秒内开始围攻。

堆分配时间线

对于第一个请求,您可以看到明显的分配峰值。这与 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.`);
Enter fullscreen mode Exit fullscreen mode

并触发转储:

$ 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
Enter fullscreen mode Exit fullscreen mode

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

最后的办法是,您可以使用信号方法在生产实例上生成堆转储。但需要注意的是,创建堆快照需要的内存大小是快照创建时堆大小的两倍。

快照可用后,您可以使用 Chrome DevTools 读取它。只需打开“内存”选项卡,右键单击侧面并选择“加载”即可。

将堆快照加载到 Chrome 检查器中

修复泄漏

既然我已经确定了导致内存堆增长的原因,就必须找到解决方案。就我的例子而言,解决方案是将日志存储在文件系统中,而不是内存中。在实际项目中,最好将日志存储委托给其他服务(例如 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.');
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

固定内存使用量

如你所见,内存增长速度明显变慢了!这是因为我们不再将每个请求的请求日志存储在内存中(变量内部requestLogs)。

话虽如此,API 的响应时间更长:我之前每秒处理 89.40 笔交易,现在只有 64.37 笔。
磁盘的读写操作是有成本的,其他 API 调用或数据库请求也是如此。

请注意,测量潜在修复前后的内存消耗非常重要,以确认(并证明)内存问题已得到修复。

结论

实际上,一旦发现内存泄漏,修复它就比较容易:使用知名且经过测试的库,不要长时间复制或存储重物,等等。

最难的部分是找到它们。幸运的是,尽管有一些 bug,但当前的 Node.js 工具还是很简洁的。现在你知道怎么用它们了!

为了使本文简短易懂,我没有提到其他一些工具,例如memwatchllnode模块(简单)或使用或进行 Core Dump 分析mdb(高级),但我为您提供了有关它们的更详细的阅读材料:

进一步阅读:

文章来源:https://dev.to/kmaschta/finding-and-fixing-nodejs-memory-leaks-a-practical-guide-3f5a
PREV
Mint 🌿 用于编写单页应用程序(SPA)的编程语言
NEXT
全栈无服务器