Node.js报错JavaScript heap out of memory的排查方法(--max-old-space-size调整堆上限、堆快照抓取与内存泄漏定位)

Node进程跑一段时间就崩,日志里躺着一段FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory,上面跟着几行Last few GCs和一串Scavenge、Mark-sweep的数字。守护进程把它拉起来,几个钟头后再崩一次。这条报错的意思是V8的旧生代被填满了,再要分配对象就失败,属于进程自己主动退出,跟被系统杀掉是两回事。


先分清堆溢出还是被OOM杀掉

这两种故障的处理方向完全相反。堆溢出时Node会打印FATAL ERROR和垃圾回收统计后退出;被系统杀掉则只有孤零零一行Killed,容器场景下docker inspect能看到OOMKilled为true,退出码137。前者说明V8的堆上限到了,可以往上抬;后者说明常驻内存超过了容器或系统给的限额,这时候再抬--max-old-space-size是加速崩溃,应该往小压。

容器里特别容易搞混,是因为Node 12起默认堆上限按宿主机可用内存推算,不看容器收到的限额。一台64GB的宿主机上跑限制512MB的容器,Node以为自己能用好几个GB的堆,于是堆一路涨,最后先撞上的是容器限额。当前实际生效的上限可以直接读出来:

node -e "const v=require('v8').getHeapStatistics();console.log(v.heap_size_limit/1048576)"

顺带一提,11及更早的版本没有这套按内存推算的逻辑,64位固定在1400MB上下、32位700MB上下,那时候判断反倒简单。


抬上限的写法

参数单位是MiB,放在NODE_OPTIONS里最省事,子进程和npm script都能继承:

NODE_OPTIONS="--max-old-space-size=4096" node dist/main.js

用pm2就在ecosystem.config.js里写node_args,或者命令行加--node-args。做镜像时写成ENV而不是塞在CMD里,好处是exec进容器手动拉起进程也能带上同一个值。

官方手册里给过一个经验值:2GiB内存的机器配1536,给堆以外的部分留点余量。堆远不是进程内存的全部,Buffer、原生插件、Worker线程都算在rss里,不受这个参数约束。容器里控制在限额的七成左右比较稳,实测过512MB的容器配--max-old-space-size=512,rss冲到600多MB,进程直接被容器外的机制清掉了。


看内存涨在哪个部位

process.memoryUsage()返回五个值:rss是进程常驻内存,heapTotal和heapUsed是V8堆的总量与实际占用,external是绑在本机对象上的C++侧内存,arrayBuffers是Buffer占的那一块。heapUsed一路往上走,指向JS对象泄漏;external或arrayBuffers比heapUsed涨得更快,多半是一次性把大文件读进了内存,或者做图片、音视频处理时把整份数据攒在Buffer里没往后传。

setInterval(() => console.log(process.memoryUsage()), 10000);

打点十分钟,heapUsed应该在每次GC后回落,画出锯齿。只涨不落的就是泄漏。


抓堆快照

看不到对象堆被谁占着,光靠猜是找不到的。允许改代码的话,直接在进程里写:

const v8 = require('node:v8');
v8.writeHeapSnapshot('./heap-' + Date.now() + '.heapsnapshot');

不方便改代码时,启动时加--heapsnapshot-signal=SIGUSR2,之后用kill发信号触发快照,文件落在进程的工作目录:

node --heapsnapshot-signal=SIGUSR2 dist/main.js
kill -USR2 12345

单张快照基本没用。标准做法是连抓三张:启动稳定后一张,压一会儿第二张,再隔一段时间第三张。放进Chrome开发者工具的Memory面板依次载入,切到Comparison视图,让第三张跟第一张比,按Retained Size排序,Delta一直为正的构造函数就是突破口,再顺着Retainers链往上走,通常就能落到某行具体的赋值上。


常见的那几个泄漏点

现场遇到过一堆,回头归纳下来就这几类。全局Map或数组当缓存用,只进不出,既没上限也没过期策略;事件监听器重复注册,每个请求都给同一个Emitter挂一次却从不removeListener,日志里出现MaxListenersExceededWarning其实已经在报警;闭包把大对象吊住不放;HTTP响应体没消费也没销毁,流停在半路。

require的模块缓存不属于这一类,它常驻但不增长。


查不出来的时候怎么兜底

泄漏一时半刻定位不到,业务又不能停,之前的处理办法是先给pm2的max_memory_restart设一个阈值,越过就自动重启,让挂这种事发生在白天而不是深夜。这是买时间的办法,日志已经把问题写得很明白了,堆一直涨上去迟早还得回头来查。


数据截至2026年10月,上面这些行为在Node.js 18.20.8、20.20.2、22.23.3、24.21.0与26.11.0上验证过。Node 20已于2026年4月30日结束维护,还在跑的建议挪到24这个LTS上。