阅读提要

进程内存一路上涨、重启就好、过几天又涨——先分清是「泄漏」还是「缓存没上限」。这篇给出从观察到定位的完整路径。

开始阅读

字号

先分清两件事

看到 RSS 上涨,第一反应是「内存泄漏」。但实际情况分两类:

一类是真泄漏:对象被不该持有的引用链挂着,GC 永远回收不掉。

另一类是缓存没上限:对象确实还有用,只是你忘了设淘汰策略。

第二类更常见,也更好修。判断方法:重启后内存回落到基线,然后缓慢上升到某个值后稳定——这是缓存;一直上升到 OOM——这是泄漏。

排查路径

第一层:确认是堆还是堆外

ps -o rss= -p <pid>

RSS 是操作系统看到的总内存,包含堆、堆外(Buffer、C++ 对象)、以及共享库。

再看堆:

setInterval(() => {
  const m = process.memoryUsage()
  console.log({
    rss: (m.rss / 1e6).toFixed(0),
    heapUsed: (m.heapUsed / 1e6).toFixed(0),
    external: (m.external / 1e6).toFixed(0),
    arrayBuffers: (m.arrayBuffers / 1e6).toFixed(0)
  })
}, 30_000)

如果 heapUsed 稳定而 rss 一直涨,问题在堆外——最常见的是 Buffer 没释放,或者 sharp、better-sqlite3 这类原生模块的对象没被正确回收。

如果 heapUsed 一直涨,才是典型的 JS 堆泄漏。

第二层:两次快照对比

node --inspect server.js
# 或者给运行中的进程发信号
kill -USR2 <pid>

用 Chrome DevTools 连上(chrome://inspect),在 Memory 面板拍两次快照,间隔几分钟,选择「Comparison」视图。

看 Delta 列排序:增长最多的构造函数就是嫌疑对象。

常见结果:

  • Array 增长巨大 → 某个全局数组一直 push;
  • Closure 增长 → 事件监听器没解绑;
  • Map / Set 增长 → 缓存没淘汰;
  • 业务对象(如 User)增长 → 某处把对象挂在全局了。

第三层:五类常见泄漏

1. 全局缓存无上限

const cache = new Map()
function get(key, load) {
  if (!cache.has(key)) cache.set(key, load(key))
  return cache.get(key)
}

请求参数一多,这个 Map 会无限增长。修法是加 LRU 上限,或者用 WeakMap(键是对象时)。

2. 事件监听器累积

// 每次请求都加一个监听
req.on('close', () => cleanup())

单次请求没问题,但如果监听加在长生命周期的对象上(process、server、全局 EventEmitter),就会累积。Node 默认在超过 10 个监听时警告 MaxListenersExceededWarning——这个警告不要忽略。

3. 定时器没清

function poll() {
  const timer = setInterval(fetchData, 1000)
  // 忘了 clearInterval
}

定时器持有回调,回调持有作用域,作用域持有大量对象。一个没清的 interval 能拖住几十 MB。

4. 闭包持有大对象

function createHandler(bigData) {
  return () => console.log(bigData.length)   // 一直持有 bigData
}

只要 handler 活着,bigData 就回收不掉。解法是在用完的地方把它置空,或者只提取需要的字段。

5. Buffer 切片

const chunk = bigBuffer.slice(0, 100)   // 共享底层内存

slice 不复制数据,返回的视图持有整个 bigBuffer。100 字节的视图让 10MB 的 Buffer 无法回收。用 Buffer.from(bigBuffer.subarray(0, 100)) 复制一份。

定位之后怎么验证

改完不要急着上线。用压测验证:

  1. 记录改前的基线(跑 1 小时,内存曲线);
  2. 改后同样压测;
  3. 对比「稳态内存」和「是否回落」。

稳态内存下降 = 有效;压测结束后能回落到基线 = 真修好了;仍然单调上升 = 还有别的地方。

预防手段

  • 给所有缓存设上限,哪怕是「绝对不会超」的缓存;
  • 给每个 setInterval 配一个 clearInterval,写在同一个函数里;
  • 长生命周期对象的监听器要成对出现(on/off 写在一起);
  • 线上开启 --max-old-space-size,让泄漏表现成可观测的重启,而不是拖垮整台机器;
  • 加一条内存告警:RSS 超过阈值持续 10 分钟就通知。

最后一条最实用。等用户反馈「服务变慢」时,往往已经泄漏几天了。

收尾

内存问题排查的顺序是:先分清堆内堆外,再对比快照,最后对照五类常见模式。

顺序错了会浪费很多时间——比如对着堆外的问题翻 JS 代码,怎么都找不到。

你认为这篇文章怎么样?

还没有表情反馈

分享文章