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

共 6186字,需浏览 13分钟

 ·

2021-10-29 09:12


导语 | 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函数前, 生成堆快照Aleak()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 



(二)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需要注意哪些问题?






浏览 52
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报