Node

Node.js 内存泄漏排查实战:从症状到根因的完整方法论

✎ -- 字 🕐 -- 分钟
字号

在生产环境中,Node.js 进程内存持续增长却不释放——这是每个 Node.js 开发者迟早都会遭遇的噩梦。本文结合真实案例,完整演示从发现问题、采集证据,到定位根因、验证修复的全套排查流程。

一、什么是内存泄漏?

内存泄漏(Memory Leak)是指程序申请的内存在不再需要时未被释放,导致可用内存持续减少。在 Node.js 中,V8 引擎有垃圾回收机制,理论上会自动回收不再被引用的对象——但以下几类情况会让 GC 无从下手:

  • 意外的全局变量:未用 var/let/const 声明的变量会挂到 global 对象上
  • 遗忘的定时器:setInterval 持有回调引用,闭包里的大对象永远不会被回收
  • 闭包引用:闭包持有外层作用域变量,若外层对象很大就会一直存活
  • EventEmitter 监听器堆积:每次请求都 addListener 但从不 removeListener
  • 缓存无上限增长:Map/Object 作为缓存使用,却没有淘汰机制

二、复现一个真实的泄漏场景

先构造一个包含内存泄漏的 Express 服务:

// leak-demo.js
const express = require('express');
const app = express();

// 泄漏点1:无上限缓存
const cache = new Map();

// 泄漏点2:EventEmitter 监听器堆积
const EventEmitter = require('events');
const emitter = new EventEmitter();
emitter.setMaxListeners(0);

app.get('/leak', (req, res) => {
  const key = Date.now();
  cache.set(key, Buffer.alloc(1024 * 1024, 'x'));  // 每次塞 1MB
  emitter.on('data', () => console.log('data'));    // 监听器堆积
  res.json({ cacheSize: cache.size });
});

app.listen(3000);
npm install -g autocannon
autocannon -c 10 -d 30 http://localhost:3000/leak
# 30 秒后 RSS 飙到几百 MB

三、工具链全景

🔭
监控层

process.memoryUsage()、PM2 内置监控、clinic.js —— 发现内存趋势异常

🩺
诊断层

--inspect + Chrome DevTools、heapdump 快照对比 —— 定位泄漏对象类型和数量

🔬
分析层

Heap Snapshot diff、Retainer 链分析 —— 找到根引用,定位到具体代码行

四、方法一:process.memoryUsage() 初步判断

function logMemory(label) {
  const mem = process.memoryUsage();
  console.log(`[${label}]`, {
    rss:      `${(mem.rss      / 1024 / 1024).toFixed(1)} MB`,
    heapTotal:`${(mem.heapTotal/ 1024 / 1024).toFixed(1)} MB`,
    heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MB`,
    external: `${(mem.external / 1024 / 1024).toFixed(1)} MB`,
  });
}
setInterval(() => logMemory('heartbeat'), 5000);

heapUsed GC 后仍持续上涨 → 堆泄漏;rss 涨但 heapUsed 不变 → Buffer/Stream 泄漏;两者平稳但变慢 → 排查 CPU/事件循环阻塞。

五、方法二:clinic.js 自动诊断

npm install -g clinic
clinic heap -- node leak-demo.js
# 另开终端压测后 Ctrl+C,自动生成 HTML 报告
autocannon -c 20 -d 30 http://localhost:3000/leak

六、方法三:Chrome DevTools Heap Snapshot(黄金方案)

核心思路:在内存升高前后各拍一个堆快照,对比差量。

Step 1:以 --inspect 模式启动

node --inspect leak-demo.js
# Debugger listening on ws://127.0.0.1:9229/...

Step 2:打开 chrome://inspect

选择 Memory 标签页,拍基准 Snapshot 1。

Step 3:触发泄漏 + 手动 GC + 拍 Snapshot 2

for i in $(seq 1 100); do curl -s http://localhost:3000/leak > /dev/null; done

DevTools Console 执行 gc(),再拍 Snapshot 2。

Step 4:Comparison 模式

Constructor   # New  # Deleted  Size Delta
Buffer        100    0          +104,857,600 B  <-- 泄漏!
(closure)     100    0          +12,800 B       <-- 泄漏!

点击 Buffer,Retainer 面板追溯:Buffer → cache (Map) → module.exports → (root)。直接定位到 cache 这个 Map!

七、方法四:heapdump 生产环境抓取

const heapdump = require('heapdump');
process.on('SIGUSR2', () => {
  const fname = `/tmp/heap-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(fname, (err, f) => {
    if (!err) console.log(`Snapshot: ${f}`);
  });
});
kill -USR2 $(pgrep -f app.js)    # 正常时拍第一张
# 等内存涨起来
kill -USR2 $(pgrep -f app.js)    # 拍第二张
scp root@server:/tmp/heap-*.heapsnapshot ./

八、四大泄漏模式与修复

1. Map/Object 无限缓存

// 修复:LRU 缓存 + TTL
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5 });

2. EventEmitter 监听器堆积

// 修复:用 once 代替 on
emitter.once('result', (data) => res.json(data));

3. setInterval 闭包持有大对象

let count = 0;
const timer = setInterval(() => {
  process(bigData);
  if (++count >= 10) clearInterval(timer);
}, 1000);

4. 未关闭的 Stream

const stream = fs.createReadStream('/path/to/file');
req.on('close', () => stream.destroy());
stream.pipe(res);

九、生产预防体系

// PM2 ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: 'app.js',
    max_memory_restart: '512M',
    env: { NODE_OPTIONS: '--max-old-space-size=512' }
  }]
};

十、排查流程速查

heapUsed 持续增  --> Heap Snapshot 对比 --> 追 Retainer 链
rss增/heap平     --> 检查 Stream/Buffer 是否未关闭
两者平稳但变慢   --> 排查 CPU / 事件循环阻塞

clinic heap(粗定位)--> Chrome DevTools Comparison(精定位)
--> 定位代码 --> 修复 --> 压测验证 --> Grafana 告警

掌握 Heap Snapshot 对比这一核心技能,90% 的 Node.js 内存问题都能在半小时内定位。