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的插件代码存在内存泄露。

posted @ 2023-03-14 17:33  书生轻狂  阅读(263)  评论(0编辑  收藏  举报