跳到主要内容

Nodejs内存泄漏排查

1. V8 内存模型

进程内存
├── New Space(新生代) ~32MB,频繁 minor GC
├── Old Space(老生代) 长寿对象
├── Code Space JIT 代码
├── Map Space 对象 hidden class
└── Large Object Space > 1MB 对象

启动时设:

node --max-old-space-size=2048 server.js # 老生代上限 2GB
node --max-semi-space-size=64 server.js # 新生代单 space 上限 64MB

容器内:max-old-space-size 设到容器 memory limit 的 75%。

2. 泄漏的典型表现

# 看进程内存
ps aux | grep node | awk '{print $4, $5, $6, $11}'
# %MEM RSS VSZ COMMAND

健康:稳定波动 / 缓慢增长后被 GC 回收。 泄漏:持续单调上升直到 OOM。

// 应用内监控
setInterval(() => {
const m = process.memoryUsage()
logger.info({
rss: m.rss / 1024 / 1024,
heapTotal: m.heapTotal / 1024 / 1024,
heapUsed: m.heapUsed / 1024 / 1024,
external: m.external / 1024 / 1024,
}, 'memory')
}, 30000)

3. 常见泄漏模式

3.1 全局变量 / 闭包

// 全局 cache 无限增长
const cache = {}
app.get('/user/:id', (req, res) => {
cache[req.params.id] = await db.query(...)
// 没清理
})

// 修复:用 LRU
const LRU = require('lru-cache')
const cache = new LRU({ max: 1000, ttl: 1000 * 60 * 5 })

3.2 事件监听器

// ✗ 每次请求注册,不解绑
app.use((req, res, next) => {
process.on('uncaughtException', () => {}) // 每请求加一个
next()
})

// 看监听器数量
emitter.listenerCount('event')
emitter.setMaxListeners(20) // 默认 10,超就警告

3.3 Timer / Interval 没清

// ✗ 类销毁时 timer 还在
class Worker {
constructor() {
this.timer = setInterval(() => this.tick(), 1000)
}
destroy() {
// 忘了 clearInterval
}
}

3.4 大对象长驻

// 全局历史记录无上限
const history = []
app.use((req, res, next) => {
history.push({ req, res }) // res 含整个响应
next()
})

3.5 Promise 链不结束

// 长连接里 Promise 一直挂着
async function watchFile() {
while (true) {
await new Promise(resolve => fs.watch('/log', resolve))
// 永不 resolve 的 Promise + 长时间引用
}
}

4. heapdump 抓堆快照

4.1 用 --inspect

node --inspect=0.0.0.0:9229 server.js
# K8s port-forward
kubectl port-forward pod/myapp 9229:9229

Chrome 打开 chrome://inspect → Connect → Memory tab → "Take heap snapshot"。

4.2 用 v8 模块

const v8 = require('v8')
const fs = require('fs')

function dump() {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`
const stream = v8.getHeapSnapshot()
stream.pipe(fs.createWriteStream(filename))
return filename
}

// SIGUSR2 触发
process.on('SIGUSR2', () => dump())
kill -SIGUSR2 <pid>

把 .heapsnapshot 拖进 Chrome DevTools → Memory 分析。

4.3 对比两次快照

  1. 启动后取 snapshot 1
  2. 跑业务一段时间
  3. 触发 GC(DevTools 垃圾桶按钮)
  4. 取 snapshot 2
  5. Comparison 模式找新增对象

5. clinic.js 堆分析

npx clinic heap -- node server.js
# Ctrl+C 后产出报告

可视化展示每类对象内存增长。

6. 实战排查流程

6.1 现象确认

# 看 Pod 内存增长趋势(Grafana)
container_memory_working_set_bytes{pod="frontend-xxx"}

# 看进程
top -p <pid>
# RES 字段持续涨

6.2 GC 日志

node --trace-gc --trace-gc-verbose server.js
# 看 GC 频率和回收量
# 频繁 full GC 但 heap 不降 = 老生代有不可回收对象

6.3 取堆快照分析

按"3.4 抓堆快照"。

DevTools 里找:

  • Retained Size 大的对象类型
  • "Detached"(已脱离 DOM 但还引用)
  • 数量异常多的对象(Map Array 几百万个)

6.4 修复并验证

修复后再跑相同场景,看快照对象数是否回落。

7. 容器场景

容器内 process.memoryUsage() 是进程数据,但 os.totalmem() 在 Node 18 前是宿主机数据,会误判。

K8s 限制 + Node 设:

resources:
limits:
memory: "2Gi"
node --max-old-space-size=1536 server.js # 1.5G < 2G 留头

OOM Killed 的 Pod:

kubectl describe pod <pod>
# Last State: Terminated
# Reason: OOMKilled

8. 第三方库内存问题

  • mongoose 连接没关
  • axios 没用同一 instance(每请求新 agent)
  • puppeteer browser 没 close
  • 旧版 moment 替换为 dayjs

9. 常见反模式

  • global 当 cache:永不 GC
  • listener 不 off:每次都注册新的
  • 错误处理里 setInterval 重试:失败链堆积
  • 大 buffer 不流式:fs.readFile 整个文件进内存
  • 没设 max-old-space-size:容器 limit 内 V8 还想用更多就 OOM
  • 生产开 --inspect:暴露调试端口
  • 不监控 RSS:等 OOM 才发现

10. 延伸阅读