快速定位线上 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 中,只要应用接入平台后,不需要改动业务代码即可在线获取到进程的堆快照以备分析:

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

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

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

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

堆快照内容解析

字段含义

用任意的文档阅读工具打开上一节中获取的堆快照后,可以看到它里面的内容本质上是一个大 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 的名称。

整体的关系图如下所示:

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

节点和边

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

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

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

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

定位内存泄漏

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

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

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

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

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

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

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

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

实战

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

参考文档:

上一篇:Node 案发现场揭秘 —— Core dump 还原线上应用异常


下一篇:Co、递归调用引发的内存泄漏