快速定位线上 Node.js 内存泄漏问题

简介: 想要分析定位内存泄漏问题,首先我们要去获取 Node.js 进程在发生泄漏时的堆上各个对象和它们间的引用关系,这个保存了堆上各个对象以及其引用关系的文件就是堆快照。

背景

目前容器化和微服务是服务端开发的一个潮流和趋势,然而在这种微服务的架构下,我们在实际的企业开发中会遇到一些困境:趋向于越来越稳定的服务端 API 和多样化高灵活性的用户诉求间存在天然的矛盾。

更通俗地描述一些实际开发的场景:Android、IOS、PC 和 M 站对于同一个性质的接口需求的字段不一致,导致的前端开发和服务端开发间经常会因为增减字段产生的大量的沟通开销。

为了解决这样的一个困境,一些公司采取了在传统的前端和后端之间加入一层 BFF 层,进而达到谁使用谁开发维护的目的。很显然,对于前端比较熟悉的 Node.js 是这个 BFF 层实现的一个比较理想的语言。

但是这样做其实又引入了一些新的问题(典型的为了解决一个问题又引入了一个新的问题),相对于传统的比较成熟的 Java 语言来说,Node.js 的 runtime 对于绝大部分开发者来说是一个黑盒,没有对应的生态链工具来保障这个由 BFF 层运行的稳定————比如线上出现内存泄漏导致进程间歇性 OOM 了,我们应该怎么去处理定位。

这篇文章旨在这个大背景下对 Node.js 的开发中遇到内存泄漏问题做一些展开和探讨。

堆快照浅探

获取堆快照

想要分析定位内存泄漏问题,首先我们要去获取 Node.js 进程在发生泄漏时的堆上各个对象和它们间的引用关系,这个保存了堆上各个对象以及其引用关系的文件就是堆快照。V8 引擎提供了一个接口可以让我们很方便地实时获取到堆快照,下面我们介绍三种不同的方法来获取。

heapdump

首先可以执行如下命令安装 heapdump 模块:

npm install heapdump

此模块需要在代码中引入:

const heapdump = require('heapdump');

heapdump 模块提供了两种方式来获取进程当前的堆快照,第一种是在代码中通过自定义逻辑(可以是定时器定是获取,或者长连接开关热启动),下面是一个例子:

'use strict';
const heapdump = require('heapdump');
const path = require('path');
setInterval(function() {
    let filename = Date.now() + '.heapsnapshot';
    heapdump.writeSnapshot(path.join(__dirname, filename));
}, 30 * 1000);

这里每隔 30s 输出一个堆快照到到当前目前下。

第二种是启动引入了 heapdump 模块的 Node.js 进程后,通过 usr2 这个信号量来触发堆快照:

kill -USR2 <需要获取堆快照的 Node.js 进程 PID>

这种办法的好处是不需要在代码中植入相关逻辑,而仅在需要的时候 ssh 到服务器上通过信号量获取到堆快照。

v8-profiler

首先可以执行如下命令安装 heapdump 模块:

npm install v8-profiler

v8-profiler 提供了 transform 流的形式输出堆快照,对于一些比较大的堆快照文件能更好的进行生成处理:

'use strict';
const v8Profiler = require('v8-profiler-node8');
const snapshot = v8Profiler.takeSnapshot();
// 获取堆快照数据流
const transform = snapshot.export();
// 流式处理堆快照
transform.on('data', data => console.log(data));
// 数据处理完毕后删除
transform.on('finish', snapshot.delete.bind(snapshot));

v8-profiler 在 Node.js v6.x 之前的版本中通过 node-pre-gyp 可以直接下载到对应系统的 binary,无需进行本地编译,对于一些非 mac 类的开发环境还是比较友好的。

Node.js 性能平台

前面给大家介绍的方法都需要安装 npm 模块,并且需要在代码中埋入对应的热操作逻辑,Node.js 性能平台 目前将堆快照的获取整合进了 runtime 中,只要应用接入平台后,不需要改动业务代码即可在线获取到进程的堆快照以备分析:

devtools

如果所示,选中需要操作的进程后,点击 堆快照 按钮,即可生成堆快照,点击导航栏左侧的 文件 选项,即可看到刚才生成的堆快照:

file

此时点击 转储 至云端后,即可随时随地下载分析了。

堆快照内容解析

字段含义

用任意的文档阅读工具打开上一节中获取的堆快照后,可以看到它里面的内容本质上是一个大 json:

{
    snapshot: {},
    nodes: [],
    edges: [],
    strings: []
}

这里面很好猜测的是 nodesedges,显然 nodes 数组中保存的一定是内存关系中每一个节点的信息,edges 数组保存的是内存关系图中每一个节点间的联系。

那么 snapshot 保存的其实是描述每一个 node 和 edge 的描述信息,我们展开 snapshot 节点后,可以看到它里面只有一个 meta 节点,继续展开 meta 节点,就可以看到 node 和 edge 的描述信息了:

  • meta.node_fields: 数组,数组的长度就是一个 node 实际需要 nodes 数组中对应长度的数字来表示,这里显然可以看到 nodes 数组中每 6 位表示一个 node。
  • meta.node_types: 数组,其中的元素表示一个 node 每一位的含义,这里可以看到 6 位中的第一位表示节点类型,并且节点类型也是在有限的一个数组中。
  • meta.edge_fields: 数组,数组的长度就是一个 edge 实际需要 edges 数组中对应长度的数字来表示,这里显然可以看到 edges 数组中每 3 位表示一条边。
  • meta.node_types: 数组,其中的元素表示一个 edge 每一位的含义,这里可以看到 3 位中的第一位表示边的类型,并且边的类型也是在有限的一个数组中。

最后是 strings 数组,它的含义比较简单,其内部实际上保存的是 node 和 edge 的名称。

整体的关系图如下所示:

snapshot

节点和边

通过上面的信息,我们可以获取到内存关系图所需的每一个节点和每一条边的描述,但是依旧缺失节点和边之间的关系来补完全图。

我们可以注意到,上面描述 node 信息的 meta.node_fields 中有一项叫做 edge_count,这个显然描述的是此节点下属边的条数,而且 edges 数组中的边是按照顺序排列的,那么依据这些信息,我们可以构建如下的一个关系图;

reference

并且描述边信息的 meta.edge_fields 中又有一项叫做 to_node,它指的是这条边指向的 node,那么结合之前的内容,可以比较完整地构建出真正的内存关系图了。

定位内存泄漏

根据上一节的内容,我们可以获得类似如下所示的内存关系图:

image.png

我们可以思考这样的一个问题,假如节点 5 是内存泄漏的地方,它累积了大量的内存没有被正常的释放掉。此时我们如果释放掉它的父节点 3,那么从根节点出发依旧可以 1->2->4->5 的路径到达 5,也就是单独释放掉节点 3,并不能断开节点 5 的引用;同理可得节点 4。

在这个例子中,只有当我们断开节点 2 的引用时,才能释放掉节点 5,换句话说,从根节点 1 出发到节点 5,所有的路径都会经过节点 2,这就意味着节点 2 才是节点 5 的直接支配者。这里就引入了 支配树 的概念,它对于分析内存泄漏非常有帮助。

我们将上图的内存引用关系图转化为支配树,如下图所示:

dominator

此时从支配树的叶节点 8 开始向上到根节点计算 retained size,每一个节点的 retained size = 节点自身的 self size + 子节点的 retained size,最后就可以看到内存累积在哪一处,我们可以认为这些内存累积的节点有可能正是没有正常被回收从而引发内存泄漏的地方。

获取到这些可疑的泄漏点后,再次还原到内存关系图中定位到对应的代码逻辑片段,那么最终的内存泄漏确认还是要回到这些代码逻辑片段,看它们是否真的会产生一些预期之外的无法释放掉的内存。

实战

下面有两个真实线上内存泄漏的案例,在线获取到堆快照后,经过上述的定位分析流程找到内存累积的可疑泄漏点,还原后最终定位到了泄漏代码,感兴趣的同学可以看下详细的内容:

参考文档:

目录
相关文章
|
1月前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
1月前
|
JavaScript 前端开发 Java
避免 JavaScript 中的内存泄漏
【10月更文挑战第30天】避免JavaScript中的内存泄漏问题需要开发者对变量引用、事件监听器管理、DOM元素操作以及异步操作等方面有深入的理解和注意。通过遵循良好的编程实践和及时清理不再使用的资源,可以有效地减少内存泄漏的风险,提高JavaScript应用程序的性能和稳定性。
|
2月前
|
存储 JavaScript 前端开发
JS 中的内存管理
【10月更文挑战第17天】了解和掌握 JavaScript 中的内存管理是非常重要的。通过合理的内存分配、及时的垃圾回收以及避免内存泄漏等措施,可以确保代码的高效运行和稳定性。同时,不断关注内存管理的最新发展动态,以便更好地应对各种挑战。在实际开发中要时刻关注内存使用情况,以提升应用的性能和质量。
34 1
|
15天前
|
监控 JavaScript
选择适合自己的Node.js内存监控工具
选择合适的内存监控工具是优化 Node.js 应用内存使用的重要一步,它可以帮助你更好地了解内存状况,及时发现问题并采取措施,提高应用的性能和稳定性。
109 76
|
1月前
|
监控 JavaScript 前端开发
如何检测和解决 JavaScript 中内存泄漏问题
【10月更文挑战第25天】解决内存泄漏问题需要对代码有深入的理解和细致的排查。同时,不断优化和改进代码的结构和逻辑也是预防内存泄漏的重要措施。
46 6
|
1月前
|
JavaScript 前端开发 Java
JavaScript 中内存泄漏的几种常见情况
【10月更文挑战第25天】实际上还有许多其他的情况可能导致内存泄漏。为了避免内存泄漏,我们需要在开发过程中注意及时清理不再需要的资源,合理使用内存,并且定期检查内存使用情况,以确保程序的性能和稳定性
33 2
|
1月前
|
存储 JavaScript 前端开发
js 中有哪几种内存泄露的情况
JavaScript 中常见的内存泄漏情况包括:1) 全局变量未被释放;2) 意外的全局变量引用;3) 被遗忘的计时器或回调函数;4) 事件监听器未被移除;5) 子元素存在时删除父元素;6) 循环引用。
|
3月前
|
XML IDE 前端开发
IDEA忽略node_modules减少内存消耗,提升索引速度
在后端开发中,IDEA 在运行前端代码时,频繁扫描 `node_modules` 文件夹会导致高内存消耗和慢索引速度,甚至可能会导致软件卡死。为了改善这一问题,可以按照以下步骤将 `node_modules` 文件夹设为忽略:通过状态菜单右键排除该文件夹、在设置选项中将其加入忽略列表,并且手动修改项目的 `.iml` 文件以添加排除配置。这些操作可以有效提高IDE的运行性能、减少内存占用并简化项目结构,但需要注意的是,排除后将无法对该文件夹进行索引,操作文件时需谨慎。
144 4
IDEA忽略node_modules减少内存消耗,提升索引速度
|
2月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
58 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
2月前
|
缓存 监控 JavaScript