Node.js 内存泄漏排查实战:从堆快照到定位那一行

举报
yd_231768394 发表于 2026/11/03 23:50:51 2026/11/03
【摘要】 Node.js 内存泄漏排查实战:从堆快照到定位那一行 前言Node 服务跑三天,RSS 从 200MB 涨到 2GB,然后被 OOM Killer 干掉、重启、再涨。内存泄漏的本质往往不是"内存没释放",而是某个引用一直被人握着。本文给一套可复现的定位流程。 一、先分清:是泄漏还是正常水位process.memoryUsage();// { rss, heapTotal, heapUse...

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 特有的泄漏重灾区。

判据:

  1. 锯齿状周期性上升下降 = 正常 GC,不是泄漏;
  2. 压测后强制 GC,heapUsed 能回落到基线 = 没泄漏;
  3. 每次 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

六、排查顺序小结

  1. 看趋势:GC 后基线上涨 = 泄漏;
  2. 分类型:heapUsed 涨 → 堆对象;external 涨 → Buffer/堆外;
  3. 抓三次快照做 Comparison,找线性增长的构造函数;
  4. 回到代码搜该构造函数的创建点,检查是否有"只增不减"的容器;
  5. 修复后压测 30 分钟,确认基线与首次采样持平。

总结

内存泄漏排查的核心动作只有一句话:用快照对比找到"只增不减"的对象,再回溯是谁还握着它的引用。

预防比排查便宜得多:缓存必须有上限、监听必须有卸载、异步必须有并发控制、Buffer 切片必须显式复制。四条纪律落到代码规范里,Node 服务跑一个月也不会胖。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。