前言
先描述一个大多数 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涨 → 堆外内存。重点查external和arrayBuffers,常见于 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;
}
两个必须注意的点:
- 写快照会 STW(Stop The World),堆越大停顿越久。务必先把这个实例从负载均衡摘掉再操作。
- 快照文件大小约等于堆大小,1GB 的堆会产出接近 1GB 的文件,注意磁盘和下载耗时。
2.3 更优雅的方式:信号触发
Node 提供了内置的信号触发能力,不用改任何业务代码:
bash
node --heapsnapshot-signal=SIGUSR2 app.js
之后需要时对着进程发信号即可:
bash
kill -USR2 <pid>
快照会写到进程的当前工作目录。这个方式最适合"线上偶发、复现困难"的场景------提前挂上,出问题时随手打一发。
提醒:
nodemon默认用SIGUSR2做重启信号,本地开发时换成SIGUSR1或干脆别用这个方式。
三、对比快照:真正定位问题的一步
单张快照几乎没用。 你会看到几十万个对象,无从下手。有价值的是两张快照的差集。
标准操作流程:
- 服务启动、预热完成后 → 抓 Snapshot 1(基线)
- 压测或等待业务跑一段时间(让泄漏累积到肉眼可见)→ 抓 Snapshot 2
- 在 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 引用链指向一个叫 requestContextMap 的 Map。搜代码找到这段:
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 那条引用链。
而绝大多数泄漏的根因其实很朴素:某个对象被放进了一个生命周期比它长的容器里,然后没人负责把它拿出来。
写代码时多问自己一句"这个东西什么时候被删掉",能省掉未来某个凌晨爬起来看监控的时间。
你踩过最隐蔽的一次内存泄漏是什么样的?欢迎在评论区聊聊。