Skip to content
返回质量保障

内存泄漏检测

找和修应用内存随时间无界增长最终崩的内存泄漏。

为什么

内存泄漏是慢杀手——应用跑数小时或数天正常,然后突 OOM(内存不足)崩,重启丢在途请求和用户会话。在长跑服务器或 SPA 中,每个泄漏引用(未移事件监听、捕获增长数组的闭包、无驱逐缓存)累积直到进程耗尽堆。最糟的是泄漏在正常测试中不可见——须负载下跑数小时见增长,那时根因埋在百万分配中。早检测泄漏用堆快照和增长监控防 3am 崩和随后的用户可见中断。

何时使用

长跑进程(服务器、worker、SPA)监控显内存增长时。也作任何持续运行服务的发布前检查。加缓存、事件监听或订阅模式后测泄漏。

如何做

1. 用监控确认泄漏
   - APM 中随时间追踪进程内存(RSS、堆用)
   - 泄漏显稳增长永不平台即使空闲
   - 排正常增长(缓存预热、JIT 编译)——30 分钟后平台吗?

2. 在受控环境复现泄漏
   - 循环跑可疑操作(如创建和销毁 10000 请求)
   - 迭代间强制垃圾回收(node --expose-gc、global.gc())
   - 若 GC 后仍增长有泄漏——保留引用阻收集

3. 取堆快照比较
   - Node: 用 inspector——node --inspect,然后 Chrome DevTools → Memory → Heap snapshot
   - 取快照 1、跑操作 1000 次、强制 GC、取快照 2
   - 比快照 2 和 1——"delta" 显分配未释放的
   - 找高保留大小对象: 数组、闭包、DOM 节点、Map/Set

4. 找保留路径
   - 堆快照中找泄漏对象看其 "retainers"
   - 保留者是阻 GC 的引用——事件监听、缓存、闭包
   - 常见模式: addEventListener 无 removeEventListener、setInterval 无 clearInterval、
     无删增长 Map、捕获增长数组闭包

5. 修泄漏并验证
   - 移监听、清 interval、给缓存加驱逐、弱化引用
   - 重跑循环测试——内存应平台非增长
   - 部署看监控——增长曲线应平

示例

// 泄漏: 事件监听未移
class EventEmitter {
  constructor() {
    this.handlers = [];
  }
  on(handler) {
    this.handlers.push(handler);
  }
  off(handler) {
    // BUG: 从不调,故 handlers 永增长
    this.handlers = this.handlers.filter(h => h !== handler);
  }
}

// 检测: 循环跑、强制 GC、测堆
// leak-test.js
const emitter = new EventEmitter();
function runOnce() {
  const data = new Array(10000).fill("x"); // 每迭代 80KB
  const handler = () => console.log(data.length);
  emitter.on(handler);
  emitter.emit("event");
  emitter.off(handler); // 若 off 坏或忘,data 被保留
}

for (let i = 0; i < 10000; i++) {
  runOnce();
  if (i % 1000 === 0) {
    global.gc(); // 强制 GC(--expose-gc)
    const used = process.memoryUsage().heapUsed;
    console.log(`迭代 ${i}: ${(used / 1024 / 1024).toFixed(1)} MB`);
  }
}
// 泄漏输出:  10 → 50 → 110 → 170 MB(线性增长)
// 修复输出:  10 → 12 → 12 → 12 MB(平台)

// 修: 确保 off() 总调,或用 WeakRef / FinalizationRegistry
// 或用 Set 在 off 删,或用 AbortController 管监听生命周期
const controller = new AbortController();
emitter.on(handler, { signal: controller.signal });
// 完成时:
controller.abort(); // 自动移监听、释放闭包

常见问题

  • ⚠循环或每次渲染加事件监听不移——每迭代加新监听捕获其闭包,内存随每请求或渲染周期增长。
  • ⚠用 Map 或对象作缓存无驱逐——缓存随每唯一键增长,一周生产流量后持百万条目耗尽堆。
  • ⚠闭包无意捕获大对象——回调闭包 10MB 请求体即使只需响应状态,体保留到回调被 GC。
  • ⚠setInterval 或 setTimeout 回调引用大对象且不清——定时器保闭包活,保大对象活,远超需要。

最佳实践

  • ✓始终 addEventListener 配 removeEventListener,或用 AbortController 管监听生命周期——controller.abort() 自动移所有以其 signal 注册的监听。
  • ✓用带最大大小的 LRU 缓存(lru-cache npm 包)替普通 Map——缓存满时驱逐最旧条目,无论流量有界内存。
  • ✓负载测试前后取堆快照比 delta——比较立即显分配未释放,分钟内定位泄漏。
  • ✓生产监控进程内存对持续增长告警——永不平台的内存曲线是泄漏,10% 增长抓远易于 90% OOM 崩。