内存泄漏是线上服务的隐形杀手:内存缓慢爬升,最终 OOM 被反复重启,用户偶发掉线,事故却查不到原因。本文从 V8 的分代 GC 讲起,用堆快照对比定位泄漏对象,梳理闭包、全局缓存、事件监听器、定时器四大泄漏模式,最后给出 Chrome DevTools 与线上兜底方案。
1. 内存泄漏现象与影响
1.1 典型症状
内存曲线 ↘ 平稳 → ↗ 线性爬升 → 触顶 OOM
现象 RSS 只增不减,GC 越来越频繁,CPU 升高
后果 k8s/PM2 反复重启,慢请求与 5xx 增多
用 free -m 或 pm2 monit 观察:如果内存每次峰值都比上次高、回落不彻底,基本可以判定泄漏。
1.2 泄漏 vs 内存增长
| 类型 | 特征 | 处置 |
|---|---|---|
| 正常缓存 | 有上限,到顶后持平 | 无需处理 |
| 泄漏 | 无上限持续增长 | 必须定位修复 |
| 一次性峰值 | 回落后平稳 | 无需处理 |
1.3 泄漏的三个后果
1. GC 压力大 → 事件循环停顿 → 请求变慢
2. RSS 撑爆 → OOM Kill → 服务重启 → 用户掉线
3. 内存碎片 → 触顶提前 → 雪崩
一句话:内存泄漏 = RSS 无上限持续爬升;它不只是内存问题,更是性能问题——GC 停顿让全站变慢,OOM 让服务反复重启。
2. V8 GC 与堆结构
2.1 分代收集
V8 把堆分成新生代与老生代,按对象存活时间分层:
新生代(年轻) → 分配快、GC 频繁(Scavenger)
老生代(久驻) → 晋升后存活期长,GC 稀少但耗时长
大对象区 → 超过某尺寸直接进老生代
2.2 晋升机制
对象在新生代经历多次 GC 仍存活,会被晋升到老生代。泄漏对象的共同特征:从新生代一路晋升到老生代,且永远不释放。
2.3 查看堆状态
node --expose-gc -e "
global.gc();
const used = process.memoryUsage();
console.log({ heapUsed: used.heapUsed, external: used.external });
"
heapUsed 是 JS 对象占用,external 是 Buffer/原生内存,两个都要盯。
一句话:V8 用分代 GC:新生代频繁回收、老生代少而重;泄漏对象常被晋升到老生代后永不释放,诊断重点就在老生代。
3. 堆快照对比
3.1 生成堆快照
import { writeHeapSnapshot } from 'node:v8';
// 定时或触发式生成快照
const snapshotPath = writeHeapSnapshot();
console.log(`快照已生成:${snapshotPath}`);
运行时连 --inspect 也能在 DevTools 里直接取快照。
3.2 对比两张快照
同一场景、不同时间点各抓一张,用 --compare 模式:
node --compare-heapsnapshot heapA.heapsnapshot heapB.heapsnapshot
关键看两处差异:
对象数量 增长 → 什么对象越积越多
保留大小 增长 → 什么对象占据最多内存
3.3 定位步骤
1. 抓快照 A(服务刚启动,基线)
2. 压测/跑真实流量一段时间
3. 抓快照 B
4. 对比:找数量与 Retained Size 都增长的构造函数
5. 沿 Retaining Path 找到持有者,修复引用
一句话:堆快照对比 = 基线快照 A + 跑量后快照 B → 找「数量与保留大小同步增长」的构造函数 → 沿引用链找到持有者,这是定位泄漏最可靠的手段。
4. 泄漏模式
4.1 闭包长期持有
回调闭包引用着大对象,且闭包被长期持有:
const cache = new Map();
function trackUser(id) {
const heavy = loadBigProfile(id); // 大对象
cache.set(id, () => {
return heavy.name; // 闭包一直引用 heavy,永不释放
});
}
对策:闭包内只保留必要字段,或显式置空不再需要的引用。
4.2 全局缓存无限膨胀
用 Map/对象当缓存却不设上限,是头号泄漏来源:
const memo = new Map();
function memoize(key, fn) {
if (!memo.has(key)) memo.set(key, fn());
return memo.get(key);
}
对策:给缓存设上限或 TTL,见 nodejs-caching-strategies;键需可枚举上限而非无限增长。
4.3 事件监听器堆积
每次 on 都新增监听,从不移除:
function handleOrder(order) {
eventBus.on('payment', () => processPayment(order)); // 泄漏:监听器越积越多
}
对策:emitter.setMaxListeners(0) 关闭告警只是掩盖;用 { once: true } 或成对 off:
eventBus.once('payment', () => processPayment(order)); // 一次性监听
// 或不再需要时 eventBus.off('payment', handler)
4.4 定时器未清理
setInterval 未 clear,回调里的引用全部滞留:
setInterval(() => {
const data = getData();
buffer.push(data); // buffer 无上限,定时器一直跑
}, 1000);
对策:记录句柄,退出时 clearInterval;buffer 设上限与消费逻辑。
一句话:四大泄漏模式 = 闭包长持 + 无限缓存 + 监听器堆积 + 定时器未清;共性是「引用该释放却没释放」,修复本质是补全释放路径。
5. trace-gc 与 GC 日志
5.1 开启 GC 追踪
node --trace-gc app.js
输出形如:
Scavenge 2167.6 -> 2168.7 MB, 1.8 ms
Mark-sweep 2199.1 -> 2211.3 MB, 82.4 ms
before -> after 是 GC 前后的堆大小。
5.2 解读 GC 日志
1. Mark-sweep 后堆大小每次都比上次大 → 泄漏在增长
2. GC 频率越来越密、单次时间越来越长 → 堆压力大
3. Scavenge 次数激增 → 短命对象分配过多
node --trace-gc --trace-gc-ignore-scavenger app.js > gc.log 2>&1
# 只看老生代 GC,过滤噪音
5.3 格式化分析
把 GC 日志喂给简单脚本统计趋势:
// 伪代码:提取 Mark-sweep 后的堆大小,画趋势
const lines = fs.readFileSync('gc.log', 'utf8').split('\n');
for (const line of lines) {
const m = line.match(/Mark-sweep (\d+\.\d+) -> (\d+\.\d+) MB/);
if (m) console.log(m[1], m[2]); // 观察 after 值是否单调上升
}
一句话:
--trace-gc输出每次 GC 的堆前后大小;老生代 GC 后堆仍越涨越高,就是泄漏的最直接证据。
6. Chrome DevTools 分析
6.1 连接运行时
node --inspect app.js
浏览器打开 chrome://inspect,点击对应进程进入 DevTools。
6.2 Memory 面板
Heap snapshot → 抓快照,看构造函数与 Retained Size
Allocation → 记录分配,看函数级别的内存增长
Sampling → 采样分析,开销低适合线上
6.3 顺着 Retaining Path 找根
堆快照里选中疑似泄漏构造函数,看右侧 Retainers:
Array (buffer)
└─ closure (setInterval 回调)
└─ global
从对象一路往根走,找谁在持有它——通常是某个全局变量、某个事件总线、某个闭包。
一句话:DevTools =
--inspect连进进程 → Memory 面板抓快照 → 按 Retained Size 排序 → 沿 Retainers 引用链找到根持有者。
7. 线上兜底与防回归
7.1 定时堆快照
线上不能随抓随停,用 --heapsnapshot-signal 优雅触发:
node --heapsnapshot-signal=SIGUSR2 app.js
# kill -USR2 <pid> 触发堆快照落盘
也可以周期性自动 dump + 只保留最近 N 份,防止磁盘打满。
7.2 进程级兜底
// 接近内存上限时,主动 dump 并退出让守护拉起
const MAX_HEAP = 400 * 1024 * 1024;
setInterval(() => {
if (process.memoryUsage().heapUsed > MAX_HEAP) {
writeHeapSnapshot('/tmp/leak-dump.heapsnapshot');
process.exit(1); // PM2/k8s 自动重启
}
}, 30 * 1000);
7.3 回归测试防复发
// 压测场景:跑 1 万次典型操作,对比 GC 后堆大小
test('内存无泄漏', async () => {
global.gc();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 10000; i++) await handleOrder(order(i));
global.gc();
const after = process.memoryUsage().heapUsed;
expect(after - before).toBeLessThan(5 * 1024 * 1024);
});
配合 --expose-gc 运行,把内存回归测试写进 CI。
一句话:线上兜底 = SIGUSR2 触发堆快照 + 超内存主动 dump 退出 + 守护拉起;修复后写「跑 N 次典型操作、GC 后堆增长小于阈值」的回归测试进 CI。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 只看 heapUsed | external 泄漏漏掉 | 同时盯 external/RSS |
| 缓存无上限 | 内存线性爬升 | 设上限与 TTL |
| 监听器不 off | 监听器越积越多 | once 或成对 off |
| 定时器不 clear | 回调引用滞留 | 记录句柄并清理 |
| 快照只抓一张 | 无从对比 | 基线 A + 跑量后 B 对比 |
| 线上随抓随停 | 影响线上请求 | –heapsnapshot-signal |
| 泄漏修复后无验证 | 复发难防 | 内存回归测试进 CI |
| 掩盖最大监听数告警 | 泄漏被隐藏 | 排查真因而非 setMaxListeners |
9. 总结
| 环节 | 要点 |
|---|---|
| 症状 | RSS 无上限爬升,GC 频繁 |
| GC | 分代收集,泄漏晋升老生代 |
| 快照 | 两张快照对比找增长对象 |
| 模式 | 闭包 / 缓存 / 监听器 / 定时器 |
| trace-gc | 老生代 GC 后堆仍涨即泄漏 |
| DevTools | Retainers 找根持有者 |
| 兜底 | 信号触发 dump + 主动退出 |
| 防回归 | 跑量后 GC 堆增长测试 |
一句话记住:内存泄漏诊断是「对比 + 追根」的工程——两张堆快照对比定位增长对象,沿引用链追到根持有者,四大泄漏模式对号入座;修复后写内存回归测试,让泄漏在 CI 里现形,而不是等到线上 OOM。
延伸阅读
- Node.js 性能调优 — 内存之外的性能瓶颈定位
- Node.js 缓存策略 — 有界缓存避免无限膨胀
- Node.js 异步与并发 — 回调与引用的生命周期
- Node.js 可观测性 — 内存指标监控与告警
- Node.js Docker 与 Kubernetes — 容器内存限制与重启兜底
- Node.js 错误处理与日志 — OOM 前的预警日志
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。