调试 Node.js 应用程序中的内存泄漏
已发表: 2022-03-11我曾经开过一辆装有 V8 双涡轮增压发动机的奥迪,它的性能令人难以置信。 凌晨 3 点,我在芝加哥附近的 IL-80 高速公路上以 140 英里/小时的速度行驶,当时路上没有人。 从那时起,“V8”这个词对我来说就与高性能联系在一起了。
尽管奥迪的 V8 非常强大,但您的油箱容量仍然有限。 Google 的 V8 也是如此——Node.js 背后的 JavaScript 引擎。 它的性能令人难以置信,Node.js 在许多用例中运行良好的原因有很多,但你总是受到堆大小的限制。 当您需要在 Node.js 应用程序中处理更多请求时,您有两种选择:垂直扩展或水平扩展。 水平扩展意味着您必须运行更多的并发应用程序实例。 如果做得好,您最终将能够处理更多请求。 垂直扩展意味着您必须提高应用程序的内存使用率和性能,或者增加应用程序实例的可用资源。
最近,我被要求为我的一个 Toptal 客户开发 Node.js 应用程序,以解决内存泄漏问题。 该应用程序是一个 API 服务器,旨在每分钟能够处理数十万个请求。 原始应用程序占用了将近 600MB 的 RAM,因此我们决定采用热 API 端点并重新实现它们。 当您需要服务许多请求时,开销变得非常昂贵。
对于新的 API,我们选择了带有原生 MongoDB 驱动程序的 restify 和用于后台作业的 Kue。 听起来像一个非常轻量级的堆栈,对吧? 不完全的。 在峰值负载期间,一个新的应用程序实例可能会消耗多达 270MB 的 RAM。 因此,我对每 1X Heroku Dyno 拥有两个应用程序实例的梦想破灭了。
Node.js 内存泄漏调试库
内存表
如果您搜索“如何在节点中查找泄漏”,您可能会找到的第一个工具是memwatch 。 原来的包很久以前就被废弃了,不再维护。 但是,您可以在 GitHub 的存储库 fork 列表中轻松找到它的更新版本。 这个模块很有用,因为如果它看到堆增长超过 5 个连续的垃圾回收,它就会发出泄漏事件。
堆转储
很棒的工具,它允许 Node.js 开发人员拍摄堆快照并在以后使用 Chrome 开发人员工具检查它们。
节点检查器
甚至是 heapdump 更有用的替代方案,因为它允许您连接到正在运行的应用程序、进行堆转储,甚至可以即时调试和重新编译它。
使用“节点检查器”进行旋转
不幸的是,您将无法连接到在 Heroku 上运行的生产应用程序,因为它不允许将信号发送到正在运行的进程。 然而,Heroku 并不是唯一的托管平台。
为了体验 node-inspector 的实际应用,我们将使用 restify 编写一个简单的 Node.js 应用程序,并在其中放置一点内存泄漏源。 这里所有的实验都是用 Node.js v0.12.7 进行的,它是针对 V8 v3.28.71.19 编译的。
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
这里的应用程序非常简单,并且有非常明显的泄漏。 阵列任务将在应用程序生命周期内增长,导致它变慢并最终崩溃。 问题是我们不仅泄露了闭包,还泄露了整个请求对象。
V8 中的 GC 采用 stop-the-world 策略,因此这意味着您在内存中拥有的对象越多,收集垃圾所需的时间就越长。 在下面的日志中,您可以清楚地看到,在应用程序生命的开始,收集垃圾平均需要 20 毫秒,但后来几十万个请求大约需要 230 毫秒。 由于 GC,尝试访问我们应用程序的人现在必须再等待230 毫秒。 您还可以看到每隔几秒调用一次 GC,这意味着每隔几秒用户访问我们的应用程序时就会遇到问题。 并且延迟会增加,直到应用程序崩溃。
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
当使用–trace_gc标志启动 Node.js 应用程序时,会打印这些日志行:
node --trace_gc app.js
让我们假设我们已经使用这个标志启动了我们的 Node.js 应用程序。 在使用 node-inspector 连接应用程序之前,我们需要将 SIGUSR1 信号发送给正在运行的进程。 如果您在集群中运行 Node.js,请确保连接到从属进程之一。
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
通过这样做,我们使 Node.js 应用程序(准确地说是 V8)进入调试模式。 在此模式下,应用程序会使用 V8 调试协议自动打开端口 5858。
我们的下一步是运行 node-inspector,它将连接到正在运行的应用程序的调试界面,并在端口 8080 上打开另一个 Web 界面。
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
如果应用程序在生产环境中运行并且您有防火墙,我们可以将远程端口 8080 隧道传输到 localhost:
ssh -L 8080:localhost:8080 [email protected]
现在,您可以打开 Chrome 网络浏览器并获得对附加到远程生产应用程序的 Chrome 开发工具的完全访问权限。 不幸的是,Chrome 开发者工具无法在其他浏览器中运行。
让我们找出漏洞!
V8 中的内存泄漏并不是我们从 C/C++ 应用程序中知道的真正的内存泄漏。 在 JavaScript 中,变量不会消失在虚无中,它们只是被“遗忘”。 我们的目标是找到这些被遗忘的变量,并提醒他们 Dobby 是免费的。
在 Chrome 开发者工具中,我们可以访问多个分析器。 我们对随时间运行并获取多个堆快照的记录堆分配特别感兴趣。 这让我们可以清楚地看到哪些对象正在泄漏。
开始记录堆分配,让我们在主页上使用 Apache Benchmark 模拟 50 个并发用户。
ab -c 50 -n 1000000 -k http://example.com/
在拍摄新快照之前,V8 会执行标记扫描垃圾回收,因此我们肯定知道快照中没有旧垃圾。
即时修复泄漏
在3 分钟内收集堆分配快照后,我们最终得到如下内容:

我们可以清楚地看到堆中有一些巨大的数组,大量的 IncomingMessage、ReadableState、ServerResponse 和 Domain 对象。 让我们尝试分析泄漏的来源。
在从 20s 到 40s 选择图表上的 heap diff 后,我们只会看到从您启动分析器开始 20s 之后添加的对象。 这样您就可以排除所有正常数据。
记下系统中有多少每种类型的对象,我们将过滤器从 20 秒扩展到 1 分钟。 我们可以看到,已经相当庞大的数组还在继续增长。 在“(数组)”下,我们可以看到有很多距离相等的对象“(对象属性)”。 这些对象是我们内存泄漏的根源。
我们还可以看到“(闭包)”对象也迅速增长。
查看字符串可能也很方便。 在字符串列表下有很多“Hi Leaky Master”短语。 这些也可能给我们一些线索。
在我们的例子中,我们知道字符串“Hi Leaky Master”只能在“GET /”路径下组装。
如果你打开保持器路径,你会看到这个字符串以某种方式通过req引用,然后创建了上下文,所有这些都添加到了一些巨大的闭包数组中。
所以在这一点上,我们知道我们有一些巨大的闭包数组。 让我们在源选项卡下实时为所有闭包命名。
完成代码编辑后,我们可以按 CTRL+S 即时保存和重新编译代码!
现在让我们记录另一个Heap Allocations Snapshot ,看看哪些闭包占用了内存。
很明显SomeKindOfClojure()是我们的反派。 现在我们可以看到SomeKindOfClojure()闭包被添加到全局空间中一些名为任务的数组中。
很容易看出这个数组只是没用。 我们可以注释掉。 但是我们如何释放已经占用的内存呢? 非常简单,我们只需为任务分配一个空数组,下一次请求将覆盖它,并且在下一次 GC 事件后释放内存。
多比是免费的!
V8 中的垃圾寿命
V8 堆分为几个不同的空间:
- 新空间:这个空间比较小,大小在 1MB 到 8MB 之间。 大多数对象都分配在这里。
- 旧指针空间:具有可能具有指向其他对象的指针的对象。 如果对象在新空间中存活足够长的时间,它就会被提升到旧指针空间。
- 旧数据空间:仅包含原始数据,如字符串、装箱数字和未装箱双精度数组。 在新空间中经过 GC 足够长的时间的对象也会被移到这里。
- 大型对象空间:在此空间中创建太大而无法放入其他空间的对象。 每个对象在内存中都有自己的
mmap
区域 - 代码空间:包含由 JIT 编译器生成的汇编代码。
- 单元格空间、属性单元格空间、地图空间:该空间包含
Cell
、PropertyCell
和Map
。 这用于简化垃圾收集。
每个空间由页面组成。 页面是操作系统使用 mmap 分配的内存区域。 除了大对象空间中的页面外,每个页面的大小始终为 1MB。
V8 有两种内置的垃圾回收机制:Scavenge、Mark-Sweep 和 Mark-Compact。
Scavenge 是一种非常快速的垃圾收集技术,可以对New Space中的对象进行操作。 Scavenge 是切尼算法的实现。 思路很简单, New Space被分成两个相等的半空间:To-Space 和 From-Space。 当 To-Space 已满时,会发生 Scavenge GC。 它只是交换 To 和 From 空间并将所有活动对象复制到 To-Space 或将它们提升到旧空间之一,如果它们在两次清除中幸存下来,然后从空间中完全删除。 清除速度非常快,但是它们具有保持双倍大小的堆和不断在内存中复制对象的开销。 使用 scavenges 的原因是因为大多数对象都是在年轻时死去的。
Mark-Sweep & Mark-Compact 是 V8 中使用的另一种垃圾收集器。 另一个名称是完整的垃圾收集器。 它标记所有活动节点,然后扫描所有死节点并整理内存。
GC 性能和调试技巧
虽然对于 Web 应用程序来说,高性能可能不是一个大问题,但您仍然希望不惜一切代价避免泄漏。 在完全 GC 的标记阶段,应用程序实际上会暂停,直到垃圾收集完成。 这意味着堆中的对象越多,执行 GC 所需的时间就越长,用户等待的时间就越长。
总是给闭包和函数命名
当所有闭包和函数都有名称时,检查堆栈跟踪和堆会容易得多。
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
避免热函数中的大对象
理想情况下,您希望避免在热函数中使用大对象,以便所有数据都适合New Space 。 所有 CPU 和内存绑定操作都应在后台执行。 还要避免热函数的反优化触发器,优化的热函数比非优化的热函数使用更少的内存。
热功能应该优化
运行速度更快但消耗内存更少的热函数会导致 GC 运行频率降低。 V8 提供了一些有用的调试工具来发现未优化的函数或未优化的函数。
避免热函数中 IC 的多态性
内联缓存 (IC) 用于加速某些代码块的执行,通过缓存对象属性访问obj.key
或一些简单的函数。
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
当x(a,b)第一次运行时,V8 创建了一个单态 IC。 当您第二次调用x
时,V8 会擦除旧 IC 并创建一个新的多态 IC,它支持整数和字符串两种类型的操作数。 当您第三次调用 IC 时,V8 重复相同的过程并创建另一个级别 3 的多态 IC。
但是,有一个限制。 在 IC 级别达到 5 后(可以使用–max_inlining_levels标志更改),该函数变为超态并且不再被认为是可优化的。
直观可以理解,单态函数运行速度最快,内存占用也更小。
不要将大文件添加到内存中
这是显而易见且众所周知的。 如果您要处理大文件,例如大型 CSV 文件,请逐行读取并分小块进行处理,而不是将整个文件加载到内存中。 在极少数情况下,单行 csv 会大于 1mb,因此您可以将其放入New Space中。
不要阻塞主服务器线程
如果您有一些需要一些时间来处理的热门 API,例如用于调整图像大小的 API,请将其移动到单独的线程或将其转换为后台作业。 CPU 密集型操作会阻塞主线程,迫使所有其他客户等待并继续发送请求。 未处理的请求数据会堆积在内存中,从而迫使 Full GC 需要更长的时间才能完成。
不要创建不必要的数据
我曾经对restify有过一次奇怪的经历。 如果你向一个无效的 URL 发送了几十万个请求,那么应用程序内存将迅速增长到数百兆字节,直到几秒钟后一个完整的 GC 启动,这时一切都会恢复正常。 事实证明,对于每个无效的 URL,restify 都会生成一个新的错误对象,其中包含长堆栈跟踪。 这迫使新创建的对象分配在大对象空间而不是新空间中。
在开发过程中访问这些数据可能非常有用,但在生产过程中显然不需要。 因此规则很简单——除非确实需要,否则不要生成数据。
了解你的工具
最后但肯定不是最不重要的一点是了解您的工具。 有各种调试器、泄漏导管和使用图生成器。 所有这些工具都可以帮助您使您的软件更快、更高效。
结论
了解 V8 的垃圾收集和代码优化器的工作原理是应用程序性能的关键。 V8 将 JavaScript 编译为本机程序集,在某些情况下,编写良好的代码可以实现与 GCC 编译的应用程序相当的性能。
如果您想知道的话,我的 Toptal 客户端的新 API 应用程序虽然还有改进的余地,但运行良好!
Joyent 最近发布了一个新版本的 Node.js,它使用了 V8 的最新版本之一。 为 Node.js v0.12.x 编写的某些应用程序可能与新的 v4.x 版本不兼容。 但是,应用程序将在新版本的 Node.js 中体验到巨大的性能和内存使用改进。