四轮分析法:Nodejs Heap Snapshot 深度分析方法论

7 阅读13分钟

基于 Node.js SSR 应用内存泄漏的实战复盘,主要解决拿到 heapdump 文件却也分析不出内存泄露问题的痛点。这里提出了四轮分析法,主要是给人 + AI结合去使用的


为什么 Heap Snapshot 分析这么难

拿到一个 177MB 的 heapsnapshot 文件,里面有 100 万个节点、370 万条边。你面对的不是"找 bug",而是"在一座城市的下水道网络里找到那个漏水的管道接头"。

难点在于:

  1. 信息量巨大但信噪比极低。138MB 堆内存中,可能只有 30MB 是泄漏的,剩下的都是正常的运行时开销(V8 编译代码、webpack 模块、框架内部结构)。你需要先学会区分"正常的大"和"异常的大"。

  2. 泄漏对象本身往往不可疑。一个字符串 "Hello World" 看起来完全正常——它就是一条业务数据。问题不在于这个字符串存在,而在于它不应该在这里被长期持有。

  3. 引用链很深且经过混淆。从泄漏的字符串到真正的根因(mem 包的无界 Map),中间隔了 5-6 层引用,而且 closure 名字被 mimic-fn 改成了 "l",源码路径指向打包后的 chunk 文件。

本文记录的是一套经过验证的、可复现的分析方法「四轮分析法」,核心思路是:逐层缩小范围,每一轮只回答一个问题

这个方法主要是给人 + AI 结合使用的,heap dump 数据量大,人眼往往很难直接看出问题,要利用这个分析思路,让 AI 通过脚本去统计和分析


如果你只想快速修复问题,仅看本章节即可

这里将后文的思路封装成了一个 skill:

gitee.com/qsjn/codes/…

把这个 skill 复制到你的编辑器 skills 目录,就可以让 ai 按照这个思路进行分析了

第一轮:全局概览——"内存都花在哪了?"

目标

不要一上来就找泄漏。先建立全局认知:这 138MB 堆内存的构成是什么?哪些是正常开销,哪些是异常的?

方法

对快照做四个维度的统计:

  1. 按节点类型统计内存分布(string / object / code / array / closure ...)
  2. 按构造函数名统计 Top 50(哪类对象最多、最大)
  3. 列出 self_size 最大的单个对象(有没有异常大的对象)
  4. 列出最长的字符串内容(字符串往往是泄漏的载体)

本次案例的发现

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   ← 异常!为什么服务端要长期持有业务 HTMLJSON 包装字符串:      ~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(边)信息,记录了"谁引用了谁"。追踪引用链的方法:

  1. 选取几个典型的泄漏字符串(比如包含业务内容的 HTML 字符串)
  2. 找到引用这个字符串的父节点(retainer)
  3. 再找父节点的父节点,逐层往上
  4. 直到找到一个"不应该存在的长生命周期容器"

本次案例的引用链追踪过程

1 层:业务 HTML 字符串 "some user generated content..."
         ↑ 被谁引用?

第 2 层:Object { data: "some user generated content...", maxAge: Infinity }
         ↑ 一个包含 data 和 maxAge 字段的对象。maxAge 是 Infinity。
           这看起来像某种缓存条目的结构。

第 3 层:Array (155,029 条边)
         ↑ 一个巨大的数组,包含了 15 万个条目。
           这是 V8Map 的内部 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 结构。

关键思维

引用链追踪是体力活,但有几个加速技巧:

  1. maxAge: Infinity 这个字段——这是 mem 包的签名特征。如果你熟悉 mem 的源码,看到 { data, maxAge } 结构就能立刻联想到。

  2. 看 Map 的大小——一个 15 万条目的 Map 在正常业务逻辑中几乎不可能出现。这种"数量异常"是强信号。

  3. 看 closure 的 context 变量——即使函数名被混淆了,context 中的变量(如 stringify、匿名 cacheKey 函数)仍然能提供线索。


第四轮:确认身份——"这个 closure 到底是哪段代码?"

目标

第三轮找到了一个名为 "l" 的 closure,但函数名被 mimic-fn 混淆了。需要确认它对应的源码位置。

方法

  1. 从 closure 节点的属性中找 shared_function_info,它包含源码文件路径和行号
  2. 检查 closure 的 context 变量,看有没有能识别身份的线索
  3. 在打包后的 chunk 文件中搜索对应的代码

本次案例的确认过程

closure "l" 的信息:

  • 源码位置:/app/.next/server/chunks/xxx.js(打包后的 chunk 文件)
  • context 变量:
    • context::a → 匿名 closure(这是 cacheKey 函数)
    • context::bstringify(即 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 table
  • closure → 有 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 链中出现频率最高的"容器对象"MapSetArray、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 的关键原则

  1. 每次只问一个问题。不要一次性让 AI 分析整个快照,它会迷失在信息海洋中。
  2. 把上一轮的结论作为下一轮的输入。形成"发现 → 假设 → 验证"的循环。
  3. 让 AI 写脚本而不是直接分析数据。快照数据量太大,AI 无法直接处理,但它很擅长写解析脚本。
  4. 提供领域知识。告诉 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: 节点唯一 ID
  • self_size: 自身占用字节数
  • edge_count: 从该节点出发的边数
  • trace_node_id: 分配追踪 ID
  • detachedness: 是否已分离

边字段(每 3 个值一组):

  • type: 边类型索引(0=context, 1=element, 2=property, 3=internal ...)
  • name_or_index: 属性名(字符串表索引)或数组下标
  • to_node: 目标节点在 nodes 数组中的偏移量(注意是偏移量,不是 ID)

理解这个结构后,就可以写脚本遍历所有节点和边,做任意维度的统计分析。


总结:四轮分析法

第一轮  全局概览        → 找到异常信号(77MB 字符串)
第二轮  分解异常区域    → 确认泄漏内容(业务 HTML + JSON.stringify key)
第三轮  追踪引用链      → 找到泄漏容器(无界 Map15 万条目)
第四轮  确认代码身份    → 定位源码(mem 包的 memoized 函数未设置 maxAge)

每一轮只回答一个问题,用上一轮的结论驱动下一轮的方向。不要跳步,不要猜测。