Node.js 内存泄漏排查实战:从内存曲线到堆快照,一条可复用的定位路径

前言

先描述一个大多数 Node.js 服务都会遇到的场景。

服务上线后一切正常,QPS 平稳,接口 P99 也没波动。但盯着监控看一周会发现:容器的内存占用像心电图里那条一路向上的斜线,从 180MB 涨到 400MB,再涨到 900MB,直到某个凌晨触发 OOM Killer,Pod 被重启,监控上出现一个断崖,然后新的一轮爬坡开始。

很多团队的处理方式是把内存 limit 从 1G 调到 2G,再配一个"每天凌晨 4 点滚动重启"的定时任务。这确实能让告警安静下来,但问题一直在,只是被推迟了。

这篇文章不讲 V8 的 GC 原理科普,而是给出一条可以照着做的排查路径:从判断"到底是不是泄漏",到抓取和对比堆快照,再到定位具体那一行代码。所有命令和代码都可以直接跑。


一、第一步:先确认这真的是泄漏

这一步经常被跳过,但它能省掉一整天的无用功。内存持续上涨不等于内存泄漏。

有三种情况长得很像,但处理方式完全不同:

现象 本质 处理方向
内存涨到某个值后稳定 高水位(缓存预热、连接池扩张) 不用管,调 limit 即可
内存锯齿上涨,GC 后能回落一部分,但底线在抬高 真泄漏 抓快照定位
RSS 涨但 heapUsed 不涨 堆外内存(Buffer / 原生模块 / 内存碎片) 查 external 和 arrayBuffers

1.1 打点:把内存曲线记下来

不要凭感觉,先埋一个最简单的采样点:

js 复制代码
// memory-probe.js
const v8 = require('node:v8');

const MB = 1024 * 1024;
const fmt = (n) => (n / MB).toFixed(1) + 'MB';

function startMemoryProbe(intervalMs = 30_000) {
  const timer = setInterval(() => {
    const m = process.memoryUsage();
    const h = v8.getHeapStatistics();

    console.log(JSON.stringify({
      ts: new Date().toISOString(),
      rss: fmt(m.rss),                 // 进程实际占用的物理内存
      heapTotal: fmt(m.heapTotal),     // V8 申请到的堆大小
      heapUsed: fmt(m.heapUsed),       // V8 堆中实际使用的部分
      external: fmt(m.external),       // 绑定到 JS 对象的 C++ 内存
      arrayBuffers: fmt(m.arrayBuffers), // Buffer / TypedArray 占用
      heapLimit: fmt(h.heap_size_limit), // 堆上限,超过就 OOM
    }));
  }, intervalMs);

  timer.unref(); // 关键:不要因为这个定时器阻止进程退出
  return () => clearInterval(timer);
}

module.exports = { startMemoryProbe };

timer.unref() 这行别漏掉。监控代码本身把进程钉住不退出,是个很尴尬的翻车方式。

1.2 读懂这几个数字

拿到曲线后按这个顺序看:

  • heapUsed 持续抬高 → JS 对象泄漏,本文后面的堆快照流程适用。
  • heapUsed 平稳但 rss → 堆外内存。重点查 externalarrayBuffers,常见于 Buffer 拼接、sharp/canvas 等原生模块、以及 glibc 的内存碎片(换 jemalloc 往往立竿见影)。
  • heapUsed 逼近 heapLimit → 准备迎接 FATAL ERROR: JavaScript heap out of memory

1.3 手动触发一次 GC 做验证

判断"回落线"最可靠的办法是强制 GC 后再看:

bash 复制代码
node --expose-gc app.js
js 复制代码
// 只在排查期开启,不要留在生产代码里
if (typeof global.gc === 'function') {
  setInterval(() => {
    const before = process.memoryUsage().heapUsed;
    global.gc();
    const after = process.memoryUsage().heapUsed;
    console.log(`GC: ${(before / 1048576).toFixed(1)}MB -> ${(after / 1048576).toFixed(1)}MB`);
  }, 60_000).unref();
}

如果每次 GC 之后的 after 值本身在稳步上升,基本可以确诊为泄漏------有对象被人拿着引用,GC 不敢回收。


二、抓堆快照:三种方式按场景选

堆快照(Heap Snapshot)是定位泄漏的核心武器,它是某一时刻 V8 堆里所有对象的完整拓扑图。

2.1 开发环境:--inspect + Chrome DevTools

最直观的方式:

bash 复制代码
node --inspect app.js

然后浏览器打开 chrome://inspect,点击 inspect 进入 DevTools,切到 Memory 面板,选 Heap snapshot ,点 Take snapshot

2.2 生产环境:代码内主动写盘

生产环境不建议开 inspect 端口(有安全风险),用 v8.writeHeapSnapshot() 更稳妥:

js 复制代码
const v8 = require('node:v8');
const path = require('node:path');

function dumpHeapSnapshot(dir = '/tmp') {
  const file = path.join(dir, `heap-${Date.now()}.heapsnapshot`);
  // 注意:这是同步阻塞操作,堆越大停顿越久(1GB 堆可能停顿数秒)
  const written = v8.writeHeapSnapshot(file);
  console.log('heap snapshot written:', written);
  return written;
}

两个必须注意的点:

  1. 写快照会 STW(Stop The World),堆越大停顿越久。务必先把这个实例从负载均衡摘掉再操作。
  2. 快照文件大小约等于堆大小,1GB 的堆会产出接近 1GB 的文件,注意磁盘和下载耗时。

2.3 更优雅的方式:信号触发

Node 提供了内置的信号触发能力,不用改任何业务代码:

bash 复制代码
node --heapsnapshot-signal=SIGUSR2 app.js

之后需要时对着进程发信号即可:

bash 复制代码
kill -USR2 <pid>

快照会写到进程的当前工作目录。这个方式最适合"线上偶发、复现困难"的场景------提前挂上,出问题时随手打一发。

提醒:nodemon 默认用 SIGUSR2 做重启信号,本地开发时换成 SIGUSR1 或干脆别用这个方式。


三、对比快照:真正定位问题的一步

单张快照几乎没用。 你会看到几十万个对象,无从下手。有价值的是两张快照的差集

标准操作流程:

  1. 服务启动、预热完成后 → 抓 Snapshot 1(基线)
  2. 压测或等待业务跑一段时间(让泄漏累积到肉眼可见)→ 抓 Snapshot 2
  3. 在 DevTools Memory 面板加载两张快照,视图切换到 Comparison,Base 选 Snapshot 1

这时按 Size Delta 倒序排列,排在最上面的构造函数就是嫌疑人。

3.1 三个必须分清的指标

指标 含义 什么时候看
Shallow Size 对象自身占用 判断单个对象是否异常大
Retained Size 该对象被回收后能释放的总内存 定位泄漏主要看这个
Distance 到 GC Root 的最短距离 辅助判断引用层级

新手最容易犯的错是盯着 Shallow Size 找。一个 Map 实例的 Shallow Size 可能只有几十字节,但它 Retain 了 800MB 的数据------Retained Size 才是真相

3.2 看 Retainers:谁在拿着引用不放

选中可疑对象后,看下方的 Retainers 面板(有的版本叫 Object 的 "Retained by")。它会展示一条从 GC Root 到这个对象的引用链,形如:

sql 复制代码
Object @123456
  └── in system / Context @234567
        └── in cacheMap (Map) @345678
              └── in exports (Module) @456789
                    └── in GC roots

这条链就是答案:这个对象被一个模块级的 cacheMap 引用着,所以永远回收不掉。

顺着链条往上找到那个模块,去代码里搜 cacheMap,问题就定位了。


四、五种最常见的泄漏模式

排查多了会发现,90% 的 Node.js 内存泄漏逃不出这五类。

4.1 模块级缓存没有淘汰策略(最高频)

js 复制代码
// ❌ 一个会无限增长的"缓存"
const userCache = new Map();

async function getUser(id) {
  if (userCache.has(id)) return userCache.get(id);
  const user = await db.users.findById(id);
  userCache.set(id, user); // 只进不出
  return user;
}

只要用户 ID 空间足够大(或者干脆用了 traceId、请求参数拼接做 key),这个 Map 就会一直涨。

修复方式:任何缓存都必须有上限。

js 复制代码
// ✅ 用 LRU + TTL
const { LRUCache } = require('lru-cache');

const userCache = new LRUCache({
  max: 5000,              // 最多 5000 条
  ttl: 1000 * 60 * 5,     // 5 分钟过期
  updateAgeOnGet: false,  // 命中不续期,避免热点永驻
});

async function getUser(id) {
  const cached = userCache.get(id);
  if (cached) return cached;
  const user = await db.users.findById(id);
  userCache.set(id, user);
  return user;
}

如果 key 天然是对象且希望"对象没人用了缓存就该消失",用 WeakMap

js 复制代码
// key 是对象,且不阻止 key 被 GC
const metaCache = new WeakMap();
metaCache.set(reqObject, { parsedAt: Date.now() });

4.2 EventEmitter 监听器只加不减

js 复制代码
// ❌ 每个请求都往全局 emitter 上挂监听器
function handleRequest(req, res) {
  bus.on('config:changed', () => {
    res.setHeader('X-Config-Version', currentVersion);
  });
  // 请求结束了,但监听器还在,闭包里的 req/res 也跟着不释放
}

这类问题的典型信号是控制台出现:

vbnet 复制代码
MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 config:changed listeners added to [EventEmitter].

别用 setMaxListeners(0) 把警告关掉------那是把火警拆了。正确做法:

js 复制代码
// ✅ 方式一:手动解绑
function handleRequest(req, res) {
  const onChange = () => res.setHeader('X-Config-Version', currentVersion);
  bus.on('config:changed', onChange);
  res.on('close', () => bus.off('config:changed', onChange));
}

// ✅ 方式二:用 AbortSignal 统一管理(Node 16+)
function handleRequest(req, res) {
  const ac = new AbortController();
  bus.on('config:changed', onChange, { signal: ac.signal });
  res.on('close', () => ac.abort()); // 一次性摘掉所有绑定
}

顺手加一个启动期自检也很划算:

js 复制代码
process.on('warning', (w) => {
  if (w.name === 'MaxListenersExceededWarning') {
    console.error('[LEAK-SUSPECT]', w.message, w.stack);
  }
});

4.3 定时器忘记清理

js 复制代码
// ❌ 连接断开后定时器还在跑,闭包里的 socket 永远不释放
function startHeartbeat(socket) {
  setInterval(() => socket.ping(), 5000);
}

setInterval 返回的 Timeout 对象本身被事件循环持有,它闭包里引用的所有东西都会跟着活下去。

js 复制代码
// ✅ 生命周期绑定
function startHeartbeat(socket) {
  const timer = setInterval(() => socket.ping(), 5000);
  socket.once('close', () => clearInterval(timer));
}

4.4 未消费的 Stream 与背压失控

js 复制代码
// ❌ 一次性把整个文件读进内存
const data = await fs.promises.readFile('/data/huge.csv'); // 2GB 直接进堆外内存
js 复制代码
// ✅ 流式处理,内存占用恒定
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const readline = require('node:readline');

async function processCsv(file) {
  const rl = readline.createInterface({
    input: fs.createReadStream(file, { encoding: 'utf8' }),
    crlfDelay: Infinity,
  });
  for await (const line of rl) {
    await handleLine(line); // await 天然形成背压
  }
}

这里的关键是 for await 里的 await:它会让读取暂停,等待处理完成,从而避免"读得比处理快"导致数据在内存里堆积。

4.5 全局数组当日志缓冲区

js 复制代码
// ❌ 看起来很聪明的"批量上报"
const pendingLogs = [];
function log(entry) {
  pendingLogs.push(entry);
}
setInterval(() => flush(pendingLogs), 10_000);

一旦 flush 因为网络问题失败又没有清空逻辑,或者上报速度追不上写入速度,这个数组就是个定时炸弹。

js 复制代码
// ✅ 有界队列 + 丢弃策略
const MAX_PENDING = 10_000;
const pendingLogs = [];

function log(entry) {
  if (pendingLogs.length >= MAX_PENDING) {
    pendingLogs.shift(); // 丢最老的,保护进程存活
    droppedCount++;
  }
  pendingLogs.push(entry);
}

内存安全的核心原则:宁可丢数据,不能丢进程。


五、一个真实案例的完整复盘

背景:一个订单查询服务,日均 300 万请求,每 48 小时必 OOM 重启一次。

Step 1 --- 确认泄漏 埋点观察 12 小时,heapUsed 从 210MB 稳定爬到 690MB,--expose-gc 手动 GC 后只回落 30MB。判定:真泄漏。

Step 2 --- 抓快照 把一个实例从 LB 摘掉,间隔 2 小时抓了两张快照,文件分别是 240MB 和 610MB。

Step 3 --- 对比 Comparison 视图按 Delta 排序,第一名是 Object+ 1,240,331 个,Retained Size 增加约 340MB。

Step 4 --- 看 Retainers 引用链指向一个叫 requestContextMapMap。搜代码找到这段:

js 复制代码
// 链路追踪的上下文存储
const requestContextMap = new Map();

app.use((req, res, next) => {
  requestContextMap.set(req.id, {
    traceId: req.headers['x-trace-id'],
    startAt: Date.now(),
    user: req.user,
  });
  next();
});

只有 set 没有 delete。每个请求写入一条,永不清理。

Step 5 --- 修复

js 复制代码
app.use((req, res, next) => {
  requestContextMap.set(req.id, { /* ... */ });
  res.on('close', () => requestContextMap.delete(req.id)); // 请求结束即清理
  next();
});

res.on('close') 而不是 res.on('finish')finish 只在响应正常发完时触发,客户端提前断开的场景不会走到,那部分就漏了。

更好的方案 是直接用 Node 内置的 AsyncLocalStorage,把上下文的生命周期交给运行时管理:

js 复制代码
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();

app.use((req, res, next) => {
  als.run({ traceId: req.headers['x-trace-id'], user: req.user }, next);
});

// 任意深度的调用栈里都能取到,且请求结束自动释放
function getTraceId() {
  return als.getStore()?.traceId;
}

结果 :修复后连续运行 15 天,heapUsed 稳定在 230MB 上下,OOM 告警清零。


六、不能只靠人工:把防线前移

排查是止血,防住才是治本。

6.1 加一层内存水位告警

js 复制代码
const v8 = require('node:v8');

setInterval(() => {
  const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
  const ratio = used_heap_size / heap_size_limit;
  if (ratio > 0.85) {
    // 接到你的告警系统,比等 OOM 强得多
    metrics.gauge('node.heap.usage_ratio', ratio);
    logger.warn(`heap usage high: ${(ratio * 100).toFixed(1)}%`);
  }
}, 30_000).unref();

告警阈值建议设在 85%,留出处置时间窗口。

6.2 显式设置堆上限

容器环境里 V8 不一定能正确识别 cgroup 的内存限制,务必显式指定:

bash 复制代码
# 容器 limit 是 2G,堆上限设为 1.5G,给堆外内存留余量
node --max-old-space-size=1536 app.js

留出的这 500MB 是给 Buffer、原生模块和栈用的。堆上限设成和容器 limit 一样大,只会让 OOM Killer 抢在 V8 报错之前动手,连错误日志都拿不到。

6.3 CI 里加一个内存回归检查

在压测环节跑一个简化版检测:

js 复制代码
// leak-check.js:跑 N 轮相同操作,看内存是否单调上升
async function leakCheck(fn, rounds = 50) {
  const samples = [];
  for (let i = 0; i < rounds; i++) {
    await fn();
    if (i % 10 === 9) {
      global.gc();
      samples.push(process.memoryUsage().heapUsed);
    }
  }
  const growth = (samples.at(-1) - samples[0]) / samples[0];
  if (growth > 0.3) {
    throw new Error(`possible leak: heap grew ${(growth * 100).toFixed(1)}%`);
  }
}

配合 node --expose-gc leak-check.js 运行。这个检查不精确,但足以拦住"每次调用都往全局 Map 塞东西"这类低级错误。


七、排查 Checklist

遇到内存问题时,按这个顺序走一遍:

  • 埋点采样 rss / heapUsed / external / arrayBuffers,观察至少 2 小时
  • heapUsed 涨 → JS 对象泄漏;只有 rss 涨 → 查堆外内存和内存碎片
  • --expose-gc 强制 GC,确认回落线是否在抬高
  • 摘掉负载,间隔抓两张堆快照
  • DevTools Comparison 视图,按 Retained Size Delta 排序
  • 看 Retainers 引用链,定位持有者
  • 对照五种常见模式(缓存 / 监听器 / 定时器 / Stream / 全局数组)
  • 修复后回归验证,并补上内存水位告警

写在最后

Node.js 的内存泄漏排查,难点从来不在工具------DevTools 的 Memory 面板功能足够强,v8.writeHeapSnapshot() 也就一行代码。难在两件事:一是判断"到底是不是泄漏",二是读懂 Retainers 那条引用链。

而绝大多数泄漏的根因其实很朴素:某个对象被放进了一个生命周期比它长的容器里,然后没人负责把它拿出来。

写代码时多问自己一句"这个东西什么时候被删掉",能省掉未来某个凌晨爬起来看监控的时间。

你踩过最隐蔽的一次内存泄漏是什么样的?欢迎在评论区聊聊。

相关推荐
烂蜻蜓2 小时前
Node.js入门教程(七):工作机制
node.js
凌云拓界15 小时前
NodeVerdict | .ndv 二进制格式:为 WASM 解码器设计的紧凑布局
安全·架构·开源·node.js·编辑器·软件工程·wasm
凌云拓界21 小时前
NodeVerdict | 性能门禁:把追踪数据变成 CI 规则
ci/cd·信息可视化·开源·node.js·bug·数据可视化·安全架构
烂蜻蜓1 天前
Node.js入门教程(五):NVM 管理多版本 Node.js
node.js
接着奏乐接着舞。1 天前
LLM 流式响应:网慢和断线怎么处理?
前端·人工智能·node.js·aigc
MartinYeung51 天前
npm爆发大规模供应链攻击 蠕虫污染 2000+ 个包版本: 深度技术剖析
前端·npm·node.js
爱喝水的鱼丶1 天前
SAP-ABAP:SAP性能分析核心工具入门——ST05/SAT/ST12等常用事务码的基础用法解析
性能优化·sap·abap·性能分析·开发交流·交流学习
鲟迹1 天前
NVM 管理Node.js版本
node.js
FungLeo1 天前
Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
网络·flutter·缓存·性能优化