聊聊一个有趣的内存泄漏案例

本文通过一个实际的SSR项目中遇到的内存泄漏问题,详细阐述了如何复现、分析并解决由console.log引起的内存泄漏。通过分析发现,多次执行的VM环境中,对console对象的赋值操作形成了链式引用,导致内存无法回收。解决方案是使用Proxy进行隔离,避免对宿主环境的console对象直接操作。此外,还提供了开发阶段检测内存泄漏的方法,以预防类似问题的发生。
摘要由CSDN通过智能技术生成

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 多,并且还没有回收

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值