上个月接了一个外包团队的 Node.js 接口服务,跑在一台 4 核 4G 的云服务器上。功能没什么问题,但运维那边每天早上都要发一条告警:常驻内存超过 1.5G 就重启。重启之后安静几个小时,然后又慢慢往上爬。我去看了下监控曲线,那条线不是尖峰,是一条标准的斜坡,从 400M 一路爬到 2G,用了整整两周。这种曲线只有一种解释进程里的东西在不断累积,而且从来没人去收。
如果你正在排查 Node.js 这类服务的内存占用,或者需要一台内存规格合适的机器先把复现环境搭起来再动代码,可以先看看这两家的配置和技术说明:churros(香港/日本/新加坡 BGP 云服务器) EVOXT(高主频云主机)
这类问题最烦的地方在于,它不报错、不崩溃、接口响应还正常,只是内存慢慢涨。很多团队的做法是加内存或者加个定时重启,本质上是把问题往后推,而不是解决它。这次我花了两天时间,用 V8 的堆快照把它挖出来了,写下来给同样在抓瞎的朋友。

很多人一上来就想找“泄漏代码”,但 Node.js 的内存至少分三块,你连涨的是哪一块都没搞清楚,后面全是瞎猜。直接看 process.memoryUsage() 的四个关键字段:
rss 是进程向操作系统要到的物理内存,heapTotal 是 V8 向系统申请到的堆空间,heapUsed 是堆里真正被对象占用的部分,external 是堆外的 Buffer 和 TypedArray。判断方向有个很粗暴但很好用的办法:
如果 heapUsed 和 rss 一起涨,那问题在 JS 对象层面某个 Map、Array 或者闭包一直挂着不放;如果 heapUsed 基本不动但 rss 一路涨,那就是堆外的问题,通常是 Buffer 没释放或者 native 插件自己踩了坑;如果 external 单独疯涨,那就直接查 Buffer 的分配点。我们这台机器上属于第一种,heapUsed 和 rss 是同步爬升的,所以方向很明确:JS 对象被某个东西一直引用着。
确认方向之后就是取证。Node.js 内置了 v8.writeHeapSnapshot(),产出一个几百 MB 的 .heapsnapshot 文件,本质上是 JSON,记着当下所有对象的引用关系。命令行里可以直接开一个调试端口,用 Chrome DevTools 的 Memory 面板连上去点 Take heap snapshot,效果是一样的。
但这里有个关键动作,很多人做漏了:要抓两份。第一份在服务刚起来或者泄漏还没发生的时候抓一份存着,等内存涨上去之后再抓第二份,然后把两份都拖进 DevTools 的 Memory 面板,用 Comparison 视图做对比。
对比视图会直接告诉你这期间新增了哪些对象、各占多少。最典型的结论长这样:某个 Timer 类型的对象数量从 3 个变成了 46000 个,占了 1.2G。这时候基本就不用猜了,代码里定时器没清这件事已经写在脸上。

翻代码,定位到的是一个数据同步任务,大致长这样:
function startSync() {
setInterval(async () => {
const rows = await fetchAll();
cache.set('rows', rows);
}, 60 * 1000);
}
问题不在 setInterval 本身,而在回调里那个 async 函数。定时器每分钟触发一次,如果某次抓数据慢、或者下游接口超时,这个异步任务就还没跑完,下一个定时器已经排上了。每一个未完成的 async 闭包都会连带它引用的整条数据链一起留在堆里包括那个 rows 数组。跑两周就是两万多次,内存自然就撑起来了。
修法不复杂,但要注意两点。第一,回调里要自己判重,上一次没跑完就直接跳过这次:
let running = false;
setInterval(async () => {
if (running) return;
running = true;
try { const rows = await fetchAll(); cache.set('rows', rows); }
finally { running = false; }
}, 60000);
第二,如果这个任务只是要轮询,更稳妥的做法是用 setTimeout 递归调用自己,跑完再排下一次,从根子上避免堆积。改完之后再观察一周,那条斜坡彻底平了,常驻内存稳定在 380M 左右,比原来还低了一截因为原来那 1.2G 里有一大半是重复抓回来的同一批数据。

定时器只是其中一个坑。堆快照对比里还暴露了第二个:某个模块级的 Map,被当成“最近访问记录”一直往里塞 key,从来没做过淘汰。这个 Map 本身不大,但它持有的每一个 value 都是一份完整的业务对象,加起来又是几百 M。
这类无界缓存是最常见的内存杀手,因为写代码的时候特别顺手:查不到就放进 Map,下次直接命中。问题是没人会记得它需要淘汰。修法是给它加个上限,比如用 lru-cache 这种带容量限制的实现,或者自己维护一个简单的先进先出队列,超过阈值就丢最老的。改完之后,堆快照里 Map 的 size 从 80 万降到稳定的两千。

不要指望 --max-old-space-size 治本。 这参数只是告诉 V8 堆涨到多少就该开始更积极地回收垃圾,它不阻止你的对象继续被引用着。把它从 2G 调到 4G,曲线只是从第 5 天爬变成了第 10 天爬。真正需要它的地方是防止进程被内核 OOM 直接杀掉,属于兜底而不是解法。
事件监听器也会累积。 用 EventEmitter 的时候反复 on 而不 off,或者在 HTTP 服务里对每个请求都注册一个监听器而请求结束后没摘掉,都会造成同样的问题。在堆快照里表现为 Listener 类对象数量异常。
heapdump 文件很大,别在生产机器上随手生成。 抓快照的瞬间进程会暂停几秒到几十秒,文件可能撑到几百 MB。生产环境要抓,提前规划好磁盘空间和抓取窗口,最好在预发环境复现。
这次排查最大的收获不是那个定时器,而是一个思路:内存问题不要靠猜,靠对比。两份堆快照一减,该死的对象自己会站出来。代码层面能做的事情其实有限定时器记得清、缓存记得设上限、监听器记得摘、异步任务记得判重,这四条做到位,八成的 Node.js 内存增长都能挡住。剩下的,是给进程配一台内存够用的机器,让它有喘息的机会。