Node服务端内存泄露问题分析
服务端内存泄露问题分析
1,问题发现
首先在进行服务端单核cpu爆满的问题排查中发现服务端代码多进程通信存在问题,在解决多进程通信问题后,我们依然在对项目进行压力测试
目前压力测试的接口是验证码生成接口,我们注释了存储到redis的代码,以防redis爆满对代码造成影响,进而影响我们排查问题
2,测试准备
首先是压测的接口,代码如下:
相同的代码,我们准备了两个项目,一个是目前的服务端代码egg环境,还有一个是把框架更换后的koa服务端环境,两个项目都跑在同一个x86服务器环境里面。
3,测试工具
3.1 apache ab 的安装
我准备了两套工具,一个是apache ab压测工具,这个工具简单方便,只需要安装 httpd-tools 即可,在centos之类的系统,,安装命令如下:
yum -y install httpd-tools
unbutun之类的系统,安装命令如下:
apt-get install httpd-tools
如果系统源里面没有httpd-tools这个插件的话,安装apache环境也可以使用ab测试工具。
3.2 ab的使用
ab -c 100 -n 10000 localhost:8080/login
参数说明:
-n 即requests,用于指定压力测试总共的执行次数。
-c 即concurrency,用于指定的并发数。
-t 即timelimit,等待响应的最大时间(单位:秒)。
-b 即windowsize,TCP发送/接收的缓冲大小(单位:字节)。
-p 即postfile,发送POST请求时需要上传的文件,此外还必须设置-T参数。
-u 即putfile,发送PUT请求时需要上传的文件,此外还必须设置-T参数。
-T 即content-type,用于设置Content-Type请求头信息,例如:application/x-www-form-urlencoded,默认值为text/plain。
-v 即verbosity,指定打印帮助信息的冗余级别。
-w 以HTML表格形式打印结果。
-i 使用HEAD请求代替GET请求。
-x 插入字符串作为table标签的属性。
-y 插入字符串作为tr标签的属性。
-z 插入字符串作为td标签的属性。
-C 添加cookie信息,例如:“Apache=1234”(可以重复该参数选项以添加多个)。
-H 添加任意的请求头,例如:“Accept-Encoding: gzip”,请求头将会添加在现有的多个请求头之后(可以重复该参数选项以添加多个)。
-A 添加一个基本的网络认证信息,用户名和密码之间用英文冒号隔开。
-P 添加一个基本的代理认证信息,用户名和密码之间用英文冒号隔开。
-X 指定使用的和端口号,例如:“126.10.10.3:88”。
-V 打印版本号并退出。
-k 使用HTTP的KeepAlive特性。
-d 不显示百分比。
-S 不显示预估和警告信息。
-g 输出结果信息到gnuplot格式的文件中。
-e 输出结果信息到CSV格式的文件中。
-r 指定接收到错误信息时不退出程序。
-h 显示用法信息,其实就是ab -help。
3.3 jmeter的安装
jmeter也是apache的一个插件工具,他对比ab就更加的精确,功能更加完善和专业,ab简单方便,对于简单的api测试,结果差距不大,还有个重要的区别是,ab只是单纯的发出请求,不会统计统计返回结果,这样就会存在一些误差和一些相关的统计数据。当然使用起来也会更加复杂,配置也会更多。
但是安装很简单,我们到官网根据我们的机器环境下载一个压缩包即可,使用的话,就是解压压缩包,找到bin目录里面的jmeter.sh文件,直接通过脚本启动就可以了。
插件图片,可以在后面测试结果的地方看到。
3.4 jmeter的使用
插件自带了很多模板,向我们进行http的测试,可以直接用系统自带的http-request模板
也可以自己手动添加,手动添加的项目截图如下:
参数说明:
线程组:我们直接可以理解为多少个用户—— 一般和你的并发数相等
Ramp-ups 时间:规定时间的跑完所有请求
循环次数:线程组循环多少次——你设置线程组为10000,循环 10000次,就会有10万 个请求
如图上我所设置的,Ramp-up 时间为 5,他就会 5s 内,跑完所有所有请求。这样是控制的样本数添加完,然后添加上测试的url,端口,参数等,点击上面绿色的按钮即可进行测试了,可以自己设置压测的线程和循环次数。
后面还有调度器的使用,使用调度器可以设置持续时间,控制压测的时间(样本数不是固定的),这个和我的测试无关,暂不说明了。
4,问题确认
下面设置的是10线程(相当于并发10),循环100000万次,的请求结果,因为目前连接的无线物联网,带宽比较低,可以明显发现吞吐量很低。然后这个x86的服务器性能可能也很一般,10的并发量cpu都已经100%以上了。
首先是egg框架的代码测试结果,如下:
内存增长以后,停止了测试等待了一个晚上大概12个小时,内存没有有效释放,所以推测存在内存泄露。
把相同的代码放到koa框架下继续测试,结果如下:
内存一直保持在4G多,未见明显内存升高。
5,原因分析和测试方案
5.1 接口代码存在内存泄露
因为在新的koa框架里使用的接口代码相同,这个原因可以排除掉,从代码上看,也没有存在内存泄露的代码
5.2 egg的框架存在内存泄露
这个情况的话,我们需要搞一个和目前egg版本一样的基线代码去测试验证码接口,如果存在内存泄露,则是这个原因,同时搞一个egg的最新版本运行验证码接口进行比较
5.3 使用egg代码时存在内存泄露
如果5.2,证明egg代码框架本身没有内存泄露的话,那就是我们使用egg中存在内存泄露
这个也分为2种情况:
5.3.1 引入的第三方插件存在内存泄露
这个情况就是去掉个人代码进行压测,存在的话,就是第三方插件存在内存泄露,然后慢慢排查。
5.3.2 个人其他模块代码存在内存泄露
如果第三方插件不存在问题,个人代码也是分模块慢慢排查。
6. 测试结果和最终问题确认
测试框架基本情况 | 测试10万个请求后的内存情况 | 是否存在内存泄露 |
---|---|---|
egg的基线代码测试 egg: ^2.33.1 egg-script: ^2.15.2 egg-bin: ^4.18.1 egg-ci: ^1.19.0 |
内存基本未提升 | 否 |
egg的基线代码测试 egg: ^3 egg-scripts: ^2 egg-bin: ^5 egg-ci: ^2 |
内存基本未提升 | 否 |
服务端egg项目代码测试 egg: ^2.33.1 egg-script: ^2.15.2 egg-bin: ^4.18.1 egg-ci: ^1.19.0 |
内存缓慢上升,升到了30G(最大32),停止压测后,内存12个小时,都未恢复正常 | 是 |
服务端egg项目代码测试 egg: ^3 egg-scripts: ^2 egg-bin: ^5 egg-ci: ^2 |
内存缓慢上升,升到了30G(最大32),停止压测后,内存12个小时,都未恢复正常 | 是 |
通过以上结果,我们最终确定是我们在使用egg框架的过程中存在内存泄露,分为两种情况,个人代码和引入的第三方插件代码问题。
后续就是隔离代码测试的过程。
这里我来说一下egg这个框架怎么隔离业务代码进行测试,依照我的理解,肯定是在路由入口下手,直接不跳转到业务代码就行,也能隔离一大波,业务代码中引入的第三方插件。
我们来看一下修改的代码:
直接注释掉了initRouter这个封装(这个项目是我之前用的cool-admin封装的一些插件),自己写了了个单独的路由入口,然后新增了一个controller的home.js文件,里面新增了上面测试用的验证码接口。
测试结果如下图:
经过一段时间的观察,内存没有缓慢和快速增长,基本无变化,基本上内存泄露是在业务代码和中间件封装这一块。后面就是这种二分法之类的的反复测试了,基本上流程和上面一样,具体我就不详细表述了,下面直接说最后结果,中间我觉得有用的细节也会说一下。
在做二分法之前,我们先用alinode的性能分析平台对内存的堆栈进行一下分析,说句实话alinode是个很强大的工具,但不知道我的堆栈快照抓取总是失败,最后我只能控制一个很小的ssr内存才能抓取到可用的堆栈快照,我的20u32g的服务器配置也不差呀。当然我使用的是流量卡,带宽确实很低,这个影响我感觉还是很大的。在反复实验了很多次的情况下,终于抓取到了下面几个内存的堆栈快照。
通过上面的图,我们明显看到,在GC roots里面有很多的Object树没有被垃圾回收掉,这些Object就是压测发出的请求,所以我推断可能是,这个接口请求封装处理路由的时候存在内存泄露,或者在中间件和记录日志时存在问题,下面我们继续排查。
第一步:在我注释了所有权限和日志中间件以后,压测结果依然存在内存泄露
第二步:删除了出验证码以外的所有业务controller,压测结果依然存在内存泄露
第三步:再去掉service层的代码,验证码在controller层通过ctx.body直接返回
第四步:把cool团队封装的egg-cool-controller替换成egg自带的controller,依然存在
第五步:前面我们注释掉egg-cool-router后,进行压测,是没有内存泄露的,所以现在很大可能问题就出来egg-cool-router这个插件里面,我们先去npm看看这个插件的版本,发现现在是最后一个版本,这个插件现在已经没有维护了,所以只能我们自己去代码里面找问题了。
下面是内存泄露的快照,里面主要是接口返回的data,占用了内存,未能释放。
下面是我怀疑引起内存泄露的封装路由的代码:
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
const _ = require('lodash');
const tslib_1 = require("tslib");
const {
locate
} = require('func-loc');
/** http方法名 */
const HTTP_METHODS = ['get', 'post', 'patch', 'del', 'options', 'put', 'all'];
let baseControllerArr = [];
class RouterDecorator {
constructor() {
HTTP_METHODS.forEach(httpMethod => {
this[httpMethod] = (url, ...beforeMiddlewares) => (target, name) => {
const routerOption = {
httpMethod,
beforeMiddlewares,
handlerName: name,
constructorFn: target.constructor,
className: target.constructor.name,
url: url
};
if (target.constructor.name === 'BaseController') {
baseControllerArr.push(routerOption)
} else {
// console.log("---------routerOption---------");
// console.log(routerOption);
this.__setRouter__(url, routerOption);
}
};
});
}
/** 推入路由配置 */
__setRouter__(url, routerOption) {
console.log("-----------RouterDecorator.__router__[url]------------");
console.log(RouterDecorator.__router__[url]);
// console.log(routerOption);
RouterDecorator.__router__[url] = RouterDecorator.__router__[url] || [];
RouterDecorator.__router__[url].push(routerOption);
console.log(RouterDecorator.__router__[url]);
}
/**
* 装饰Controller class的工厂函数
* 为一整个controller添加prefix
* 可以追加中间件
* @param {string} prefixUrl
* @param {...Middleware[]} beforeMiddlewares
* @param {any[]} baseFn
* @returns 装饰器函数
* @memberof RouterDecorator
*/
prefix(prefixUrl, baseFn = [], ...beforeMiddlewares) {
if (typeof(prefixUrl) != 'string') {
baseFn = prefixUrl;
prefixUrl = '';
}
if (!baseFn) {
baseFn = [];
}
return (targetControllerClass) => {
RouterDecorator.__classPrefix__[targetControllerClass.name] = {
prefix: prefixUrl,
beforeMiddlewares: beforeMiddlewares,
baseFn: baseFn,
target: targetControllerClass
};
return targetControllerClass;
};
}
/**
* 注册路由
* 路由信息是通过装饰器收集的
* @export
* @param {Application} app eggApp实例
* @param {string} [options={ prefix: '' }] 举例: { prefix: '/api' }
*/
static initRouter(app, options = {
prefix: ''
}) {
let addUrl = [];
Object.keys(RouterDecorator.__router__).forEach(url => {
RouterDecorator.__router__[url].forEach((opt) => {
const controllerPrefixData = RouterDecorator.__classPrefix__[opt.className] || {
prefix: '',
beforeMiddlewares: [],
baseFn: [],
target: {}
};
locate(controllerPrefixData.target).then(res => {
const pathSps = res.path.split('.');
const paths = pathSps[pathSps.length - 2].split('/');
const pathArr = [];
for (const path of paths.reverse()) {
if (path != 'controller') {
pathArr.push(path);
}
if (path == 'controller') {
break;
}
}
const prefixAuto = `/${pathArr.reverse().join('/')}`;
let fullUrl = `${options.prefix}${controllerPrefixData.prefix?controllerPrefixData.prefix:prefixAuto}${url}`;
console.log(`>>>>>>>>custom register URL * ${opt.httpMethod.toUpperCase()} ${fullUrl} * ${opt.className}.${opt.handlerName}`);
if (!addUrl.includes(fullUrl)) {
app.router[opt.httpMethod](fullUrl, ...controllerPrefixData.beforeMiddlewares, ...opt.beforeMiddlewares, (ctx) => tslib_1.__awaiter(this, void 0, void 0, function*() {
const ist = new opt.constructorFn(ctx);
yield ist[opt.handlerName](ctx);
}));
addUrl.push(fullUrl);
}
})
});
});
// 通用方法
const cArr = [].concat(_.uniq(baseControllerArr));
Object.keys(RouterDecorator.__classPrefix__).forEach(cl => {
const controllerPrefixData = RouterDecorator.__classPrefix__[cl] || {
prefix: '',
beforeMiddlewares: [],
baseFn: [],
target: {}
};
const setCArr = cArr.filter(c => {
if (RouterDecorator.__classPrefix__[cl].baseFn.includes(c.url.replace('/', ''))) {
return c;
}
});
setCArr.forEach(cf => {
locate(controllerPrefixData.target).then(res => {
const pathSps = res.path.split('.');
const paths = pathSps[pathSps.length - 2].split('/');
const pathArr = [];
for (const path of paths.reverse()) {
if (path != 'controller') {
pathArr.push(path);
}
if (path == 'controller') {
break;
}
}
const prefixAuto = `/${pathArr.reverse().join('/')}`;
let fullUrl = `${options.prefix}${controllerPrefixData.prefix?controllerPrefixData.prefix:prefixAuto}${cf.url}`;
console.log(`>>>>>>>>comm register URL * ${cf.httpMethod.toUpperCase()} ${fullUrl} * ${cl}.${cf.handlerName}`);
app.router[cf.httpMethod](fullUrl, ...controllerPrefixData.beforeMiddlewares, ...cf.beforeMiddlewares, (ctx) => tslib_1.__awaiter(this, void 0, void 0, function*() {
const ist = new controllerPrefixData.target(ctx);
yield ist[cf.handlerName](ctx);
}));
})
});
});
}
}
/**
* 记录各个class的prefix以及相关中间件
* 最后统一设置
* @private
* @static
* @type {ClassPrefix}
* @memberof RouterDecorator
*/
RouterDecorator.__classPrefix__ = {};
/**
* 记录各个routerUrl的路由配置
* 最后统一设置
* @private
* @static
* @type {Router}
* @memberof RouterDecorator
*/
RouterDecorator.__router__ = {};
/** 暴露注册路由方法 */
exports.initRouter = RouterDecorator.initRouter;
/** 暴露实例的prefix和http的各个方法 */
exports.default = new RouterDecorator();
所以我在一个基线的egg框架代码里面只安装了这个插件,然后进行压测
现在100%确定是这个封装router的插件代码存在内存泄露。