首页 / 文章 / 开发模式下的节点源映射缓存正在悄悄造成内存泄漏

开发模式下的节点源映射缓存正在悄悄造成内存泄漏

了解为何启用 --enable-source-maps 或 NODE_V8_COVERAGE 会导致因反复的 eval 调用而使堆内存无限制增长,以及如何立即诊断和解决这一问题。

1073 词

节点进程在启动后初始状态良好,但随着持续编辑代码,内存使用量会逐渐上升。如果该进程是使用 --enable-source-maps(或设置了 NODE_V8_COVERAGE)参数启动的,并且代码栈中存在每次都使用新的 //# sourceURL 调用 eval 的情况,那么问题很可能出在过强的源映射缓存上——它不断积累生成的条目却从不释放,导致堆内存持续增长。你重启进程,问题依旧存在。

你尝试强制进行垃圾回收,清除了所有能想到的引用,但堆内存仍然持续上升。

增长模式

想象一下开发服务器持续运行一段时间,或者任何长期运行的 Node 进程,它们会生成带有唯一源代码 URL 标记的合成堆栈跟踪信息或“所属帧”。此时 RSS 值和 heapUsed 值会一直上升。重启进程可以重置这些数值,但如果不使用源码映射标志而继续运行相同的工作负载,内存占用则不会下降。

在 Node.js 的公开问题跟踪系统中(nodejs/node#65760),有一个最简化的复现案例:针对单个外部源码映射文件,每次迭代仅更改 sourceURL,并对大约 90 字节的源代码进行解析。在执行强制垃圾回收后:

Evals | with --enable-source-maps | no flag
0     | 5 MB                       | 5 MB
400   | 170 MB                     | 5 MB
800   | 334 MB                     | 6 MB
1200  | 499 MB                     | 6 MB

每次 eval 调用大约会占用 415 KB 的内存,而且似乎没有上限。

如果你使用 Next.js App Router,通常会遇到这样的问题:每次修改文件后,next dev 的大小都会增加数十兆字节。React Server Components 的 owner-stack 机制会在每个栈帧中执行一次 eval,并为每次调用添加类似 //# sourceURL=about://React/…?<counter++> 的标记,同时还附带大量的内联源码映射。由于该计数器在每次调用时都会递增,因此每个缓存键都是唯一的,从而导致没有任何内容会被重复使用。相关的 Next.js 讨论帖子位于 vercel/next.js#98221 —— 可将其视为问题出现的地方,而非独立的成因。

为何缓存不会释放内容

--enable-source-maps 参数指示 Node 对源映射文件进行缓存,以便将运行时的堆栈跟踪信息转换回原始源文件(详见CLI 文档)。普通模块的源代码会存入弱键缓存中,因此一旦不再被引用,垃圾回收器就可以回收它们。而由 isGeneratedSource 分支处理的生成源代码,则会被存入 generatedSourceMapCache 中——这是一个定义在 lib/internal/source_map/source_map_cache.js 中的普通强引用型 Map,且永远不会被清理。

该缓存周围的代码注释假设在进程的整个生命周期中只会生成少量源文件。而热模块替换和所有者栈重建完全打破了这一假设。每个不同的 sourceURL 都会成为永久性的键,不会有任何内容删除旧条目,且每个源文件的完整解析映射都会一直保留在内存中。

设置 NODE_V8_COVERAGE 也会经过完全相同的缓存路径,因此当生成的 eval 键不断变化时,你仍会看到同样的无限制增长现象。

上游已经有一个开放的拉取请求(#65761),该请求采用基于字节数预算的LRU策略来限制generated-sources缓存的大小——在最新版本中为32 MiB——并在读取时刷新条目,从而避免仍在使用的生成函数对应的映射被过早移除。目前该拉取请求仍处于开放状态,标记为needs-ci。它尚未被合并,也不属于任何已发布的Node构建版本,因此不要以为你当前的使用中的LTS版本已经包含了此修复。

确认诊断结果

  1. 检查你的长时间运行的进程是否是通过--enable-source-maps参数启动的,或者是否设置了NODE_V8_COVERAGE;同时确认是否有程序每次都在使用新的//# sourceURL来重复执行代码——这可能是HMR、RSC的拥有者栈,或是自定义的代码生成机制。
  • 在通过 node --expose-gc 启动的独立测试环境中,或通过正在运行的进程的调试工具获取堆快照后,运行强制垃圾回收循环,并输出 process.memoryUsage().heapUsed 的值作为样本。
  • 去掉该标志后运行相同的工作负载。根据 #65760 的反馈,不使用该标志时内存使用量保持稳定,而启用该标志后则持续上升。
  • 可选地,获取堆快照并搜索其中是否存在 generatedSourceMapCachecontext:generatedSourceMapCache。处理该问题的工程师在真实的 next dev 会话中发现,有数千条记录保留了超过1GB的 sourcesContentmappings 数据。
  • 提高 --max-old-space-size 的值并不能解决问题——它只是推迟了最终的内存不足崩溃。

    立即该做什么

    选择适合您环境的方法:

    1. 对于 Next.js 项目,立即采取行动:运行 next dev --disable-source-maps。根据 #65760 题目的报告者测量,关闭源映射后每次编辑仅会增加约 6 MB 的内存占用,而开启源映射时约为 89 MB。虽然堆栈跟踪的可读性会略有下降,但机器不会再出现内存不足的问题。
    2. 对于其他长时间运行的 Node 工具:在任意热重载服务器上移除 --enable-source-maps 或取消设置 NODE_V8_COVERAGE,直到确实需要生成带映射的堆栈跟踪为止。将源映射功能用于短暂的调试会话即可。
  • 跟踪上游修复进展:同时关注 nodejs/node#65760 以及 pull request #65761。一旦某个 Node 版本明确提到已实现有限大小的生成代码缓存,就可以安全地升级。在此之前,其他用户报告的数值仅代表其特定环境下的测试结果,并不能保证您的应用程序会有完全相同的表现。
  • 无需采纳“只需提高堆内存上限”的建议。在当前的标志设置和使用模式下,底层缓存本身就非常强大且没有大小限制。提高堆内存上限只能稍微延长应用程序在崩溃前的运行时间而已。
  • 总结

    你的开发服务器并非无缘无故地随机消耗内存。将 --enable-source-maps 与使用唯一 sourceURL 值的重复评估操作结合使用,会导致被强引用的 generatedSourceMapCache 满载,而这些缓存条目永远无法被释放。普通模块的源映射文件可以被垃圾回收,但生成的源映射文件则不行,至少在 #65761 版本发布之前都是如此。建议立即在热重载过程中关闭源映射功能,等有容量限制的缓存机制推出后再进行升级。

    因此,下次你在编辑文件时发现 RSS 值不断上升,而强制垃圾回收却无法阻止这一现象时,不妨检查一下是否就是那个标志导致的。

    相关阅读