概要
在帮助客户排查问题的过程中,我们发现很多客户对于 Node.js 中的事件侦听器的使用存在一定的误区,所以事件侦听器的泄漏是编写 Node.js 代码的一大定时炸弹,下面我们通过一个真实的客户案例来详细解读下此类泄漏,以帮助大家避免类似的问题。
发现问题
接入 Node.js 性能平台后,我们在全局告警中看到某个客户的应用频繁提醒堆内使用内存占据堆上限超过 80%,这种情况基本上大概率就是发生内存泄漏了,联系到对应的客户后,经过客户的授权,我们看到了有问题的进程内存状况,如下图所示:
虽然图中依旧显示健康态,但是依旧可以看到趋势是堆内内存稳步上升,一些问题比较严重的业务进程直接达到堆内限制上限从而 OOM 掉。
定位问题
堆快照分析
排查内存泄漏,首先需要的就是堆快照,因为此次挑选的进程堆内内存大小约 225M,因此能顺利通过 Node.js 性能平台打印堆快照获得 HeapSnapshot,并且这份快照也能反映出内存中的一些问题。经过性能平台提供的在线分析,可以获取如下信息。
第一个信息是当前的堆结构概览:
第二个信息是内存泄漏报表:
展开引力图,看到疑似的泄露点引用关系如下图所示:
进一步根据引力图详细信息,可以看到内存堆积的引用文字关系如下所示(顺序):
(context) of function /home/xxxx/app/controller/home.js() / home.js @345463 -> Client @46073 的 _events 属性 -> EventHandlers @46075 的 error 属性 -> Array @46089
看到这里,熟悉 Node.js 的 Event 类实现的小伙伴就能直接判断出是 socket 创建时的 error 事件侦听器策略不当引发的内存泄漏,更简单的说,就是在同一个 socket 创建中不断侦听 error 事件导致的内存泄漏。
第三个信息是对象簇视图:
可以看到,确实和上面猜测的一样,app/controller/home.js 中的某个 socket 对象的 error 事件侦听器回调函数在不停增加。
代码分析
到这里可以去代码中定位具体有问题的代码了,因此又经过与此应用负责人沟通后,拿到了项目代码仓库的查看权限,查看 app/controller/home.js 文件,搜索 error ,直接找到了出问题的地方,以下是问题最小化代码:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//开发环境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
}
client.on('error', err => {
//error 处理...
})
//其余逻辑处理...
}
}
}
return HomeController;
};
并且在 router.js 中定义的对应这个 controller 的路由如下:
app.get(/.*/, 'home.demo');
好了,可以看到,由于 client 是全局变量,此时用户每访问一次网站首页,都会给 client._events.error 对应的数组增加一个 error 处理函数,虽然每个 error 处理函数 26KB 左右,但是流量上来后,很容易累积触发 OOM 。
解决问题
理解内存泄漏产生的原因后,要解决这个问题就比较简单了,一种通用的解决办法是在 error 侦听操作放入 client 的初始化里面:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//开发环境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
client.on('error', err => {
//error 处理...
})
}
//其余逻辑处理...
}
}
}
return HomeController;
};
这样保证全局只有一个 error 事件侦听器,性能也比较好。还有一种处理方式是每次 controller 处理完成后移除侦听器:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//开发环境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
}
//定义 error 处理句柄
const errorHandle = err => {
//error 处理...
}
client.on('error', errorHandle);
//其余逻辑处理...
//移除 error 侦听器
client.removeListener('error', errorHandle);
}
}
}
return HomeController;
};
但是这样子比第一种耗费一些额外的性能,只是作为解决事件侦听器内存泄漏的方式写出来供大家参考。
最后一种是 egg 框架推荐的写法,也是本问题的最佳解决办法,像这种进程生命周期只需要一次连接的可以放到 app/extend/application.js 中去由框架保证全局单例:
// app/extend/application.js
const CLIENT = Symbol('Application#xxClient');
module.exports = {
get xxClient() {
if (!this[CLIENT]) {
this[CLIENT] = Client.create({});
// this[CLIENT].on('error', fn);
}
return this[CLIENT];
}
}
// app/controller/home.js
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
this.app.xxClient.xx();
}
}
return HomeController;
};