基于 Node.js SSR 应用内存泄漏的实战复盘,主要解决拿到 heapdump 文件却也分析不出内存泄露问题的痛点。这里提出了四轮分析法,主要是给人 + AI结合去使用的
为什么 Heap Snapshot 分析这么难
拿到一个 177MB 的 heapsnapshot 文件,里面有 100 万个节点、370 万条边。你面对的不是"找 bug",而是"在一座城市的下水道网络里找到那个漏水的管道接头"。
难点在于:
-
信息量巨大但信噪比极低。138MB 堆内存中,可能只有 30MB 是泄漏的,剩下的都是正常的运行时开销(V8 编译代码、webpack 模块、框架内部结构)。你需要先学会区分"正常的大"和"异常的大"。
-
泄漏对象本身往往不可疑。一个字符串
"Hello World"看起来完全正常——它就是一条业务数据。问题不在于这个字符串存在,而在于它不应该在这里被长期持有。 -
引用链很深且经过混淆。从泄漏的字符串到真正的根因(
mem包的无界 Map),中间隔了 5-6 层引用,而且 closure 名字被mimic-fn改成了"l",源码路径指向打包后的 chunk 文件。
本文记录的是一套经过验证的、可复现的分析方法「四轮分析法」,核心思路是:逐层缩小范围,每一轮只回答一个问题。
这个方法主要是给人 + AI 结合使用的,heap dump 数据量大,人眼往往很难直接看出问题,要利用这个分析思路,让 AI 通过脚本去统计和分析
如果你只想快速修复问题,仅看本章节即可
这里将后文的思路封装成了一个 skill:
把这个 skill 复制到你的编辑器 skills 目录,就可以让 ai 按照这个思路进行分析了
第一轮:全局概览——"内存都花在哪了?"
目标
不要一上来就找泄漏。先建立全局认知:这 138MB 堆内存的构成是什么?哪些是正常开销,哪些是异常的?
方法
对快照做四个维度的统计:
- 按节点类型统计内存分布(string / object / code / array / closure ...)
- 按构造函数名统计 Top 50(哪类对象最多、最大)
- 列出 self_size 最大的单个对象(有没有异常大的对象)
- 列出最长的字符串内容(字符串往往是泄漏的载体)
本次案例的发现
string: 302,124 个, 77.09 MB ← 占了堆的 56%,这正常吗?
code: 196,735 个, 25.79 MB ← V8 编译代码,Next.js 项目正常
array: 18,824 个, 12.35 MB ← 需要看看是什么 array 这么大
object: 167,287 个, 7.53 MB ← 正常
77MB 的字符串占了堆的 56%。对于一个 SSR 渲染的内容型应用来说,这个比例偏高。正常的 Next.js 应用,字符串占比通常在 30-40%。
但此时还不能下结论。77MB 字符串里可能大部分是 webpack 打包的源码字符串(这是正常的)。需要进一步分解。
关键思维
第一轮的目的不是找到答案,而是找到"下一步该往哪里挖"。
你需要的是一个"异常信号"——某个数字看起来不太对劲。在本次案例中,信号是"77MB 字符串"。
第二轮:分解异常区域——"77MB 字符串里都是什么?"
目标
把第一轮发现的异常区域(77MB 字符串)拆开看,区分正常内容和可疑内容。
方法
对所有字符串按内容特征分类:
- 包含
webpack/__webpack_require__的 → webpack 模块源码(正常) - 包含
<p>/<img>/<div>等 HTML 标签的 → HTML 内容(可疑) - 包含
{开头的 JSON 结构 → API 响应数据(可疑) - 短字符串(< 100 字符)→ 变量名、属性名等(正常)
- 以
["开头的 → JSON.stringify 的结果(可疑)
本次案例的发现
webpack 模块源码: ~13 MB ← 正常,Next.js SSR 需要在服务端持有模块代码
HTML 内容字符串: ~17 MB ← 异常!为什么服务端要长期持有业务 HTML?
JSON 包装字符串: ~13 MB ← 异常!大量 '["业务内容..."]' 格式的字符串
短字符串/属性名: ~34 MB ← 正常运行时开销
17MB 的 HTML 内容 + 13MB 的 JSON 包装字符串,合计 30MB。这些字符串的内容是用户生成的业务数据(UGC 内容)。
关键发现:JSON 包装字符串的格式是 '["原始内容"]',这是 JSON.stringify(["原始内容"]) 的结果。说明有某个地方在用 JSON.stringify 作为 cache key。
关键思维
到这一步,你已经知道"泄漏的是什么"(业务 HTML 内容),但还不知道"谁在持有它"。
JSON.stringify 格式的字符串是一个重要线索——它暗示了某种缓存机制在用 JSON.stringify 做 key。
第三轮:追踪引用链——"谁在持有这些字符串?"
目标
这是整个分析中最关键的一步。找到泄漏字符串的 retainer(持有者),沿着引用链往上追溯,直到找到根对象。
方法
Heap snapshot 中每个节点都有 edge(边)信息,记录了"谁引用了谁"。追踪引用链的方法:
- 选取几个典型的泄漏字符串(比如包含业务内容的 HTML 字符串)
- 找到引用这个字符串的父节点(retainer)
- 再找父节点的父节点,逐层往上
- 直到找到一个"不应该存在的长生命周期容器"
本次案例的引用链追踪过程
第 1 层:业务 HTML 字符串 "some user generated content..."
↑ 被谁引用?
第 2 层:Object { data: "some user generated content...", maxAge: Infinity }
↑ 一个包含 data 和 maxAge 字段的对象。maxAge 是 Infinity。
这看起来像某种缓存条目的结构。
第 3 层:Array (155,029 条边)
↑ 一个巨大的数组,包含了 15 万个条目。
这是 V8 中 Map 的内部 hash table 实现。
第 4 层:Map (id=677247)
↑ 一个 Map 对象。15 万个条目的 Map,存储了所有业务内容。
这就是泄漏的容器。
第 5 层:closure "l" (id=605069)
↑ 一个闭包函数,名字被混淆成了 "l"。
它的 context 变量中包含这个 Map。
第 6 层:WeakMap (id=1328353)
↑ 一个 WeakMap,以 closure 为 key,以 Map 为 value。
这是 mem 包的 cacheStore 结构。
关键思维
引用链追踪是体力活,但有几个加速技巧:
看
maxAge: Infinity这个字段——这是mem包的签名特征。如果你熟悉mem的源码,看到{ data, maxAge }结构就能立刻联想到。看 Map 的大小——一个 15 万条目的 Map 在正常业务逻辑中几乎不可能出现。这种"数量异常"是强信号。
看 closure 的 context 变量——即使函数名被混淆了,context 中的变量(如
stringify、匿名 cacheKey 函数)仍然能提供线索。
第四轮:确认身份——"这个 closure 到底是哪段代码?"
目标
第三轮找到了一个名为 "l" 的 closure,但函数名被 mimic-fn 混淆了。需要确认它对应的源码位置。
方法
- 从 closure 节点的属性中找
shared_function_info,它包含源码文件路径和行号 - 检查 closure 的 context 变量,看有没有能识别身份的线索
- 在打包后的 chunk 文件中搜索对应的代码
本次案例的确认过程
closure "l" 的信息:
- 源码位置:
/app/.next/server/chunks/xxx.js(打包后的 chunk 文件) - context 变量:
context::a→ 匿名 closure(这是 cacheKey 函数)context::b→stringify(即JSON.stringify)context::c→ Map (id=677247)(就是那个大 Map)
看到 stringify + cacheKey + Map,再结合 { data, maxAge } 的缓存条目结构,可以确认这就是 mem 包创建的 memoized 函数。
然后在项目源码中搜索 mem( 的调用,找到:
// 示例:使用 mem 包对某个函数做 memoize,但未设置 maxAge
export const memoizedFn = mem(someFunction, {
cacheKey: JSON.stringify // ← context::b 就是这个 stringify
});
至此,根因定位完成:mem 包默认 maxAge: Infinity,缓存永不过期,而 cacheKey 使用 JSON.stringify,导致每个不同的输入都会创建一个新的缓存条目,在 SSR 长生命周期进程中无限增长。
分析中容易走的弯路
弯路 1:一开始就盯着 LRUNode
本次分析中,第一轮概览时 LRUNode(27,184 个)在构造函数名 Top 50 中非常显眼。很容易一头扎进去分析 LRU 相关的问题。
实际上 LRUNode 是 Next.js 内部的路由缓存,虽然也有问题(淘汰机制失效),但只占 ~5MB,不是主要矛盾。
教训:先看总量,再看个体。77MB 字符串才是大头,5MB 的 LRU 是次要问题。
弯路 2:在 Chrome DevTools 里手动翻找
177MB 的快照在 DevTools 里打开就要几分钟,每次操作都卡顿。手动在 Summary/Comparison 视图里翻找,效率极低。
更好的方式是写脚本做批量统计分析。heapsnapshot 文件本质上是一个 JSON,可以用 Node.js 流式解析。
弯路 3:只看 self_size 不看 retained_size
一个 Map 对象的 self_size 可能只有几十字节,但它 retained 的所有 key-value 可能有 30MB。如果只按 self_size 排序,这个 Map 根本排不到前面。
需要同时关注:
- self_size 大的对象(直接占用内存多)
- 子节点/边数异常多的对象(间接持有大量内存)
弯路 4:不理解 V8 的内部表示
V8 的 Map 不是直接存储 key-value 对的。它内部有一个 hash table(表现为 Array 节点),key 和 value 交替存储在这个 Array 中。如果你不知道这一点,看到一个 155,029 条边的 Array 会很困惑。
常见的 V8 内部结构:
Map→ 内部有一个table属性指向 Array(hash table)Set→ 类似 Map,内部也是 hash tableclosure→ 有context属性,存储闭包捕获的变量concatenated string→ V8 对字符串拼接的优化,不会立即创建新字符串,而是创建一个指向两个子字符串的节点sliced string→ 字符串 slice 操作的结果,持有对原始字符串的引用
如何引导 AI Agent 分析 Heap Snapshot
如果快照文件太大无法直接喂给 AI,可以分阶段引导:
阶段 1:让 AI 写分析脚本
我有一个 177MB 的 V8 heap snapshot 文件(.heapsnapshot),
Chrome DevTools 打开太卡了。
请帮我写一个 Node.js 脚本,流式解析这个文件,输出以下统计信息:
1. 按节点类型(string/object/code/array/closure)统计数量和总 self_size
2. 按构造函数名统计 Top 50(按总 self_size 排序)
3. 列出 self_size 最大的 20 个对象(输出类型、大小、构造函数名)
4. 列出最长的 20 个字符串的前 200 个字符
拿到输出后,把统计结果贴给 AI,让它帮你判断哪些是异常的。
阶段 2:让 AI 深入可疑区域
上一轮分析发现 77MB 的字符串中有大量 HTML 内容和 JSON 包装字符串。
请帮我写一个脚本,对所有字符串按以下规则分类统计:
- 包含 HTML 标签的(<p>, <div>, <img> 等)
- 以 '["' 开头的(JSON.stringify 结果)
- 包含 webpack 关键词的
- 其他
每类统计数量和总大小,并采样输出 10 个典型内容。
阶段 3:让 AI 追踪引用链
上一轮发现大量业务 HTML 内容被长期持有。
请帮我写一个脚本,做以下事情:
1. 找到所有包含 HTML 标签且长度 > 500 的字符串节点
2. 对每个这样的字符串,追踪它的 retainer 链(最多 6 层)
3. 统计这些 retainer 链中出现频率最高的"容器对象"(Map、Set、Array、closure 等)
4. 对出现频率最高的容器,输出它的详细信息(类型、大小、子节点数、构造函数名)
阶段 4:让 AI 确认根因
上一轮追踪到一个 Map(id=677247),包含 15 万个条目,
存储了 { data: 业务内容, maxAge: Infinity } 结构的缓存条目。
这个 Map 被一个名为 "l" 的 closure 持有。
请帮我写一个脚本:
1. 找到这个 closure 的 shared_function_info,输出源码文件路径
2. 输出这个 closure 的所有 context 变量的名称和值
3. 在项目源码中搜索可能创建这种 { data, maxAge } 缓存结构的代码
引导 AI 的关键原则
- 每次只问一个问题。不要一次性让 AI 分析整个快照,它会迷失在信息海洋中。
- 把上一轮的结论作为下一轮的输入。形成"发现 → 假设 → 验证"的循环。
- 让 AI 写脚本而不是直接分析数据。快照数据量太大,AI 无法直接处理,但它很擅长写解析脚本。
- 提供领域知识。告诉 AI 这是一个 Next.js SSR 应用、用了什么技术栈、是什么类型的业务。这些上下文能帮助 AI 判断什么是正常的、什么是异常的。
Heapsnapshot 文件结构速查
写分析脚本时需要理解 .heapsnapshot 文件的 JSON 结构:
{
"snapshot": {
"meta": {
"node_fields": ["type","name","id","self_size","edge_count","trace_node_id","detachedness"],
"node_types": [["hidden","array","string","object","code","closure","regexp","number",
"native","synthetic","concatenated string","sliced string","symbol","bigint"],
"string","number","number","number","number","number"],
"edge_fields": ["type","name_or_index","to_node"],
"edge_types": [["context","element","property","internal","hidden","shortcut","weak"],
"string_or_number","node"]
},
"node_count": 1032504,
"edge_count": 3728200
},
"nodes": [0,1,3,48,5,0,0, ...], // 扁平数组,每 7 个值描述一个节点
"edges": [1,2,7, ...], // 扁平数组,每 3 个值描述一条边
"strings": ["","<dummy>","GC roots",...] // 字符串表,节点和边通过索引引用
}
节点字段(每 7 个值一组):
type: 节点类型索引(0=hidden, 1=array, 2=string, 3=object, 5=closure ...)name: 字符串表索引(构造函数名或字符串内容)id: 节点唯一 IDself_size: 自身占用字节数edge_count: 从该节点出发的边数trace_node_id: 分配追踪 IDdetachedness: 是否已分离
边字段(每 3 个值一组):
type: 边类型索引(0=context, 1=element, 2=property, 3=internal ...)name_or_index: 属性名(字符串表索引)或数组下标to_node: 目标节点在 nodes 数组中的偏移量(注意是偏移量,不是 ID)
理解这个结构后,就可以写脚本遍历所有节点和边,做任意维度的统计分析。
总结:四轮分析法
第一轮 全局概览 → 找到异常信号(77MB 字符串)
第二轮 分解异常区域 → 确认泄漏内容(业务 HTML + JSON.stringify key)
第三轮 追踪引用链 → 找到泄漏容器(无界 Map,15 万条目)
第四轮 确认代码身份 → 定位源码(mem 包的 memoized 函数未设置 maxAge)
每一轮只回答一个问题,用上一轮的结论驱动下一轮的方向。不要跳步,不要猜测。