内存泄漏排查
区分真泄漏与缓存膨胀、池未归还、监听未卸载;先看清堆与 GC 的边界,再用快照与剖析对比增量对象,把引用链写成可协作的备忘。
SKILL 指导 Agent 先确认现象:RSS 与堆外、容器 cgroup 限制、是否 native 或元空间增长。再选择工具(heap dump、allocation profiling、async profiler)。
现象与区分
常驻内存曲线单调上升未必是泄漏:可能是有界但偏大的缓存、预分配池、或堆尚未触发回收。需要把堆内对象增长与堆外 / 线程栈 / 元空间分开看,并排除「流量变大导致合法驻留增多」。
- 禁止在生产全量 dump 超大堆,优先采样、短时窗口或隔离副本。
- 与「并发背压」区分:无界队列堆积也会像泄漏一样涨。
- 输出须含可疑类型名、持有者摘要与建议改动位置。
三类高频泄漏模式(Node.js / 浏览器):
// ❌ 模式1:闭包捕获大对象
function makeHandler(bigData) {
// bigData(可能几MB)被闭包持有,handler 存活则永不 GC
return (event) => console.log(bigData.payload, event.type);
}
// ✅ 修复:只留必要字段
function makeHandler(bigData) {
const id = bigData.id;
return (event) => console.log(id, event.type);
}
// ❌ 模式2:事件监听未移除(浏览器 / Node EventEmitter)
class Widget {
mount() {
this._handler = this.onResize.bind(this);
window.addEventListener("resize", this._handler);
}
// 忘记 destroy → _handler 持有 this → Widget 无法 GC
destroy() {
window.removeEventListener("resize", this._handler); // ✅ 必须
}
}
// ❌ 模式3:全局 Map/对象无限增长
const reqCache = new Map();
app.use((req, res, next) => {
reqCache.set(req.id, req.body); // 永不删除,内存单调增
next();
});
// ✅ 修复:引入 LRU + TTL
const { LRUCache } = require("lru-cache");
const reqCache = new LRUCache({ max: 1000, ttl: 5 * 60 * 1000 });
堆与 GC
JVM:关注老年代 / G1 Humongous、Metaspace(类加载泄漏)、直接内存(NIO、Netty)。Full GC 后堆仍不降,才更像真泄漏或永久驻留集合。仅被 WeakReference 等弱引用持有的对象可被回收,incoming 里若只有弱引用链,通常不算泄漏根因。
Node / V8:新生代 Scavenge 与老年代 Mark-Compact 节奏不同;Buffer、TypedArray 可能走堆外。长期增长的 Map、闭包捕获的请求上下文、未 removeListener 是高频来源。
浏览器:Detached DOM、未撤销的 requestAnimationFrame、全局单例上的监听器,会在 Performance → Memory 的堆快照中表现为保留子树。
Node.js 内存指标采集与 heapdump 触发:
// 1. 实时打印内存指标(RSS / heapUsed / heapTotal / external)
function logMemory(label) {
const m = process.memoryUsage();
console.log(`[${label}]`, {
rss: (m.rss / 1024 / 1024).toFixed(1) + " MB", // 进程总 RSS
heapTotal: (m.heapTotal / 1024 / 1024).toFixed(1) + " MB", // V8 已申请堆
heapUsed: (m.heapUsed / 1024 / 1024).toFixed(1) + " MB", // 实际使用堆
external: (m.external / 1024 / 1024).toFixed(1) + " MB", // Buffer/TypedArray
});
}
setInterval(() => logMemory("tick"), 10_000);
// 2. --expose-gc + 手动触发 GC 后对比
// 启动: node --expose-gc server.js
if (global.gc) {
logMemory("before-gc");
global.gc();
logMemory("after-gc"); // 若 heapUsed 未降则疑似泄漏
}
// 3. 生成堆快照(v8 内置,Node 11.13+)
const v8 = require("v8");
const path = require("path");
app.get("/debug/heap", (req, res) => {
const file = v8.writeHeapSnapshot(
path.join("/tmp", `heap-${Date.now()}.heapsnapshot`)
);
res.json({ file }); // 下载后在 Chrome DevTools 分析
});
// 4. 告警阈值参考(按实际 RSS limit 调整)
// heapUsed > heapTotal * 0.85 → 告警
// RSS > container_limit * 0.80 → 告警
// GC 占用 CPU > 20% → 告警(性能问题)
剖析与快照
堆快照对比:在相同业务步骤前后各采一次,按类或包聚类看 delta 与 retained size;对最大增量展开 incoming references,追到 GC root 路径上的「谁一直握着」。
分配剖析:Allocation profiling / async-profiler 的 alloc 模式可指向「谁在短时间内造了大量短生命周期对象」;与泄漏(长生命周期持有)互补——有时要先压掉分配热点再观察曲线。
系统层:结合 pmap、容器内存指标、native 内存追踪,判断问题是否在堆外或 JNI。
Chrome DevTools 三次快照法(浏览器 / Node Inspector):
// === Chrome DevTools 三次快照法操作步骤 ===
// 1. 打开 DevTools → Memory → Heap Snapshot
// 2. 快照 S1(基线)
// 3. 执行可疑操作(如:导航到某页 → 返回,重复 5 次)
// 4. 快照 S2
// 5. 再重复操作(重复 5 次)
// 6. 快照 S3
// 7. 选 S3 → 顶部切换到 "Comparison" 模式,基准选 S1
// 8. 按 #Delta(新增对象数)降序排列 → 展开最大增量类
// 9. 选中实例 → 底部 "Retainers" 面板 → 追 GC Root 路径
// Node.js 使用 --inspect 打开 Chrome 远程调试:
// node --inspect server.js
// 浏览器打开 chrome://inspect → 点 "Open dedicated DevTools for Node"
// === 快速 CLI 对比法(heapdump-diff) ===
// npm install -g heapdump
const heapdump = require("heapdump");
// 信号触发快照(生产中推荐)
process.on("SIGUSR2", () => {
heapdump.writeSnapshot("/tmp/heap-" + Date.now() + ".heapsnapshot",
(err, file) => console.log("snapshot:", file));
});
// 触发: kill -SIGUSR2 <pid>
// === 引用链追踪关键词 ===
// Retainers 面板中:
// system / Context → 闭包作用域
// (global) → 全局变量
// EventListener → 未移除的监听器
// Detached ... → 已从 DOM 断开但仍被 JS 持有
排查流程
[ 确认:RSS / 堆 / 堆外 / 元空间 / cgroup 限制 ]
│
▼
[ 区分:真泄漏 vs 缓存·池·流量合法增长 ]
│
▼
[ 取两次快照或时间序列趋势 ]
│
▼
[ 按 retained / delta 排序 → 展开 incoming ]
│
┌────────┴────────┐
▼ ▼
[ 追到根引用持有者 ] [ 分配剖析找制造者 ]
│ │
└────────┬────────┘
▼
[ 修复:dispose / 弱引用 / 限时缓存 / 背压 ]
│
▼
[ 压测或 soak → 曲线变平 + 告警 ]
与 Agent 协作
给 Agent 的上下文应包含:运行时(JVM 版本 / Node 版本)、采样时间窗、两张快照或指标截图中的具体类名与 retained 数字。要求它先复述引用链再写补丁,避免无证据地猜「可能是缓存」。
修复后验证:限时 soak、对比修复前后同类实例计数;必要时加堆使用率与 GC 频率告警,而不是只盯 RSS。
内存指标监控与告警示例(Prometheus + Node.js):
// Prometheus 指标上报(prom-client)
const client = require("prom-client");
const heapUsedGauge = new client.Gauge({
name: "nodejs_heap_used_bytes",
help: "V8 heap used",
});
const rssGauge = new client.Gauge({
name: "nodejs_rss_bytes",
help: "Resident Set Size",
});
setInterval(() => {
const m = process.memoryUsage();
heapUsedGauge.set(m.heapUsed);
rssGauge.set(m.rss);
}, 5000);
// Prometheus alerting rule(告警阈值参考)
// groups:
// - name: nodejs-memory
// rules:
// - alert: HeapUsageHigh
// expr: nodejs_heap_used_bytes / nodejs_heap_size_total_bytes > 0.85
// for: 5m
// annotations:
// summary: "Heap usage > 85% for 5 min"
// - alert: RSSGrowthSteady
// expr: increase(nodejs_rss_bytes[30m]) > 100 * 1024 * 1024
// annotations:
// summary: "RSS grew > 100 MB in 30 min — possible leak"
// 指标说明:
// rss = 进程占用物理内存总量(含堆、栈、代码段、堆外 Buffer)
// heapTotal = V8 已向 OS 申请的堆大小(含空闲页)
// heapUsed = V8 已分配对象占用的堆大小(关键监控项)
// external = C++ 层 Buffer / TypedArray 等堆外内存
// arrayBuffers = ArrayBuffer / SharedArrayBuffer 大小(含于 external)
引用链备忘拼装
从 MAT、Chrome Heap Snapshot、YourKit 等工具里抄下关键节点后,填入下方字段,生成一段可粘贴的备忘;内容仅保存在本页浏览器(localStorage),不上传。
---
name: memory-leak
description: 内存泄漏排查:堆快照、引用链与监控告警
---
# 前置确认
- 运行时版本(Node.js / JVM)与容器 cgroup 内存上限
- 确认是堆增长而非 RSS 外因(堆外 Buffer、元空间、JNI)
- 排除「流量增大导致合法驻留增多」
# 工具选择
- Node.js:node --expose-gc + v8.writeHeapSnapshot() 或 heapdump
- 浏览器:Chrome DevTools → Memory → Heap Snapshot
- JVM:jmap -dump / async-profiler -e alloc / MAT
# 三次快照法步骤
1. 快照 S1(基线)
2. 执行可疑业务操作 × 5
3. 快照 S2
4. 再执行 × 5
5. 快照 S3 → Comparison 对比 S1
6. 按 #Delta 降序 → 展开最大增量 → 追 Retainers 到 GC Root
# 常见根因模式
- 闭包捕获大对象(只取必要字段)
- 事件监听未 removeListener(组件 unmount 时清理)
- 全局 Map/对象无限增长(LRU + TTL 替换)
- setInterval / RAF 未取消
# 告警阈值参考
- heapUsed / heapTotal > 85% 持续 5min → 告警
- RSS 30min 增长 > 100 MB → 告警
- GC CPU 占用 > 20% → 性能告警
# 修复验证
- soak 测试 30min,对比修复前后同类实例计数
- 在 Prometheus/Grafana 上确认曲线变平