首页 / Node.js 教程 / 内存诊断

Node.js 教程

内存诊断

本教程共 76 篇 · 第 68 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.js内存堆快照内存泄漏GC

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 面板,就能看到内存全貌:

  1. Summary 视图:按构造函数分组,看哪类对象占最多内存。
  2. Comparison 视图:对比两个快照,找出增长最多的对象类型。
  3. Containment 视图:从 GC roots 出发,追踪对象的引用链。
  4. Statistics 视图:饼图展示内存按类型的分布。

排查泄漏的标准流程是:

  1. 应用启动后拍一张快照(baseline)。
  2. 执行疑似泄漏的操作若干次。
  3. 再拍一张快照。
  4. 用 Comparison 视图对比两张快照。
  5. 找到增长异常的对象类型,顺着引用链找到是谁在持有它们。

一个内存泄漏的例子

下面这段代码有典型的泄漏:事件监听器没清理。

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._eventsMap 持有。

修复

// 修复 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. 缓存无上限

MapWeakMapLRU 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),内存超过阈值时自动通知,并保留堆快照供线下分析。