Node.js内存泄漏的原因竟然是……?

Posted QcloudCommunity

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Node.js内存泄漏的原因竟然是……?相关的知识,希望对你有一定的参考价值。

导语 | Node.js内存泄漏的问题经常让开发者头疼,我们应该怎么样解决这类问题呢?本文通过一个V8引擎自身Bug导致Generator内存泄漏案例,来介绍常用的应对手段。

一、背景

最近新开发了一个Node.js服务,却发现上线之后内存一直持续上涨。相信很多使用Node.js做过服务端开发的同学,也遇到过这样的问题,这种情况就是典型的内存泄漏。内存泄漏虽然不会马上让应用停止服务,但是如果不处理的话,轻则会导致你的应用越来越慢,重则会导致应用Crash。所以对于这种情况,我们不能掉以轻心。

二、为什么会内存泄露


(一)C语言中的内存管理(手动管理)

在C语言中,我们如果需要使用一个变量来存储某些值,需要开发者先手动调用malloc函数,向系统申请一块内存,然后才能将相关信息保存到这块内存中。并且使用完之后,开发者还要手动调用free函数将这块内存给释放掉:

# include 
# include 
int main(void)
{
    int *p = malloc(sizeof*p); // 申请一块内存
    *p = 10; // 将int类型的10写入这块内存中
    printf("*p = %d\\n", *p); // 输出 *p = 10
    free(p); // 释放内存
    return 0;
}

这种让开发者手动管理内存的方式,严重拖慢了开发效率。而且开发者忘记free的内存块,会一直无法释放。这样也会导致内存泄漏。

(二)Node.js中的内存管理(自动管理)

为了解决手动管理内存带来的问题,V8在内存管理方面做了改进:

  • 开发者在创建数据时,V8会自动分配对应的内存空间,无需再调用malloc。

  • V8引入了GC机制,自动找到程序中不再需要使用的内存,并将其释放

这种方式虽然给我们解决了很大的麻烦,但是也留下了新的问题:开发者习惯于V8帮助我们进行内存管理,从而产生一种不需要关注应用内存的错觉

实际上GC机制并不能完全帮我们回收所有“不需要的内存”(开发者认为不需要的内存,如果没有妥善处理,GC还是不会去回收)

三、问题排查


内存泄漏问题排查起来一般都会比较困难,最常用的方式是通过分析内存泄漏前后的内存快照,对比找出持续增长的内容。

(一)对比内存快照

对比内存快照的方式分为4步

  • 程序启动之后,生成堆快照A。

  • 执行可能导致内存泄漏的操作。

  • 内存上涨后,生成堆快照B。

  • 在Chrome Dev Tool中对比两次快照,找出这段时间内一直增长的内容。

  • 原理

class Person {
  constructor(name) {
    this.name = name
  }
}


let persons = []


function leak() {
  const bob = new Person('bob')
  persons.push(bob)
}


genHeapSnapshot() // 伪码: 执行leak函数前, 生成堆快照A
leak()
genHeapSnapshot() // 伪码: 执行leak函数后, 生成堆快照B

内存快照A中的信息:

  • 1个array, 变量名为persons。

  • 其他系统对象。

内存快照B的信息:

  • 1个array,变量名为persons。

  • 1个Person,变量名为bob;被persons.0所引用;被leak函数的Context引用(在leak函数中定义)

  • 1个string;被bob中的name属性引用。

把2个快照做对比之后就能发现:leak函数执行完之后,内存中多了1个Person对象和1个string

当leak函数执行10000次后,内存中就会增加10000个Person和string,我们只需要找到这些新增的对象,就能找到内存增长的原因。

  • 实践

获取内存快照的方式有很多,常用的有heapdump、v8-profiler等模块。还可以通过启用Inspector模式,在Chrome Dev Tool中采集Node.js应用的堆内存。

将快照加载到Chrome Dev Tool之后,我们看到增长最多的对象是(system)、(array)、(string)、(compiled code)等。

但是当试图从(system)里边找出问题对象时,就会发现事情没有想象中那么简单。

两次内存快照之间,system新创建了39822个,销毁了39078个,没能正常销毁的只占了1.8%。要找到这1.8%的问题对象,需要耗费不少时间。

虽然对比内存快照的方式,大部分情况下都能帮我们解决问题,但是这次的情况却不太适用。当然,除了快照对比,还有其他的一些方法,比如MAT。

(二)MAT

MAT(Memory Analizer Tool)是Eclipse中的一个插件,经常被用来定位Java中的内存泄漏问题。MAT的思路是:如果发生了内存泄漏,那么这些导致内存泄漏的对象会在内存占很大比重

  • 原理

class Person {}


let persons = []
let women = []


function leak() {
  const bob = new Person()
  const steve = new Person()
  const lily = new Person()
  persons.push(bob, steve, lily)
  women.push(lily)
}


leak()
genHeapSnapshot() // 伪码: 执行leak函数后, 生成堆快照

这个例子生成的内存快照,其中的对象引用关系,如图中所示(简化版,去掉了各种内置对象):

支配树中的每个节点都有一个Retained Size属性,表示该节点所支配的内存大小,节点自身的Retained Size=所有子节点的Retained Size+节点的Self Size(自己占用的内存大小)

MAT的工作原理是将内存快照转换成一个支配树,将支配树中所支配内存超过一定阈值的对象认为是可疑对象,找到这些对象的支配链,和链上的内存积累点

在我们的例子中,当越来越多的Person被放进persons数组时,persons的Retained Size会变得越来越大。当对象的Retained Size达到一达阈值(可自定义,默认是占总内存的20%),就认为该对象是可疑对象。开发者可以根据对象的支配链路,快速找到问题所在。

  • 实践

可以使用v8-mat这个npm包,把内存快照转换成支配树,并找到内存中的可疑对象。也可以使用Chrome Dev Tool对快照中的对象,按Retained Size进行排序,自行判断。

在服务运行一天后,我们采集了内存快照进行分析,发现了一个内存泄漏可疑点:内存中有一个Generator支配了73%的内存!

虽然找到了可疑的支配链,但是支配链下的对象却是些和业务代码无关的内置对象。

看到这里时,已经有点怀疑是否是Node.js本身存在的Bug。

(三)问题解决

这时在网上发现了一个相似的案例:由于TS将async/await编译成Generator,导致内存泄漏。

(https://github.com/apollographql/apollo-server/issues/3730)

发现是V8引擎存在一个Bug,导致了在11.0.0-12.15.x,使用Generator时,都会出现内存泄漏!

解决方式有2个:去除代码中的Generator,将Node.js将级到12.16以上。

查看了tsconfig.json及编译后的代码,发现并无异常。再到node_modules中查找是否存在yield关键词,结果却搜出来几十个使用了Generator的库。改代码是改不动了,只能尝试升级Node.js到14,看看内存占用是否恢复正常。

可以看到升级之后,Node.js应用的内存消耗已经下降了很多,并且保存在稳定的状态,没有再出现之前持续增长的情况。至此,内存泄漏的问题已经解决。

四、常见的内存泄露场景

最后列举一些常见的内存泄漏场景,在开发过程中,对这些情况稍加注意,能帮助我们避免大部分的内存泄漏问题。


(一)隐式全局变量

没有使用var/let/const声明的变量会直接绑定在Global对象上(Node.js中)或者Windows对象上(浏览器中),哪怕不再使用,仍不会被自动回收:

function test() {
  x = new Array(100000);
}
test();
console.log(x); // 输出 [ <100000 empty items> ]

(二)没释放的无用对象(监听器、缓存)

没有释放的监听器,会一直保存在内存中,导致内存无法释放:

class Test {
  constructor() {
    this.init()
  }
  init() {
    emitter.addListener('message', function() {
      // 相关操作
    });
  }
  destroy() {
    // 没有removeListener
  }
}

使用内存作为缓存时,没有释放过期的缓存也是常见的情况:

const app = require('express')()
const cache = {};
// 设置缓存
app.post('/data', (req, res) => {
  cache[req.body.key] = req.body.value
  res.send('succ')
})
// 获取缓存
app.get('/data', (req, res) => {
  res.send(cache[req.params.key])
})

(三)闭包

闭包也是导致内存泄漏的常见原因。

const func = function () {
  const data = 'inner variable'
  return () => {
    return data
  }
}
const getData = func()
console.log(getData()) // 此时func函数内部的data变量无法释放

五、相关工具介绍

(一)heapdump

(https://github.com/bnoordhuis/node-heapdump)

老牌内存快照生成库,可以通过API或者系统信号的形式,生成内存快照。缺点是只支持内存快照生成,不支持生成CPU Profile文件。

使用API生成快照:

var heapdump = require('heapdump');
heapdump.writeSnapshot('/var/local/' + Date.now() + '.heapsnapshot');
使用系统信号生成快照:
kill -USR2 <pid>

(二)v8-profiler

(https://github.com/hyj1991/v8-profiler-next)

支持生成CPU Profile/堆快照/Allocation Profile,缺点是需要登陆机器将生成的文件下载后,使用其他工具进行分析。

生成CPU Profile文件:

const v8Profiler = require('v8-profiler-next');
const title = 'good-name';
v8Profiler.startProfiling(title, true);
setTimeout(() => {
  const profile = v8Profiler.stopProfiling(title);
  profile.export(function (error, result) {
    fs.writeFileSync(`${title}.cpuprofile`, result);
    profile.delete();
  });
}, 5 * 60 * 1000);

生成堆内存快照:

const v8Profiler = require('v8-profiler-next');
const snapshot = v8Profiler.takeSnapshot();
const transform = snapshot.export();
transform.pipe(process.stdout);
transform.on('finish', snapshot.delete.bind(snapshot))

生成Allocation Profile:

const v8Profiler = require('v8-profiler-next');
const arraytest = [];
setInterval(() => {
  arraytest.push(new Array(1e2).fill('*').join());
}, 20);


v8Profiler.startSamplingHeapProfiling();
setTimeout(() => {
  const profile = v8Profiler.stopSamplingHeapProfiling();
  require('fs').writeFileSync('./shf.heapprofile', JSON.stringify(profile));
}, 60 * 1000);

(三)Chrome Inspector

使用--inspect参数启动服务,会默认在9229端口启动一个websocket server,Chrome DevTool连接该端口后,可以对Node.js程序进行Debug。Chrome DevTool功能齐全,缺点是线上机房网络与本地开发网络不通,使用不便,通常只在DevCloud开发机中使用。

开启inspect模式:

node --inspect=0.0.0.0:9229 app.js

访问chrome://inspect/可以对指定进程进行调试,采集CPU Profile、堆快照等。

六、结语

虽然javascript、Java等语言能帮我们自动回收内存,提高了开发效率,但是这并不意味着不会出现内存泄漏的情况。作为开发者,在开发过程中也需要对可能的内存泄漏,保持敏锐的嗅觉。同时还需要了解相关的问题排查方法,即便是应用上线之后才发现问题,我们也能够快速将它解决。

 作者简介

王思鸿

腾讯高级前端工程师

腾讯高级前端工程师,毕业于华中科技大学,目前负责腾讯教育企鹅辅导业务的开发工作。专注于前端性能优化与全栈开发,在Node.js监控领域有深入研究。

 推荐阅读

超详细教程!手把手带你使用Raft分布式共识性算法

Pulsar与Rocketmq、Kafka、Inlong-TubeMQ,谁才是消息中间件的王者?

gRPC如何在Golang和PHP中进行实战?7步教你上手!

详细解答!从C++转向Rust需要注意哪些问题?


以上是关于Node.js内存泄漏的原因竟然是……?的主要内容,如果未能解决你的问题,请参考以下文章

Websocket 内存泄漏 node.js。事件发射器?

Node.js 内存泄漏?

node.js 中的长循环泄漏内存

Node.js内存泄漏分析

Node.js 刮板中的内存泄漏

Node.js内存泄漏分析