在生产环境中,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 内存问题都能在半小时内定位。