2022 年第四季度的一天,研发用户群里陆续有人反馈平台无法访问,后台堆着大量没有完成的异步任务。服务没有立即崩溃,但响应越来越慢。打开 Grafana 后,最显眼的不是某一次尖峰,而是 heapUsed 从 10 点左右开始持续抬升,经历多轮 GC 也没有回到原来的基线。
这类问题最难的地方不是知道“内存泄漏”这个名词,而是从无数接口、定时任务和中间件里,找到究竟是哪类对象被谁持有。本文记录的是我当时真实的排查路径。原文最早发布于知乎,这里重新整理了证据链、代码边界和生产操作风险。
图 1:监控负责发现“什么时候开始异常”,堆快照负责回答“哪些对象为什么还活着”。
先证明它是泄漏,而不是内存高
Node.js 进程占用 500 MB 内存并不自动等于泄漏。V8 会为了减少频繁申请内存而保留已经扩张的堆,RSS 也包含堆外缓冲区、原生模块和线程栈。真正需要警惕的是:在相同负载下,执行完整 GC 后的存活对象基线仍然一轮轮上升。
process.memoryUsage() 暴露了几个容易混淆的指标:
| 指标 | 含义 | 排查时怎么用 |
|---|---|---|
rss |
进程常驻物理内存 | 判断容器是否接近限制,但不能单独证明 V8 堆泄漏 |
heapTotal |
V8 当前申请的堆容量 | 会随运行扩张,不等于实际存活对象 |
heapUsed |
V8 堆中已使用的内存 | 观察 GC 后基线是否持续上升 |
external |
V8 管理但位于堆外的内存 | Buffer、C++ 对象异常时重点关注 |
arrayBuffers |
ArrayBuffer 与 SharedArrayBuffer | 大量二进制处理场景中用于拆分 external |
function sampleMemory() {
const bytes = process.memoryUsage();
return Object.fromEntries(
Object.entries(bytes).map(([name, value]) => [name, Math.round(value / 1024 / 1024)]),
);
}
setInterval(() => {
console.info("memory_mb", sampleMemory());
}, 30_000).unref();原来的监控图里,heapUsed 与进程内存都在上涨,活动句柄数也从一百多个持续涨到四百多个。句柄这条线后来成为关键旁证。

图 2:内存上涨并非孤立信号;活动句柄同步增长,把范围指向了网络连接或事件资源。
先分全局泄漏与局部泄漏
我会先问一个简单问题:随便压一个最小接口,内存是否也会稳定上涨?
如果任何请求都能触发,泄漏大概率位于全局中间件、日志、请求上下文或公共组件。此时二分法很有效:关闭一半公共逻辑,用同一流量模型重测;根据结果继续对剩余范围二分。2020 年排查 Nuxt SSR 服务时,我就用这种办法在半小时左右把问题定位到 HTTP 客户端相关代码。
如果只有特定接口、异步任务或业务操作才触发,它就是局部泄漏。局部泄漏不能靠随意注释公共代码解决,需要把故障时间、发布记录、请求路径和任务类型对齐。
| 现象 | 优先怀疑 | 第一种验证方式 |
|---|---|---|
| 任意接口压测都上涨 | 中间件、全局缓存、公共 SDK | 最小接口 + 二分关闭公共逻辑 |
| 某个接口调用后上涨 | 请求闭包、事件监听、未释放连接 | 固定调用次数并比较前后快照 |
| 某类任务执行后上涨 | 队列实例、定时器、子进程 | 按任务类型拆分活动句柄与堆对象 |
heapUsed 稳定但 RSS 上涨 |
Buffer、原生模块、碎片 | 同时观察 external、系统分配与原生依赖 |
这次问题无法被最小接口复现,只在某批后台任务运行后出现,因此我按局部泄漏处理。
用时间线缩小功能范围
最理想的状态,是能确定泄漏从哪个发布版本开始。假设一个迭代只上线了 A、B、C 三个功能,就应该先在这三个变更里复现,不要一上来扫描整个仓库。
这次发现得比较晚,只能把初次出现时间缩到大约一个月,期间又跨过一个大版本。仅靠 Git 提交无法快速定位,所以我把以下信号放到同一条时间线上:
- QPS、状态码和请求路径;
- 平均响应时间与事件循环延迟;
- 进程重启次数、CPU 和 Node.js 版本;
rss、heapUsed、external与可用堆;- 活动请求数和活动句柄数;
- 同期发布的异步任务与依赖变更。
这里只有堆快照是不够的。快照告诉你某一刻有什么对象,却不告诉你那个对象对应哪次业务动作。监控时间线把晦涩的对象变化重新接回了真实操作。
采集至少三份可比较的堆快照
一份 Heap Snapshot 只能描述一个时刻。为了判断哪些对象“只增不减”,至少需要基线、触发后和 GC 后三份快照。当时为了看清趋势,我实际采了五份。
一个可重复的采集序列是:
- 服务启动并预热后,手动 GC,采基线快照;
- 按固定次数执行可疑任务;
- 内存上涨一个明确区间后,再次 GC 并采快照;
- 重复任务与采集,形成对象数量的时间序列;
- 修复后用完全相同的负载和采集点回归。
如果使用 heapdump 包,可以通过受控的运维信号触发,而不是暴露一个普通 HTTP 接口:
import heapdump from "heapdump";
process.on("SIGUSR2", () => {
const file = `/tmp/service-${process.pid}-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(file, (error, filename) => {
if (error) console.error("heap_snapshot_failed", error);
else console.info("heap_snapshot_written", { filename });
});
});生产环境采快照有两个不能省略的前提:权限隔离,以及流量切走。采集时主线程会停顿,快照越大,停顿越长;当时我们让主、备两个 Pod 配合,先把业务流量迁移到备用实例,再在目标 Pod 上采集。快照文件本身也可能包含业务数据,下载、存储和删除都应走受控流程。
不要等到容器已经贴近 OOM 临界点才开始采集。写快照本身需要额外内存和时间,太晚可能一份也拿不到。

图 3:快照必须按同一操作序列采集,否则 Comparison 中的增量没有可比性。
从“什么在增长”走到“谁在持有”
在 Chrome DevTools 的 Memory 面板中,我通常按三个视图逐层收窄:
Summary:先看保留量和对象数量
Summary 用构造函数聚合对象。我先按 Retained Size 排序,再观察对象数量是否随每份快照稳定增长。除了业务类名,还会特别关注 TCP、Socket、EventEmitter、Timeout、Buffer 和全局对象。

图 4:业务对象不是唯一线索,底层资源对象常常更接近泄漏类型。
Comparison:只看两次操作之间的净增量
Comparison 比较两份快照的 # New、# Deleted 和大小变化。一次任务创建一批对象很正常;任务结束并 GC 后,对象仍在每轮净增长才异常。


Retainers 与 Containment:沿引用链找到所有者
找到异常对象后,不要停在类名上。继续看 Retainers,回答“是谁让它不能被 GC”。Containment 则适合观察从 GC Root 到目标对象的层级关系。

图 7:真正有用的不是看到一个 Socket,而是确认它通过哪条引用链仍然可达。
根因:把进程级队列当成任务级对象
把五份快照放在一起后,TCP、Socket 和 EventEmitter 的数量会随任务执行持续增加;Grafana 里的活动句柄也同步上升。再回看故障时间窗内的功能,范围收敛到了 Bull 消息队列。
问题不是 Bull 自身“必然泄漏”,而是生命周期使用错了:业务流程中频繁创建队列实例,却没有关闭底层 Redis 连接和事件监听。队列通常应该是进程级长生命周期对象。如果确实创建临时实例,就必须明确释放。
容易出问题的形态类似这样:
async function enqueueReport(input: ReportInput) {
const queue = new Queue("report", { redis: redisOptions });
await queue.add(input);
// 函数结束了,但 queue 持有的连接与监听器还活着。
}修复后的边界是:实例只创建一次,进程退出时统一关闭。
const reportQueue = new Queue("report", { redis: redisOptions });
export function enqueueReport(input: ReportInput) {
return reportQueue.add(input);
}
async function shutdown(signal: string) {
console.info("queue_shutdown", { signal });
await reportQueue.close();
process.exit(0);
}
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));具体关闭 API 会随 Bull/BullMQ 版本变化,但原则不变:连接、定时器、子进程、文件描述符和事件监听器都有所有者,也必须有明确的结束时机。这与我在Node.js 优雅退出里处理的问题同源——资源按依赖方向逆序关闭,进程才能安全离开。

回归验证要复用同一负载模型
“代码改了,内存暂时没涨”不能算结案。我们重新执行了与故障复现相同的任务次数、并发和观察周期,验证四件事:
- GC 后
heapUsed回到稳定区间; - 活动句柄数不再随任务次数线性增长;
- Comparison 中相关对象的净增量接近零;
- 服务吞吐、错误率和事件循环延迟没有因修复恶化。
如果测试前后的请求结构不同,曲线就没有可比性。内存问题的验收标准应该在修复前写下来,而不是看完新曲线后再解释。
我后来保留的排查清单
常见泄漏来源可以记住,但不要拿清单代替证据:
- 无边界全局缓存;
- 未清理的 EventEmitter 监听器;
- 持有大对象的闭包;
- 没有关闭的 Socket、文件、队列和子进程;
- 不再需要但仍被定时器引用的上下文;
- 原生模块或 Buffer 导致的堆外增长。
我现在处理这类问题的顺序很固定:先从监控确认趋势,再判断全局还是局部;用时间线缩小变更范围;按同一业务动作采至少三份快照;从对象增量沿 Retainers 找到所有者;最后用同一套压测做回归。
堆快照不是一个会直接指出代码行的答案机器。它更像现场取证:单独一张图很难说明问题,但时间、指标、对象数量、引用链和代码生命周期彼此印证后,根因就不再只是猜测。
这次排查的经验后来被固化成一份 Runbook。它固定了判定条件、采样顺序和生产风险:先看 GC 后基线与活动句柄;锁定发布时间窗;快照至少三份;采集前切流量并确认磁盘;快照按敏感数据管理;修复后复用同一负载。判断标准要先于采样写下来,否则拿到新曲线后再解释,任何结论都成立。
Runbook 没有停在文档层。每季度我们会在测试环境制造一次可控句柄泄漏,让值班同学从告警一路走到 Retainers。只有另一位工程师也能按文档独立完成定位,这次昂贵的排查经验才算真正留在团队里,而不是锁在当次参与者的记忆中。