Node.js 服务内存泄漏如何排查?
简化版
Node.js 内存泄漏排查要先确认 RSS、heapUsed 是否持续上涨,再结合 heap snapshot、allocation profile、GC 日志和压测复现定位保留对象。常见原因包括全局缓存无限增长、事件监听未移除、闭包引用大对象、定时器未清理和请求上下文持有过多数据。
详细版
先用指标判断是不是泄漏,而不是看到内存高就下结论。
setInterval(() => {
const m = process.memoryUsage()
console.log({
rss: m.rss,
heapUsed: m.heapUsed,
external: m.external
})
}, 10_000)
如果 GC 后 heap 仍持续上升,并且请求停止后不回落,就要抓 heap snapshot 对比对象增长。排查重点是“谁还引用着这些对象”,而不是只看对象大小。
完整版教学
一、先区分内存高和内存泄漏
内存高不一定是泄漏。 缓存、连接池、大批量任务、V8 堆扩容都会让内存上升。 泄漏的关键特征是:业务负载结束后,本该释放的对象仍被引用,内存长期不回落。
正常缓存:
内存上升 -> 达到稳定平台 -> 波动
疑似泄漏:
内存上升 -> GC 后仍上升 -> 请求停止也不回落
假设压测 30 分钟,heapUsed 从 200 MB 到 800 MB,停止压测后仍维持 780 MB。 这比单次看到 800 MB 更能说明问题。
二、理解几个关键内存指标
process.memoryUsage() 能看到 RSS、heapTotal、heapUsed、external、arrayBuffers。
V8 堆主要看 heapUsed。
Buffer、原生扩展和部分外部内存会体现在 external 或 RSS 上。
const {
rss,
heapTotal,
heapUsed,
external,
arrayBuffers
} = process.memoryUsage()
| 指标 | 含义 | 排查方向 |
|---|---|---|
rss | 进程占用物理内存 | 总体压力 |
heapUsed | V8 堆已用 | JS 对象泄漏 |
external | V8 外部内存 | Buffer/native |
arrayBuffers | ArrayBuffer/Buffer | 大二进制数据 |
如果 heapUsed 稳定但 RSS 飙升,问题可能不在普通 JS 对象。 可能是 Buffer、大文件处理、原生模块或内存碎片。
三、heap snapshot 看的是引用链
Heap Snapshot 能展示堆里的对象、大小和引用关系。 排查泄漏时最重要的是 retained size 和 retaining path。 你要找出为什么对象没有被 GC 回收。
LeakedUserSession
<- Map cache
<- global sessionStore
<- module scope
这个引用链说明对象被模块级缓存 Map 持有。 即使请求结束,只要 Map 不删,它就不会被回收。 所以泄漏排查的本质是找“谁还拉着它不放手”。
四、对比快照比单张快照更有价值
单张快照只能告诉你当前有什么。 对比快照能告诉你两次之间增长了什么。 常见做法是在压测前抓一次,压测一段时间抓一次,停止流量并触发 GC 后再抓一次。
Snapshot A: 压测前
Snapshot B: 压测中
Snapshot C: 停止后
重点看 B 到 C 仍未释放的对象
如果某类对象从 1 万个增长到 30 万个,并且停止后仍不下降,它就是重点嫌疑。 数量增长有时比单个对象大小更危险。
五、常见泄漏来源很固定
Node 服务常见泄漏大多不是神秘问题。 全局数组缓存、Map 不淘汰、EventEmitter 监听不移除、定时器不清理、闭包持有请求对象、AsyncLocalStorage 存大对象,都很常见。
// 危险:无限增长缓存
const cache = new Map()
app.get('/user/:id', async (req, res) => {
cache.set(req.params.id, await loadUser(req.params.id))
})
| 来源 | 表现 | 修复 |
|---|---|---|
| 无限缓存 | Map/数组持续增长 | TTL/LRU/上限 |
| 监听未移除 | listener 增多 | cleanup |
| 定时器未清理 | 对象被回调引用 | clearInterval |
| 大 Buffer | external 增长 | 流式处理 |
如果缓存没有上限,在高基数用户 ID 场景里迟早变成泄漏。 缓存一定要有容量边界。
六、生产排查要控制风险
抓 heap snapshot 可能暂停进程并占用额外内存。 生产环境不要在满负载主实例上随便抓。 更安全的方式是灰度实例、复现环境、诊断端口受控开放,或者在容器副本中复现。
记忆钩子:内存泄漏不是对象大,而是对象该走没走;看引用链,找谁拽着它。
排查时先指标、再复现、再快照、再引用链。
不要一上来调大 --max-old-space-size,那只是把爆炸时间往后推。
七、常见误区与追问
- 误区:内存占用高就是泄漏。 缓存、连接池和 V8 堆扩容都可能让内存高,要看 GC 后是否持续不回落。
- 误区:调大 max-old-space-size 就解决了。 这只扩大堆上限,真正泄漏仍会继续增长。
- 误区:只看 shallow size 就能定位。 retained size 和引用链更关键,因为它解释对象为什么释放不了。
- 追问:heapUsed 和 rss 有什么区别? heapUsed 是 V8 JS 堆,RSS 是进程总物理内存,占用范围更大。
- 追问:Buffer 泄漏看哪个指标? 重点看 external、arrayBuffers 和 RSS。
- 追问:EventEmitter 泄漏怎么发现? listener 数量持续增长,可能出现 MaxListenersExceededWarning,也可从快照看监听器引用链。
八、加强记忆
内存泄漏排查按“四步走”:看指标趋势,压测复现,对比快照,追引用链。常见元凶是无限缓存、未清理监听、未清理定时器、大 Buffer 和闭包引用。别急着加内存,先找谁没放手。