故事背景
由于我们线上验证码突然出现假死,记录最后一次node崩溃的线上node内存使用达到了1.36GB,所以基本断定是内存泄漏了
工具
heapdump(npm包) chrome浏览器中的开发者工具(memory)
准备
先在入口文件中加入
const heapdump = require('heapdump');
我们的环境用的是k8s服务,所以每个prod都有自己的虚拟域名(所以下面的域名是不存在的), 我们获取验证码的接口是http://valve-tdbase/valve/verification/get 下面是我的请求的测试脚本
const http = require("http");
for(let i=0;i<500;i++)
{
http.get('http://valve-tdbase/valve/verification/get', (res) => {});
}
开干
然后使用node启动入口文件(请勿用pm2启动服务,pm2开的子进程容易造成数据的迷惑) 启动后,查出node的PID,如图 接下来每运行一次测试脚本就执行一次下面的命令(2981是我启动node服务的PID)
kill -USR2 2981
生成出内存快照后,进入chrome开发者工具进行对比,使用对比模式(comparision),将两次执行测试脚本后生成的快照进行对比,发现CAP类型的数据回收数量为0,并且新增数量巨大,如下图
并且发现绑定在reg_ary中,遂查找调用链。发现以下可能存在问题的代码(有兴趣可以先看一下)
以下是我们业务代码(每一次调用都会走,存在删减):
_M.prototype.get = function* () {
try{
this.log.info('request get default verification code');
var ccap = require('ccap');
var captcha = ccap(default_cfg.style);
var ary = captcha.get();
var code = ary[0];
var buffer = ary[1];
var pid = randomWord(false,16);
yield this.redis_client.setex(pid,default_cfg.expire_date,code.toLowerCase());
return {pid:pid,img:'data:'+default_cfg.type+';base64,'+buffer.toString('base64')}
}catch(e){
this.log.error('get default verification code error:',e);
return null;
}
};
以下是ccap库的代码(存在删减): hcap.js:
...
var timer = require('./timer.js');
...
var CAP = function(args){
...
}
module.exports = function(args){
var cap = CAP(args);
ins_count++;
timer.reg_ary.push(cap);//将实例化之后的对象注册到timer定时器中
return cap;
};
dirObj.dir();//设置路径权限,清空历史文件夹
dirObj.setSchedule();//开启定时器,每天凌晨2点清理文件夹
timer.timer();//启动计时器,每分钟更新缓存
timer.js:
var timer = {
timeout:1000*60,//更新验证码时间1分钟
reg_ary:[],
isRunning:0,
}
timer.timer = function(notFirst){
...
var rary = timer.reg_ary,
len = rary.length;
for(var i=0;i<len;i++){
try{
rary[i]._timer();
}
catch(e){
console.error(e)
}
}
...
}
module.exports = timer;
发现问题
业务代码中存在这段,每次请求都会调用
var ccap = require('ccap');
var captcha = ccap(default_cfg.style);
而运行ccap,会把CAP这个方法不断推送到timer.js文件的timer变量的reg_ary数组中! 重点reg_ary是绑定在require(’./timer.js’)的,所以不会回收,而一直缓存的!
module.exports = function(args){
var cap = CAP(args);
ins_count++;
timer.reg_ary.push(cap);//将实例化之后的对象注册到timer定时器中
return cap;
};
导致绑定在reg_ary的数据越来越多!
解决
将ccap(default_cfg.style);放到当前文件上面即可。
var ccap = require('ccap');
var captcha = ccap(default_cfg.style);
_M.prototype.get = function* () {
try{
this.log.info('request get default verification code');
var ary = captcha.get();
var code = ary[0];
var buffer = ary[1];
var pid = randomWord(false,16);
yield this.redis_client.setex(pid,default_cfg.expire_date,code.toLowerCase());
return {pid:pid,img:'data:'+default_cfg.type+';base64,'+buffer.toString('base64')}
}catch(e){
this.log.error('get default verification code error:',e);
return null;
}
};
总结
虽然这份业务代码不是我也写的,但自认如果自己写,也有可能会遇到这样的坑,所以希望ccap的作者有时间能够改良一下,虽然可能我有时间也会PR,嘿嘿!@DoubleSpout
简化一下成这样
// 文件 a
const obj = {
array: []
};
// 文件 b
const obj = require("./a"); // 文件a是被缓存的,不会被垃圾回收
function createUser(username) {
// 如果频繁调用这个函数,数组长度不断增长,内存不断增加,直到内存溢出
obj.array.push({
name: username
});
}
这个故事告诉我们,数组不能随便push,对象不能随便挂载,另外上alinode
@axetroy 666,这简化可以。准确的说使用某个库前,必须先了解这个库怎么用会内存溢出。 其实这个最大的坑还不是内存泄露,而是这个库会遍历这个不断增加的数组,导致最后的效果和死循环一样。
感谢分享
没有用 alinode 定位吗
@axetroy 动态类型一时爽,就是有人不信邪。js的动态性总是被很多人滥用,我刚刚就又被坑了 if (Date.now() - body.ttl > 60000) 在用go语言重写时没注意到这个ttl传的居然是个字符串,结果上线运行几天才发现
我也见过一个同事的代码在每一个controller当中实例化一个redis实例。你们之前的写法明显是有问题的,初始化就只应该发生一次。ccap没什么可改的吧。
严格来说,不算是内存泄露吧?因为 reg_ary
本身就不应该被回收,只能说是类库使用有问题。统一在文件头部 require
并初始化就能避免
所以你需要上 Typescript.
但是港真,即便上 Typescript,也不能解决,还需要手动校验.
毕竟 Body 是动态传入的, 即便你定义了 interface body.ttl 是 number, 但人家硬是传给你 string
用 Go 写的话就舒服得多了,序列化映射到结构体的时候,类型不对就已经抛出错误了
@JacksonTian 没有,主要是公司这边的机器都是是独立的,没有使用任何外部服务,上这个运维比较担心信息泄露等一系列问题,而且机器都是通过vpn链接到外面,即使使用了,应该是不能链接到阿里云这边的服务。所以我也没太大把握去说服和推动这件事。
@alsotang 主要是不明真相的群众,一不小心误用了,一点提示都没有。我觉得实例太多了还是要给出一些提示的,至少。
@xiedacon 内存泄露主要就是因为写法存在问题导致的。跟require无关的,主要是初始化的问题