作者在线上 Node.js 内存泄漏导致 OOM 后,放弃盲目猜测,改用 Claude Code 系统化分析堆快照,1 小时内定位根因并修复。
凌晨 2 点,一次缓慢的内存泄漏导致我的一台生产服务宕机。前一个小时我一直在猜测,接下来的一个小时才开始真正修复——直到我停止猜测,开始用 Claude Code 系统化地分析堆快照。以下是事情的全貌,以及我从这次经历中学到的 4 个教训,关于如何用 AI 编程 Agent 做真实的生产环境调试,而不是玩简单的示例。
一切始于一条 Slack 告警:某 Node.js 服务的内存使用量在稳步攀升,没有平台期,没有 GC 回收。典型的缓慢泄漏。这种泄漏前六个小时没事,然后恰好在凌晨 2 点左右,越过临界点导致 OOM kill,进入崩溃循环。
我之前也修过泄漏,但都是简单的那种——明显的无界缓存、忘记清除的 setInterval。这次不一样,问题并不显而易见。这套服务运行了两年多,有十几位贡献者,而且泄漏只在真实流量模式下才会出现,我没法在本地轻易复现。
我的第一反应是直接让 Claude Code "找出内存泄漏"。结果和你想的一样——它读遍了代码库,找到了三个看起来合理的候选(一个可能没有被清理的事件监听器、一个没有驱逐策略的缓存、一个捕获了大对象的闭包),然后以大致相同的置信度全部列出。实际原因一个都不是。
这就是陷阱。一个擅长阅读代码的 Agent 总是能找出一些看起来像泄漏的东西,因为大多数有一定规模的代码库里都躺着几个可疑模式。"看起来合理"不等于"正确",我差点就对一个错误的原因上了"修复"。
我实际上在闭包候选上浪费了将近一个小时。它是看起来最"有意思"的一个——一个捕获了大请求对象的回调——所以 Agent 和我都先被它吸引了。我们打了补丁,部署到 staging,观察了 20 分钟内存,内存依然以基本相同的速率攀升。就在那一刻我突然意识到,我们是在对"看起来可能泄漏的代码"做模式匹配,而不是在找"真正在泄漏的代码",这是两种完全不同的搜索。
修复不是更聪明的 prompt,而是给 Agent 真实的data去工作,而不是只让它从源代码推理。
我用内置的 Node inspector 从运行中的进程采集了两个堆快照,间隔 20 分钟:
node --inspect=9229 server.js
# 然后,从 Chrome DevTools -> Memory 标签页,或者用程序方式:
node -e "
const v8 = require('v8');
const fs = require('fs');
fs.writeFileSync('heap-1.heapsnapshot', v8.getHeapSnapshot());
"
两份快照很关键,因为单份快照只能告诉你什么是活着的——它没法告诉你什么在增长。两份快照之间的 diff 才是泄漏真正暴露的地方。
我把两份快照加载到 Chrome DevTools 的对比视图,把保留大小增量最大的前几个导出为 JSON,然后连同相关的源文件一起交给 Claude Code:
这是一份在稳定负载下 t=0 和 t=20min 之间的堆快照 diff。
按保留大小增长排名前 5 的构造函数:
1. Array (+340MB, +1.2M 对象)
2. RequestContext (+180MB, +40k 对象)
3. Closure (+90MB)
...
将这些与源代码交叉对照,告诉我哪个分配点
与这种增长模式一致。
这完全改变了对话。之前是"这里有三个看起来可疑的东西",现在变成了:"RequestContext 在 20 分钟内增长了 40k 对象,几乎和你请求量完全对齐——这个对象应该是请求级别短生命的,如果它被保留了,说明有什么东西在响应生命周期之后还持有引用。让我们检查是什么在抓着它。"
真正的 bug:一个请求级别的 logger 被推进了一个模块级别的数组,用于"调试回放"——这是之前处理某事故时加的,但没有人移除清理步骤——而且那个清理步骤本身有个 bug:它只在正常路径执行,早返回时不运行。每条走到早返回分支的请求(约 15% 的流量)都会让它的 RequestContext 永远泄漏。
// 修复前 — bug 所在
function handleRequest(req, res) {
const ctx = new RequestContext(req);
debugReplayBuffer.push(ctx);
if (shouldShortCircuit(req)) {
return res.status(304).end(); // ctx 永远不会被从 buffer 中移除
}
// ...正常处理...
cleanupContext(ctx); // 只在"正常"路径才会到达
}
// 修复后 — cleanup 无论走哪个出口都会执行
function handleRequest(req, res) {
const ctx = new RequestContext(req);
debugReplayBuffer.push(ctx);
try {
if (shouldShortCircuit(req)) {
return res.status(304).end();
}
// ...正常处理...
} finally {
cleanupContext(ctx);
}
}
Claude Code 找到了真正的 finally 形修复方案——一旦有了 retainer 证据供它推理——它不是在猜测"在某个地方加个 finally 块",而是从快照 diff 追踪到了这个确切的对象标识,一路回到这一个函数。
这一步真正有效的不只是修复本身——而是 Agent 能指着 diff 中的确切类名(RequestContext)去 grep 这个类被构造和存储的每个地方。这个搜索人类当然可以手动做,但很繁琐,而且一旦找到一个看起来合理的点就容易提前停止。把这个 grep 和交叉参照的苦活交给 Agent,以构造函数名作为锚点——这才是大部分实际时间节省的来源——而不是因为 Agent 有什么 profiler 没有的特殊洞察力。
我不相信"看起来对了"——部署到 canary,在负载下等 20 分钟,采第三份堆快照,和健康基线做 diff。增长曲线平了。那时候我才真正相信修复有效。
flowchart LR
A[Alert: memory climbing] --> B[Snapshot at t0]
B --> C[Snapshot at t0+20min]
C --> D[Diff: top growing retainers]
D --> E[Agent cross-references diff + source]
E --> F[Fix + finally-block cleanup]
F --> G[Canary deploy]
G --> H[Third snapshot confirms flat growth]
仅从源代码"找 bug"对生产问题是糟糕的 prompt。对代码做静态推理总会列出看起来合理的候选,因为大多数代码库都有好几个。把真实的运行时证据——堆 diff、profiler 输出、请求 trace——喂给 Agent,搜索空间会迅速收窄。
仅从源代码"找 bug"对生产问题是糟糕的 prompt。对代码做静态推理总会列出看起来合理的候选,因为大多数代码库都有好几个。把真实的运行时证据——堆 diff、profiler 输出、请求 trace——喂给 Agent,搜索空间会迅速收窄。
两份快照永远比一份好。单份堆快照是一张照片。负载下两份快照之间的 diff 是一段视频。如果你只采一份,你是在让 Agent(和你自己)在没有时间维度的东西上找增长。
两份快照永远比一份好。单份堆快照是一张照片。负载下两份快照之间的 diff 是一段视频。如果你只采一份,你是在让 Agent(和你自己)在没有时间维度的东西上找增长。
不要在不经过重新部署+再验证循环的情况下直接发布第一个"与数据一致"的理论。Agent 的解释听起来立刻就是对的,直接合并很诱人。但 canary + 第三份快照这一步才是真正区分"听起来对"和"就是对"的——那个验证步骤是你自己的责任,不是 Agent 的。
不要在不经过重新部署+再验证循环的情况下直接发布第一个"与数据一致"的理论。Agent 的解释听起来立刻就是对的,直接合并很诱人。但 canary + 第三份快照这一步才是真正区分"听起来对"和"就是对"的——那个验证步骤是你自己的责任,不是 Agent 的。
Agent 非常擅长将 retainer 数据与源代码交叉参照,但事件本身还是你在负责。决定采什么数据、何时修复可以安全发布、拿什么做 canary 对照——那个判断没有转移。改变的是我从"三个合理猜测"到"一个已验证的根本原因"能走多快。
Agent 非常擅长将 retainer 数据与源代码交叉参照,但事件本身还是你在负责。决定采什么数据、何时修复可以安全发布、拿什么做 canary 对照——那个判断没有转移。改变的是我从"三个合理猜测"到"一个已验证的根本原因"能走多快。
把调试专用的脚手架代码(比如那个回放 buffer)看紧一点。根本原因甚至不是"真正的"功能代码——而是过去某次事故时的调试辅助工具悄悄地变成了永久泄漏。如果你非要把临时 instrumentation 留在 prod,给它加个过期时间。
把调试专用的脚手架代码(比如那个回放 buffer)看紧一点。根本原因甚至不是"真正的"功能代码——而是过去某次事故时的调试辅助工具悄悄地变成了永久泄漏。如果你非要把临时 instrumentation 留在 prod,给它加个过期时间。
我还想加一个更软的第六条教训:留意你开始把 Agent 第一个合理的答案当作答案的那一刻。我们追了一个小时的闭包候选不是个坏猜测——它是个合理的猜测,只是包装得细节十足,以至于在还没被验证的情况下就感觉像被验证了一样。回头看,信号就是:在真正投入之前,我们其实都没有看过真实的增长数据。一旦"我们查过 diff 了吗"成为"我们来修这个吧"之前的硬门槛,误报就基本停止了。
我正在把快照 diff 加源代码交叉参照这个流程做成团队可重复使用的 checklist,因为"直接让 Agent 找泄漏"正是那个让我浪费了第一个小时的失败模式。下一步是尝试把同样的证据优先方法用到 CPU profiling 案例(一个慢端点,不是泄漏)上,看看这个模式在内存之外能不能泛化。
如果你一直在仅凭源代码让你的 AI 编程 Agent 调试生产问题,却得到 plausible-but-wrong 的答案,试着在让它做理论推断之前先给它真实的运行时数据——快照、diff、trace——这是个小改动,但对我帮助巨大。
如果这篇文章有用,欢迎在 Dev.to 上关注我——我会继续记录这些实战经历,有好有坏。