跳到主内容

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 小时才出现的问题,推荐组合是:先用指标确认增长曲线,再在业务低峰期连续抓取三个堆快照,最后用快照对比锁定对象类型。

监控 heapUsed确认持续上涨开启 inspector抓取初始快照制造业务流量等待对象增长二次快照做 Delta 对比沿 GC Root查看持有者修复引用补充回归验证

可运行复现:一个典型的事件监听器泄漏 ​

下面的例子模拟文档站后台任务。每次请求都会创建一个较大的 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 ​

建议按下面节奏抓取:

  1. 服务启动并完成预热后,抓取 Snapshot 1;
  2. 手动触发若干次构建请求,等待临时请求结束;
  3. 如果条件允许,手动或通过接口触发 global GC,抓取 Snapshot 2;
  4. 再运行一段时间,抓取 Snapshot 3;
  5. 在 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 小时泄漏并不可怕,可怕的是只盯着内存曲线猜测;堆里的引用关系,才是最直接的答案。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。