0. 背景
之前在这篇文章里说过做了个 SSR ,本以为今天顺顺利利,高高兴兴。
没想到项目放到线上后,随着请求量的增多,却感觉到首屏速度越来越慢,并且是在持续性地变慢。而且在发布完后(也就是容器重建了),耗时又陡然降下来了。
因此很合理地怀疑是内存泄漏了。故而在 STKE 的监控面板瞧一瞧,内存确实是一波一波似浪花。
1. 复现问题
知道是内存泄漏,我们就需要找到泄漏的点。因为不能轻易操作线上环境,线上代码也是压缩的,因此我们需要先搭建本地环境看能否方便调试问题。这里我们我们可以在本地起 Server 后,写脚本发起请求,来模拟线上环境。(但是看过上篇文章的小伙伴都知道,我们还有个骨架屏的模式,可以跳过发起 CGI 请求的步骤,大大降低单次请求耗时,让这个结果几秒钟就出来了)
我们可以使用 heapdump
包来将堆栈信息写入本地文件。 heapdump
的基本使用姿势是这样的:
const heapdump = require('heapdump');
heapdump.writeSnapshot('./test.heapsnapshot');
然后就可以将堆栈文件导入到 Chrome 开发者工具的 Memory
栏来分析。这里我选择了分别是运行了 1 次、50 次、100 次 以及等待几秒钟垃圾回收后再写个 101 次的堆栈信息。可以看到堆栈文件越变越大,从 35M 增大到 249M。
选择两个堆栈文件做比较来分析,这里有个技巧就是按内存大小排序,然后看到同一个大小的对象个数非常多,那么很有可能就是它被引用了很多次,泄漏的点就可能在那里。然后就发现了问题可能出在 console
对象上。
2. 分析问题
正常地使用 console
对象不会造成内存泄漏,因此就怀疑是否是对 console
做了什么操作。搜索了一番代码,排除正常调用外,发现有个赋值的操作,就类似于下面这段代码:
const nativeError = console.error;
console.error = (...argv) => {
// 省略一些操作
nativeError(...argv);
};
这段代码在前端开发中其实是比较常见的,比如需要在 log 中自动添加时间:
const nativeError = console.error;
console.error = (...argv) => {
nativeError(`[${(new Date()).toTimeString()}]`, ...argv);
};
console.error('Test');
// [20:58:17 GMT+0800 (中国标准时间)] Test
还有一个更常见的场景是,我们要在生产环境下屏蔽大部分的 log 输出,但是又要保留一个 log 函数引用,用来有时候在浏览器终端上输出一些关键信息,这时候会这么写:
// 引用,用来有时候在需要的时候上报
const logger = console.log;
// 必需用函数赋值,原有的一大堆使用 console.log('...') 的地方才不会报错
console.log = () => {};
logger(' 浏览器终端 AlloyTeam 招聘信息');
但是在我们的环境下,原来客户端的代码是被编译后放在 vm 里反复运行的,这会带来什么问题呢?
这里附个代码,感兴趣的小伙伴可以跑一下:
const vm = require('vm');
const heapdump = require('heapdump');
const total = 5000;
const writeSnapshot = (count) => {
heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`);
};
const code = `
const nativeError = console.error;
console.error = (...argv) => {
nativeError(argv);
}
`;
const script = new vm.Script(code);
for (let i = 1; i <= total; i++) {
script.runInNewContext({
console,
});
console.log(`${i}/${total}`);
switch (i) {
case 1:
case Math.floor(total * 0.5):
case total:
writeSnapshot(i);
}
}
setTimeout(() => {
writeSnapshot(total + 1);
}, 3000);
很小一段代码,运行 5000 次后内存占用到了 1G 多,并且还没有回收