内存诊断
本教程共 76 篇 · 第 68 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
68. 内存诊断
本节目标:堆快照、内存泄漏排查和 GC 机制。
内存泄漏是 Node.js 生产环境最难缠的问题之一。它不像崩溃那样立刻暴露,而是像慢性病一样让服务越来越慢,直到被 OOM Killer 终结。这一章教你用 Node.js 内置工具和 Chrome DevTools 排查内存问题。
Node.js 的内存结构
先搞清楚 V8 里内存长什么样。process.memoryUsage() 能实时查看:
setInterval(() => {
const mem = process.memoryUsage();
console.log({
rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`, // 进程占用的总物理内存
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MB`, // V8 堆总大小
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MB`, // 已用堆内存
external: `${(mem.external / 1024 / 1024).toFixed(1)} MB`, // C++ 对象占用的内存(如 Buffer)
arrayBuffers: `${(mem.arrayBuffers / 1024 / 1024).toFixed(1)} MB`, // ArrayBuffer 内存
});
}, 5000);
重点关注 heapUsed。如果它持续增长,永远不回落,说明有对象没被垃圾回收(GC),大概率是内存泄漏。
Tip
rss包含堆、栈、代码段、C++ 对象等所有内存。如果rss涨但heapUsed不涨,泄漏可能发生在 Native 层,比如没关闭的文件描述符或 C++ 扩展里的内存。
堆快照:给内存拍 X 光
V8 提供了生成堆快照(Heap Snapshot)的能力,它能记录某一时刻堆上所有对象的类型、大小和引用关系。Node.js 通过 v8.writeHeapSnapshot() 暴露了这个功能(自 v11.13+ 起内置,v24 已稳定)。
生成堆快照
import v8 from 'node:v8';
import fs from 'node:fs';
// 最简单的用法
const snapshotPath = v8.writeHeapSnapshot();
console.log(`堆快照已保存: ${snapshotPath}`);
跑一下你的应用,在关键时刻调用 writeHeapSnapshot(),就会生成一个 .heapsnapshot 文件。
更实用的场景是定时采样:
import v8 from 'node:v8';
let snapshotCount = 0;
setInterval(() => {
const filename = `heap-${++snapshotCount}-${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(filename);
console.log(`生成堆快照: ${filename}`);
}, 60000); // 每分钟拍一张
用 Chrome DevTools 分析
把 .heapsnapshot 文件拖到 Chrome DevTools 的 Memory 面板,就能看到内存全貌:
- Summary 视图:按构造函数分组,看哪类对象占最多内存。
- Comparison 视图:对比两个快照,找出增长最多的对象类型。
- Containment 视图:从 GC roots 出发,追踪对象的引用链。
- Statistics 视图:饼图展示内存按类型的分布。
排查泄漏的标准流程是:
- 应用启动后拍一张快照(baseline)。
- 执行疑似泄漏的操作若干次。
- 再拍一张快照。
- 用 Comparison 视图对比两张快照。
- 找到增长异常的对象类型,顺着引用链找到是谁在持有它们。
一个内存泄漏的例子
下面这段代码有典型的泄漏:事件监听器没清理。
import http from 'node:http';
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
const cache = new Map();
const server = http.createServer((req, res) => {
const id = Math.random().toString(36).slice(2);
// 泄漏 1:每次请求都加一个监听器,永远不删
emitter.on('data', (data) => {
console.log('收到数据', data);
});
// 泄漏 2:Map 无限增长,没有淘汰策略
cache.set(id, {
data: Buffer.alloc(1024 * 1024), // 1MB
time: Date.now(),
});
res.writeHead(200);
res.end('ok\n');
});
server.listen(3000);
每来一个请求:
emitter.on('data', ...)增加一个监听器,EventEmitter 内部数组不断增长。cache.set()增加一个 1MB 的 Buffer,Map 无限膨胀。
用 ab 压测几百个请求后,拍两张堆快照对比。在 DevTools 里你会看到:
Array数量暴涨(EventEmitter 内部的_events[data]数组)。Buffer/Uint8Array数量暴涨(cache 里的数据)。- 引用链显示这些对象被
EventEmitter._events和Map持有。
修复
// 修复 1:用 once 替代 on,或用 removeListener 清理
emitter.once('data', handler);
// 修复 2:给缓存加 TTL 和容量上限
const MAX_CACHE_SIZE = 100;
if (cache.size >= MAX_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(id, { data, time: Date.now() });
// 定期清理过期项
setInterval(() => {
const now = Date.now();
for (const [key, val] of cache) {
if (now - val.time > 60000) cache.delete(key);
}
}, 30000);
强制 GC:让问题暴露得更快
V8 的垃圾回收是自动的,但调试时你可能想手动触发,方便观察内存是否回落:
# 启动时加上 --expose-gc
node --expose-gc app.mjs
// 代码里调用
global.gc();
console.log('GC 后:', process.memoryUsage().heapUsed);
如果调用 global.gc() 后 heapUsed 仍然居高不下,说明那些对象确实被活着的引用链 holding 住了,不是 GC 没跑,而是真的泄漏了。
Warning
--expose-gc只在开发和调试时用,生产环境绝对不要开。强制 GC 会触发全堆回收(Full GC),造成明显的停顿(Stop-The-World)。
用 --inspect 实时分析内存
除了生成堆快照文件,你还可以直接连 Chrome DevTools 做实时分析:
node --inspect app.mjs
# 或者启动时立即断点
node --inspect-brk app.mjs
Chrome 打开 chrome://inspect,找到你的 Node.js 进程,点击 “inspect”。在 Memory 面板可以:
- Take heap snapshot:实时拍快照。
- Record allocation timeline:持续记录内存分配,看哪些代码在频繁分配对象。
- Record allocation profile:按函数统计内存分配量。
“Allocation timeline” 特别适合抓间歇性泄漏:绿色条表示还存活的对象,如果某个时间点之后绿色条持续累积不下降,那对应代码就是泄漏源。
常见内存泄漏模式
根据我踩过的坑,Node.js 里最高频的泄漏模式有这些:
1. 全局变量
function handleRequest(req, res) {
leakyArray = leakyArray || []; // 没声明 var/let/const,变成 global.leakyArray
leakyArray.push(req.body);
}
2. 闭包引用
function createHandler() {
const bigData = Buffer.alloc(100 * 1024 * 1024);
return () => {
console.log('handler called'); // 没用到 bigData,但闭包仍然引用着它
};
}
即使回调函数没访问 bigData,只要闭包还存活,V8 保守地不会释放它。
3. 事件监听器没清
const socket = new WebSocket(url);
socket.on('message', handler);
// 连接断开后没 socket.removeListener('message', handler)
// 如果 socket 对象本身被某个 Map 持有,handler 连带它闭包里的数据都泄漏
4. 定时器没清
setInterval(() => {
fetchData();
}, 1000);
// 如果这段代码在组件/请求级别执行,每次创建一个新的 interval
// 要用 clearInterval 清理,或者换用可取消的 AbortController
5. 缓存无上限
Map、WeakMap、LRU Cache,如果不设容量上限和淘汰策略,本质上是合法的内存泄漏。
监控内存:在生产环境提前发现
等用户报 “服务卡了” 再排查就太晚了。生产环境应该持续监控内存指标:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const maxRssMB = 2048;
const maxHeapMB = 1024;
setInterval(() => {
const mem = process.memoryUsage();
const rssMB = mem.rss / 1024 / 1024;
const heapMB = mem.heapUsed / 1024 / 1024;
if (rssMB > maxRssMB || heapMB > maxHeapMB) {
console.error(`内存告警 RSS=${rssMB.toFixed(0)}MB Heap=${heapMB.toFixed(0)}MB`);
// 生成堆快照供事后分析
const path = require('node:v8').writeHeapSnapshot(`alert-${Date.now()}.heapsnapshot`);
console.error(`已生成堆快照: ${path}`);
}
}, 60000);
结合告警系统(如 Prometheus + Alertmanager),内存超过阈值时自动通知,并保留堆快照供线下分析。