Node.js 内存泄漏排查实战:从堆快照到定位那一行
Node.js 内存泄漏排查实战:从堆快照到定位那一行
前言
Node 服务跑三天,RSS 从 200MB 涨到 2GB,然后被 OOM Killer 干掉、重启、再涨。内存泄漏的本质往往不是"内存没释放",而是某个引用一直被人握着。本文给一套可复现的定位流程。
一、先分清:是泄漏还是正常水位
process.memoryUsage();
// { rss, heapTotal, heapUsed, external, arrayBuffers }
- rss:进程实际占用(含堆外、Buffer、栈),容器 OOM 看它;
- heapUsed:V8 堆上活跃对象,看泄漏主要看这个;
- external / arrayBuffers:Buffer、TypedArray 占的内存,Node 特有的泄漏重灾区。
判据:
- 锯齿状周期性上升下降 = 正常 GC,不是泄漏;
- 压测后强制 GC,
heapUsed能回落到基线 = 没泄漏; - 每次 GC 后基线都在抬高 = 真泄漏。
node --expose-gc --max-old-space-size=2048 app.js
# 代码里调用 global.gc(); 再对比 heapUsed
V8 默认堆上限约 2GB(64 位,随可用内存浮动)。容器里必须用 --max-old-space-size 显式设置,且建议为容器 memory limit 的 70%~75%——留出堆外内存空间,否则进程还没到堆上限就先被 OOM Killer 杀了。
二、抓快照:三次采样法
import v8 from 'node:v8';
v8.writeHeapSnapshot('/tmp/heap-' + Date.now() + '.heapsnapshot');
节奏:服务稳定后采第一次 → 处理 500 个请求或等 10 分钟 → 采第二次 → 再来一轮采第三次。用 Chrome DevTools 的 Memory → Load 依次载入,切到 Comparison 视图,看两次之间新增对象数最多的构造函数。
读快照的两条经验:
- 看 Retained Size(释放它能连带回收多少),别被 Shallow Size 骗了;
- 优先找"数量随请求线性增长"的构造函数,比如某个
Context、某个Request、某个内部类。
线上不方便开 DevTools 时,可以只上报 process.memoryUsage() 和 v8.getHeapStatistics(),用趋势图定位起点。
三、六个高频泄漏模式
1. 无上限的缓存
const cache = new Map(); // ❌ 永不清理
cache.set(key, value);
// ✅ 用 LRU + 容量上限 + TTL
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 60_000 });
2. 定时器与事件监听未清理
// ❌ 每次请求都挂一个监听,永不卸载
emitter.on('update', handler);
// ✅ 请求结束时 off,或用 once / AbortSignal
setInterval 持有闭包,闭包持有请求上下文,请求对象就一直活着。凡是 on / setInterval / setTimeout,都要问一句"谁负责清"。
3. 全局数组攒数据
metrics.push(sample); // ❌ 只 push 不裁剪
if (metrics.length > 1000) metrics.shift(); // ✅ 环形缓冲
4. 闭包意外持有大对象
把整个 req 或数据库连接塞进全局队列/上下文,等于把整条请求链的内存钉死。只提取需要的字段,别传大对象引用。
5. 异步任务无节制堆积
// ❌ 一万个 promise 同时挂起,每个都带完整响应体
items.forEach((i) => fetchDetail(i));
用并发上限(p-limit)或流式处理,别让队列长度随输入无限增长。
6. Buffer 切片持有整个底层内存(Node 特有,最隐蔽)
const huge = Buffer.alloc(100 * 1024 * 1024); // 100MB
const small = huge.subarray(0, 100); // ⚠️ 只借用视图,底层仍是 100MB
// ✅ 复制出独立内存
const copy = Buffer.from(small);
只要还有一个切片活着,整个 100MB 就回收不掉。同理,Buffer.slice()、TypedArray.subarray() 都是视图操作。
四、Buffer 池与堆外内存
Buffer 分配不走 V8 堆,heapUsed 看起来正常、rss 却持续上涨时,重点查 external 与 arrayBuffers:
setInterval(() => {
const m = process.memoryUsage();
logger.info({ rss: m.rss >> 20, heap: m.heapUsed >> 20, ext: m.external >> 20 });
}, 60_000);
常见原因:未释放的流、大文件整块读入、压缩/加密缓冲区复用不当、readFileSync 处理大文件。
五、监控与止血
| 手段 | 做法 | 说明 |
|---|---|---|
| 指标上报 | rss、heapUsed、external 每分钟上报 |
看趋势比看瞬时值有用 |
| 阈值告警 | heapUsed 超上限 80% 且连续 3 次 |
避免单点抖动误报 |
| 定期重启 | 滚动重启压住水位 | 只止血,不治病 |
| 压力复现 | 本地 autocannon 打 10 分钟 |
泄漏在高压下暴露最快 |
定位 CPU + 内存混合问题时,用 clinic doctor 或 0x 生成火焰图,比裸读快照直观:
npx clinic doctor -- node app.js
npx clinic flame -- node app.js
六、排查顺序小结
- 看趋势:GC 后基线上涨 = 泄漏;
- 分类型:
heapUsed涨 → 堆对象;external涨 → Buffer/堆外; - 抓三次快照做 Comparison,找线性增长的构造函数;
- 回到代码搜该构造函数的创建点,检查是否有"只增不减"的容器;
- 修复后压测 30 分钟,确认基线与首次采样持平。
总结
内存泄漏排查的核心动作只有一句话:用快照对比找到"只增不减"的对象,再回溯是谁还握着它的引用。
预防比排查便宜得多:缓存必须有上限、监听必须有卸载、异步必须有并发控制、Buffer 切片必须显式复制。四条纪律落到代码规范里,Node 服务跑一个月也不会胖。
- 点赞
- 收藏
- 关注作者
评论(0)