外观
48 小时必 OOM:Node.js heap snapshot 内存泄漏排查实录
问题背景:服务为什么总是 48 小时后 OOM
最近一个基于 Node.js 的前端文档构建与预览服务在线上稳定复现一个问题:进程刚启动时 RSS 约 120MB,运行十几个小时后缓慢涨到 500MB,大约 48 小时触发 JavaScript heap out of memory。容器被守护进程重启后,一切又恢复正常。
这类问题最麻烦的地方在于:它不是接口立刻报错,也不是压测时马上溢出,而是典型的慢性内存泄漏。只看 process.memoryUsage() 只能知道内存在涨,却无法回答“谁持有了这些对象”。真正有效的办法是抓取 heap snapshot,对多个时间点的堆快照做 retained size 对比,再沿着 GC Root 引用链找到没有释放的业务对象。
本文用一个最小可运行示例复现问题,演示如何生成快照、如何在 Chrome DevTools 中定位,以及最后如何修复。
方案对比:日志、指标与 heap snapshot 该怎么选
排查内存问题时,常见手段有三类。
第一类是进程指标。通过 rss、heapUsed、external 判断是否真的泄漏,适合监控和告警,但无法定位具体对象。
第二类是采样分析器。例如 Chrome DevTools 的 allocation sampling,或者 Clinic.js、Node.js inspector。它开销较低,适合先判断泄漏方向,但对慢泄漏、低频任务,证据链可能不够完整。
第三类是 heap snapshot。它会记录某一时刻堆中的对象、构造函数、大小以及引用关系,支持两个快照按 allocated size 与 retained size 对比。缺点是快照文件较大,抓取时会有明显 STW 停顿,不适合在无准备的高流量进程上频繁执行。
对于 48 小时才出现的问题,推荐组合是:先用指标确认增长曲线,再在业务低峰期连续抓取三个堆快照,最后用快照对比锁定对象类型。
可运行复现:一个典型的事件监听器泄漏
下面的例子模拟文档站后台任务。每次请求都会创建一个较大的 DocBuildContext,并把监听器注册到全局 EventEmitter 上;请求结束后却没有移除监听器。由于全局 emitter 生命周期与进程一致,上下文对象无法被 GC 回收。
javascript
// leak-demo.js
// 运行:node leak-demo.js
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
bus.setMaxListeners(0); // 生产中不要为了消除告警而无脑关闭限制
function createContext(id) {
// 模拟构建文档时产生的大对象
return {
id,
chunks: Array.from({ length: 3000 }, (_, i) => ({
index: i,
content: `doc-${id}-chunk-${i}-`.repeat(20),
})),
createdAt: Date.now(),
};
}
function handleRequest(id) {
const ctx = createContext(id);
// 问题点:闭包持有 ctx,bus 又持有回调,导致 ctx 长期存活
bus.on('build:finish', function onFinish() {
console.log(`finish build ${ctx.id}, chunks=${ctx.chunks.length}`);
});
bus.emit('build:finish');
// 请求结束后没有 bus.off,也没有任何地方取消引用
}
setInterval(() => {
const id = Date.now();
handleRequest(id);
const { heapUsed, rss } = process.memoryUsage();
console.log(
`[memory] heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)}MB ` +
`rss=${(rss / 1024 / 1024).toFixed(1)}MB ` +
`listeners=${bus.listenerCount('build:finish')}`
);
}, 100);如果直接运行,很快就能看到 heapUsed 与监听器数量同步上涨。真实项目中增长可能以小时计,因为任务频率低、对象更大、引用链更长,但本质相同。
抓取 heap snapshot 的三种方式
方式一:启动时打开 inspector
最直接的方式是用 --inspect 启动:
bash
node --inspect=0.0.0.0:9229 leak-demo.js然后打开 Chrome 地址栏输入 chrome://inspect,点击对应进程的 inspect,进入 Memory 面板,选择 Heap snapshot 后点击 Take snapshot。
注意:线上容器不要把 9229 暴露到公网。更安全的做法是绑定 127.0.0.1,再通过 SSH 隧道转发:
bash
ssh -L 9229:127.0.0.1:9229 user@server方式二:给运行中进程发送 USR1 信号
如果进程启动时没有加 --inspect,Linux/macOS 可以发送信号:
bash
kill -USR1 <pid>Node.js 会打印类似 Debugger listening on ws://127.0.0.1:9229/... 的信息。随后仍通过 chrome://inspect 连接。
方式三:代码内按需生成 .heapsnapshot
有些环境无法方便地连接 DevTools,可以用 v8.writeHeapSnapshot() 直接落盘:
javascript
// snapshot.js
const fs = require('node:fs');
const v8 = require('node:v8');
const path = require('node:path');
function saveSnapshot(tag) {
if (typeof v8.writeHeapSnapshot === 'function') {
const file = v8.writeHeapSnapshot(`
${tag}-${Date.now()}.heapsnapshot`.trim());
console.log('heap snapshot written:', path.resolve(file));
} else {
console.warn('当前 Node.js 版本不支持 v8.writeHeapSnapshot');
}
}
// 简单示例:收到 SIGUSR2 时生成快照
process.on('SIGUSR2', () => saveSnapshot('manual'));
// 也可以按 heapUsed 阈值兜底,避免 OOM 前完全无现场
const LIMIT_MB = 800;
setInterval(() => {
const usedMB = process.memoryUsage().heapUsed / 1024 / 1024;
if (usedMB > LIMIT_MB) {
saveSnapshot(`threshold-${Math.round(usedMB)}mb`);
}
}, 30_000);生成的 .heapsnapshot 文件可以在 Chrome DevTools 的 Memory 面板中通过 Load 按钮打开。生产环境要控制目录权限、磁盘容量和文件清理策略,避免快照把磁盘打满。
DevTools 对比快照:从对象数量追到 GC Root
建议按下面节奏抓取:
- 服务启动并完成预热后,抓取 Snapshot 1;
- 手动触发若干次构建请求,等待临时请求结束;
- 如果条件允许,手动或通过接口触发 global GC,抓取 Snapshot 2;
- 再运行一段时间,抓取 Snapshot 3;
- 在 Snapshot 3 中选择对比对象
Objects allocated between Snapshot 1 and Snapshot 2,重点看# New、# Deleted、# Delta和Size Delta。
在本次案例中,快照里会看到 Object 或业务构造名 DocBuildContext 的数量持续增加,Retained Size 很大。展开对象后选择一条记录,在下方 Retainers 面板向上追溯,引用链大致如下:
text
DocBuildContext
└─ context (closure)
└─ onFinish (function)
└─ array / internal array
└─ EventEmitter "build:finish"
└─ bus (module scope)
└─ GC Root这说明不是 V8 没有回收,而是业务代码仍然通过全局事件总线强引用着这些对象。只看到大字符串或数组很容易误判为“缓存太大”,真正关键是 Retainers 中的上一层持有者。
修复方式:及时解绑或弱化引用
最直接的修复是请求结束后移除监听器。对于只执行一次的事件,使用 once 或在回调中 off:
javascript
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
function createContext(id) {
return {
id,
chunks: Array.from({ length: 300 }, (_, i) => ({
index: i,
content: `doc-${id}-${i}`,
})),
};
}
function handleRequest(id) {
const ctx = createContext(id);
bus.once('build:finish', function onFinish() {
// once 触发后会自动移除监听器,闭包与 ctx 可在后续 GC 中释放
console.log(`finish build ${ctx.id}`);
});
bus.emit('build:finish');
}
for (let i = 0; i < 10; i++) handleRequest(i);
setInterval(() => {
console.log('listeners:', bus.listenerCount('build:finish'));
}, 1000);如果监听器确实需要长期存在,就不要让它闭包持有整个请求上下文,而应只保留必要 ID,并把缓存数据放到有上限、有过期策略的结构中:
javascript
const cache = new Map();
const MAX_CACHE_SIZE = 100;
function setCache(id, data) {
cache.set(id, data);
if (cache.size > MAX_CACHE_SIZE) {
// 删除最早插入的 key,避免无限增长
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
}修复后重新做同样的快照对比,DocBuildContext 的 # Delta 应趋近于 0,build:finish 的监听器数量不再随请求数增长。
避坑总结:内存泄漏排查中的高频误判
1. 只看内存上涨就断定泄漏,忽略正常峰值与延迟 GC
常见现象:压测一停,heapUsed 没有立刻下降,于是误判为泄漏。V8 的垃圾回收并非每个请求结束后立即执行。排查时应使用 node --expose-gc,在测试环境手动调用 global.gc(),再观察堆是否回落。生产环境不要默认开启该参数,也不要把手动 GC 当优化手段。
2. 快照只抓一张,没有基线和时间差
一张堆快照只能说明“此刻有什么”,不能证明“哪些对象本该消失”。必须至少抓两张:预热后一张、业务流量后一张;更稳妥是三张,用第二、第三张确认持续增长。对比时不要只按 Shallow Size 排序,字符串、数组本身大并不代表根因,应结合 Retained Size 和 Retainers 找持有者。
3. 为消除 MaxListenersExceededWarning 直接设置无限监听器
很多代码遇到告警后写 emitter.setMaxListeners(0),这会把最有价值的泄漏提示关掉。正确做法是检查是否重复注册、请求结束是否解绑、热更新或测试环境是否反复加载模块。监听器数量应该与有效订阅数量一致,而不是与累计请求数一致。
4. 忽略定时器、全局缓存和模块作用域引用
除了 EventEmitter,setInterval 未清理、全局 Map/Set 只增不删、WebSocket 重连重复绑定、VitePress 插件在 configureServer 中重复添加中间件,也会形成类似引用链。定位时优先搜索模块级单例、全局事件总线、长生命周期闭包,这些往往是慢泄漏的根。
5. OOM 后才想起来抓现场,导致证据丢失
容器重启后旧进程内存完全消失,无法再分析。建议为 Node.js 服务配置 --heapsnapshot-near-heap-limit=1 或在监控中设置内存阈值,提前生成快照。同时限制快照文件数量和磁盘目录,避免排查工具本身造成二次事故。
通过指标确认趋势、用 heap snapshot 对比对象增量、沿 Retainers 追到 GC Root,再用监听器数量和快照复测验证修复,基本可以覆盖大多数 Node.js 慢性 OOM。48 小时泄漏并不可怕,可怕的是只盯着内存曲线猜测;堆里的引用关系,才是最直接的答案。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。