修复 Node 应用中的内存泄漏

2025-06-07

修复 Node 应用中的内存泄漏

几个月前,我们的网络服务器崩溃了。它只持续了一分钟就重启了,但作为一家小型初创公司的技术人员,那一分钟真是让人压力山大。我从来没有设置过内存不足时重启的服务,但我们确实连接了一些报告工具,所以崩溃后,我深入研究了我们的日志。

狗
没错,这确实是内存泄漏!但我该如何追踪它呢?

就像乐高积木一样

调试时,我喜欢把内存想象成乐高积木。每个创建的对象都是一块积木。每种对象类型都有不同的颜色。堆就像客厅的地板,我(垃圾收集器)会清理那些没人玩的积木,因为如果我不这样做,地板就会变成一个充满危险的雷区,踩到脚会很疼。诀窍在于找出哪些积木没有被使用。

调试

在 Node 中对内存泄漏进行分类时,有两种策略:快照和配置文件。

快照(又称堆转储)记录了堆上某一时刻的所有内容。
这就像拍一张客厅地板的照片,包括乐高积木。如果你拍了两张快照,就像翻阅一本精彩集锦杂志一样:找到两张照片之间的差异,就能找到 bug。很简单!

因此,快照是查找内存泄漏的黄金标准。遗憾的是,快照拍摄可能需要长达一分钟的时间。在此期间,服务器将完全无响应,这意味着您需要在没有人访问您的网站时进行快照。由于我们是企业 SaaS 平台,这意味着周六凌晨 3 点。如果您没有这个时间,则需要在转储时将反向代理重定向到备份服务器。

采样分配配置文件是一种轻量级的替代方案,耗时不到一秒钟。顾名思义,它会对所有分配的对象进行采样。虽然这会生成一个类似于 CPU 配置文件的、非常直观的火焰图,但它不会显示哪些对象正在被垃圾回收。
轮廓

这就像只看到正在玩的乐高积木,却没注意到哪些被放下了。如果你看到 100 块红砖和 5 块蓝砖,那么很有可能红砖就是罪魁祸首。然而,同样有可能的是,所有 100 块红砖都被垃圾回收了,只有 5 块蓝砖还在。换句话说,你需要对应用进行分析并深入了解才能找到泄漏点。

实施

就我而言,我两者都做了。为了设置分析器,我每小时运行一次,如果实际使用的内存增加了 50MB,它就会写入一个快照。

import * as heapProfile from 'heap-profile'

let highWaterMark = 0
heapProfile.start()
  setInterval(() => {
    const memoryUsage = process.memoryUsage()
    const {rss} = memoryUsage
    const MB = 2 ** 20
    const usedMB = Math.floor(rss / MB)
    if (usedMB > highWaterMark + 50) {
      highWaterMark = usedMB
      const fileName = `sample_${Date.now()}_${usedMB}.heapprofile`
      heapProfile.write(fileName)
    }
  }, 1000 * 60 * 60)

快照稍微有趣一点。虽然常规方法是SIGUSR2使用 向节点进程发送信号kill,但我不喜欢这样做,因为你知道还有什么可以发送 吗SIGUSR2?任何东西都可以。你的依赖项中现在(或将来)可能有一个包会发出相同的信号,如果确实如此,那么你的网站就会瘫痪,直到该进程完成。风险太大,而且使用起来很麻烦。因此,我为它创建了一个 GraphQL 突变。我把它放在我们的“私有”(仅限超级用户)架构中,可以使用GraphiQL调用它。
GraphiQL

端点背后的代码非常简单:

import profiler from 'v8-profiler-next'

const snap = profiler.takeSnapshot()
const transform = snap.export()
const now = new Date().toJSON()
const fileName = `Dumpy_${now}.heapsnapshot`
transform.pipe(fs.createWriteStream(fileName))
return new Promise((resolve, reject) => {
  transform.on('finish', () => {
    snap.delete()
    resolve(fileName)
  })
})

我们拍摄快照,将其传输到文件,删除快照,然后返回文件名。很简单!然后,我们只需将其上传到 Chrome DevTools 的“内存”选项卡即可。

阅读转储

虽然配置文件没什么用,但堆转储确实提供了我所需的信息。让我们来看看一个名为 的泄漏ServerEnvironment
倾倒

在我们的应用中,我们进行了一些轻量级的服务端渲染 (SSR) 来生成电子邮件。由于我们的应用由Relay(一个类似 Apollo 的优秀 GraphQL 客户端缓存)提供支持,我们使用我命名为 a 的方法ServerEnvironment获取数据、填充组件,然后就结束了。那么,为什么会有 39 个实例呢?谁还在玩那些乐高积木呢?

答案就在“Retainers”部分。用通俗的话说,这张表我是这样理解的:“ServerEnvironment无法被垃圾回收,因为它是56a 中的item Map,而 a 又无法被垃圾回收,因为它被 object 使用requestCachesByEnvironment。此外,它还被 所使用environment,而 又被 所使用_fetchOptions,而queryFetcher又被 所使用”……你懂的。所以requestCachesByEnvironment,和requestCache才是罪魁祸首。

如果我寻找第一个,我只需几行代码就能找到罪魁祸首(为简洁起见进行了编辑,原始文件在此处):

const requestCachesByEnvironment = new Map();

function getRequestCache(environment) {
  const cached = requestCachesByEnvironment.get(environment)
  if (!cached) {
    const requestCache = new Map()
    requestCachesByEnvironment.set(environment, requestCache)
  }
  return requestCachesByEnvironment.get(environment)
}

这是典型的内存泄漏。它指的是文件最外层闭包中的一个对象,被内部闭包中的函数写入,但未delete找到任何调用。一般来说,写入外部闭包中的变量是可以的,因为存在限制;但写入对象通常会导致类似的问题,因为这种可能性是无限的。由于该对象未导出,我们知道必须修补此文件。要修复此问题,我们可以编写一个清理函数,或者问自己两个问题:
1)该 Map 是否正在被迭代?
2)如果 Map 项从应用程序的其余部分移除,它是否需要存在于 Map 中?

既然这两个问题的答案都是“否”,那就很容易解决了!只需变成Map这样WeakMap就搞定了!WeakMap 和 Map 类似,只不过它们的键会被垃圾回收。相当实用!

第二个保留器可以追溯到requestCache。它不是Map,而是一个普通的 JavaScript 对象,同样保存在最外层的闭包中(注意到这里的模式了吗?这是一个糟糕的模式)。虽然用一个闭包来实现这一点很棒,但这需要大量重写。一个更简洁、更优雅的解决方案是,如果它不在浏览器中运行,就清除它,参见

有了这两个修复,我们的ServerEnvironment代码就可以被垃圾回收了,内存泄漏问题也解决了!剩下要做的就是把修复提交到上游并使用新版本。可惜的是,这可能需要几周/几个月的时间,甚至永远都无法实现。为了立即获得满足,我喜欢使用超棒的gitpkg CLI,它可以将 monorepo 的一部分发布到你 fork 的某个 git 标签上。我没见过有人写关于它的文章,但它确实为我 fork 软件包节省了大量时间。

每个人都会遇到内存泄漏。请注意,我之所以挑 Facebook 的代码来挑衅他们,并非是为了表达粗鲁、侮辱,或对他们的公司道德采取某种奇怪的政治立场。原因很简单:1)这些是我在自己的应用中发现的内存泄漏;2)它们是最常见泄漏的典型案例;3)Facebook 非常慷慨地开源了他们的工具,供大家改进。

说到开源,如果你想在世界任何地方(👋 哥斯达黎加)花时间编写开源代码,那就加入我们吧!我们是一群前企业员工,致力于终结毫无意义的会议,让工作变得有意义。请访问 https://www.parabol.co/join直接给我留言。

文章来源:https://dev.to/mattkrick/fixing-memory-leaks-in-node-apps-5eh4
PREV
Object.freeze() 变得困难🥶❄️
NEXT
作为开发人员如何脱颖而出?