Node.js內(nèi)存泄漏的原因竟然是……?

導(dǎo)語(yǔ)?|?Node.js內(nèi)存泄漏的問(wèn)題經(jīng)常讓開(kāi)發(fā)者頭疼,我們應(yīng)該怎么樣解決這類(lèi)問(wèn)題呢?本文通過(guò)一個(gè)V8引擎自身Bug導(dǎo)致Generator內(nèi)存泄漏案例,來(lái)介紹常用的應(yīng)對(duì)手段。
一、背景
最近新開(kāi)發(fā)了一個(gè)Node.js服務(wù),卻發(fā)現(xiàn)上線(xiàn)之后內(nèi)存一直持續(xù)上漲。相信很多使用Node.js做過(guò)服務(wù)端開(kāi)發(fā)的同學(xué),也遇到過(guò)這樣的問(wèn)題,這種情況就是典型的內(nèi)存泄漏。內(nèi)存泄漏雖然不會(huì)馬上讓?xiě)?yīng)用停止服務(wù),但是如果不處理的話(huà),輕則會(huì)導(dǎo)致你的應(yīng)用越來(lái)越慢,重則會(huì)導(dǎo)致應(yīng)用Crash。所以對(duì)于這種情況,我們不能掉以輕心。

二、為什么會(huì)內(nèi)存泄露
(一)C語(yǔ)言中的內(nèi)存管理(手動(dòng)管理)
在C語(yǔ)言中,我們?nèi)绻枰褂靡粋€(gè)變量來(lái)存儲(chǔ)某些值,需要開(kāi)發(fā)者先手動(dòng)調(diào)用malloc函數(shù),向系統(tǒng)申請(qǐng)一塊內(nèi)存,然后才能將相關(guān)信息保存到這塊內(nèi)存中。并且使用完之后,開(kāi)發(fā)者還要手動(dòng)調(diào)用free函數(shù)將這塊內(nèi)存給釋放掉:
int main(void){int *p = malloc(sizeof*p); // 申請(qǐng)一塊內(nèi)存*p = 10; // 將int類(lèi)型的10寫(xiě)入這塊內(nèi)存中printf("*p = %d\n", *p); // 輸出 *p = 10free(p); // 釋放內(nèi)存return 0;}
這種讓開(kāi)發(fā)者手動(dòng)管理內(nèi)存的方式,嚴(yán)重拖慢了開(kāi)發(fā)效率。而且開(kāi)發(fā)者忘記free的內(nèi)存塊,會(huì)一直無(wú)法釋放。這樣也會(huì)導(dǎo)致內(nèi)存泄漏。
(二)Node.js中的內(nèi)存管理(自動(dòng)管理)
為了解決手動(dòng)管理內(nèi)存帶來(lái)的問(wèn)題,V8在內(nèi)存管理方面做了改進(jìn):
開(kāi)發(fā)者在創(chuàng)建數(shù)據(jù)時(shí),V8會(huì)自動(dòng)分配對(duì)應(yīng)的內(nèi)存空間,無(wú)需再調(diào)用malloc。
V8引入了GC機(jī)制,自動(dòng)找到程序中不再需要使用的內(nèi)存,并將其釋放
這種方式雖然給我們解決了很大的麻煩,但是也留下了新的問(wèn)題:開(kāi)發(fā)者習(xí)慣于V8幫助我們進(jìn)行內(nèi)存管理,從而產(chǎn)生一種不需要關(guān)注應(yīng)用內(nèi)存的錯(cuò)覺(jué)。
實(shí)際上GC機(jī)制并不能完全幫我們回收所有“不需要的內(nèi)存”(開(kāi)發(fā)者認(rèn)為不需要的內(nèi)存,如果沒(méi)有妥善處理,GC還是不會(huì)去回收)
三、問(wèn)題排查
內(nèi)存泄漏問(wèn)題排查起來(lái)一般都會(huì)比較困難,最常用的方式是通過(guò)分析內(nèi)存泄漏前后的內(nèi)存快照,對(duì)比找出持續(xù)增長(zhǎng)的內(nèi)容。
(一)對(duì)比內(nèi)存快照
對(duì)比內(nèi)存快照的方式分為4步:
程序啟動(dòng)之后,生成堆快照A。
執(zhí)行可能導(dǎo)致內(nèi)存泄漏的操作。
內(nèi)存上漲后,生成堆快照B。
在Chrome Dev Tool中對(duì)比兩次快照,找出這段時(shí)間內(nèi)一直增長(zhǎng)的內(nèi)容。
原理
class Person {constructor(name) {this.name = name}}let persons = []function leak() {const bob = new Person('bob')persons.push(bob)}genHeapSnapshot() // 偽碼: 執(zhí)行l(wèi)eak函數(shù)前, 生成堆快照Aleak()genHeapSnapshot() // 偽碼: 執(zhí)行l(wèi)eak函數(shù)后, 生成堆快照B
內(nèi)存快照A中的信息:
1個(gè)array, 變量名為persons。
其他系統(tǒng)對(duì)象。
內(nèi)存快照B的信息:
1個(gè)array,變量名為persons。
1個(gè)Person,變量名為bob;被persons.0所引用;被leak函數(shù)的Context引用(在leak函數(shù)中定義)
1個(gè)string;被bob中的name屬性引用。
把2個(gè)快照做對(duì)比之后就能發(fā)現(xiàn):leak函數(shù)執(zhí)行完之后,內(nèi)存中多了1個(gè)Person對(duì)象和1個(gè)string。
當(dāng)leak函數(shù)執(zhí)行10000次后,內(nèi)存中就會(huì)增加10000個(gè)Person和string,我們只需要找到這些新增的對(duì)象,就能找到內(nèi)存增長(zhǎng)的原因。
實(shí)踐
獲取內(nèi)存快照的方式有很多,常用的有heapdump、v8-profiler等模塊。還可以通過(guò)啟用Inspector模式,在Chrome Dev Tool中采集Node.js應(yīng)用的堆內(nèi)存。
將快照加載到Chrome Dev Tool之后,我們看到增長(zhǎng)最多的對(duì)象是(system)、(array)、(string)、(compiled code)等。

但是當(dāng)試圖從(system)里邊找出問(wèn)題對(duì)象時(shí),就會(huì)發(fā)現(xiàn)事情沒(méi)有想象中那么簡(jiǎn)單。
兩次內(nèi)存快照之間,system新創(chuàng)建了39822個(gè),銷(xiāo)毀了39078個(gè),沒(méi)能正常銷(xiāo)毀的只占了1.8%。要找到這1.8%的問(wèn)題對(duì)象,需要耗費(fèi)不少時(shí)間。
雖然對(duì)比內(nèi)存快照的方式,大部分情況下都能幫我們解決問(wèn)題,但是這次的情況卻不太適用。當(dāng)然,除了快照對(duì)比,還有其他的一些方法,比如MAT。
(二)MAT
MAT(Memory Analizer Tool)是Eclipse中的一個(gè)插件,經(jīng)常被用來(lái)定位Java中的內(nèi)存泄漏問(wèn)題。MAT的思路是:如果發(fā)生了內(nèi)存泄漏,那么這些導(dǎo)致內(nèi)存泄漏的對(duì)象會(huì)在內(nèi)存占很大比重。
原理
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() // 偽碼: 執(zhí)行l(wèi)eak函數(shù)后, 生成堆快照
這個(gè)例子生成的內(nèi)存快照,其中的對(duì)象引用關(guān)系,如圖中所示(簡(jiǎn)化版,去掉了各種內(nèi)置對(duì)象):

支配樹(shù)中的每個(gè)節(jié)點(diǎn)都有一個(gè)Retained Size屬性,表示該節(jié)點(diǎn)所支配的內(nèi)存大小,節(jié)點(diǎn)自身的Retained Size=所有子節(jié)點(diǎn)的Retained Size+節(jié)點(diǎn)的Self Size(自己占用的內(nèi)存大小)
MAT的工作原理是將內(nèi)存快照轉(zhuǎn)換成一個(gè)支配樹(shù),將支配樹(shù)中所支配內(nèi)存超過(guò)一定閾值的對(duì)象認(rèn)為是可疑對(duì)象,找到這些對(duì)象的支配鏈,和鏈上的內(nèi)存積累點(diǎn)。
在我們的例子中,當(dāng)越來(lái)越多的Person被放進(jìn)persons數(shù)組時(shí),persons的Retained Size會(huì)變得越來(lái)越大。當(dāng)對(duì)象的Retained Size達(dá)到一達(dá)閾值(可自定義,默認(rèn)是占總內(nèi)存的20%),就認(rèn)為該對(duì)象是可疑對(duì)象。開(kāi)發(fā)者可以根據(jù)對(duì)象的支配鏈路,快速找到問(wèn)題所在。

實(shí)踐
可以使用v8-mat這個(gè)npm包,把內(nèi)存快照轉(zhuǎn)換成支配樹(shù),并找到內(nèi)存中的可疑對(duì)象。也可以使用Chrome Dev Tool對(duì)快照中的對(duì)象,按Retained Size進(jìn)行排序,自行判斷。
在服務(wù)運(yùn)行一天后,我們采集了內(nèi)存快照進(jìn)行分析,發(fā)現(xiàn)了一個(gè)內(nèi)存泄漏可疑點(diǎn):內(nèi)存中有一個(gè)Generator支配了73%的內(nèi)存!

雖然找到了可疑的支配鏈,但是支配鏈下的對(duì)象卻是些和業(yè)務(wù)代碼無(wú)關(guān)的內(nèi)置對(duì)象。

看到這里時(shí),已經(jīng)有點(diǎn)懷疑是否是Node.js本身存在的Bug。
(三)問(wèn)題解決
這時(shí)在網(wǎng)上發(fā)現(xiàn)了一個(gè)相似的案例:由于TS將async/await編譯成Generator,導(dǎo)致內(nèi)存泄漏。
(https://github.com/apollographql/apollo-server/issues/3730)
發(fā)現(xiàn)是V8引擎存在一個(gè)Bug,導(dǎo)致了在11.0.0-12.15.x,使用Generator時(shí),都會(huì)出現(xiàn)內(nèi)存泄漏!
解決方式有2個(gè):去除代碼中的Generator,將Node.js將級(jí)到12.16以上。
查看了tsconfig.json及編譯后的代碼,發(fā)現(xiàn)并無(wú)異常。再到node_modules中查找是否存在yield關(guān)鍵詞,結(jié)果卻搜出來(lái)幾十個(gè)使用了Generator的庫(kù)。改代碼是改不動(dòng)了,只能?chē)L試升級(jí)Node.js到14,看看內(nèi)存占用是否恢復(fù)正常。

可以看到升級(jí)之后,Node.js應(yīng)用的內(nèi)存消耗已經(jīng)下降了很多,并且保存在穩(wěn)定的狀態(tài),沒(méi)有再出現(xiàn)之前持續(xù)增長(zhǎng)的情況。至此,內(nèi)存泄漏的問(wèn)題已經(jīng)解決。
四、常見(jiàn)的內(nèi)存泄露場(chǎng)景
最后列舉一些常見(jiàn)的內(nèi)存泄漏場(chǎng)景,在開(kāi)發(fā)過(guò)程中,對(duì)這些情況稍加注意,能幫助我們避免大部分的內(nèi)存泄漏問(wèn)題。
(一)隱式全局變量
沒(méi)有使用var/let/const聲明的變量會(huì)直接綁定在Global對(duì)象上(Node.js中)或者Windows對(duì)象上(瀏覽器中),哪怕不再使用,仍不會(huì)被自動(dòng)回收:
function test() {x = new Array(100000);}test();console.log(x); // 輸出 [ <100000 empty items> ]
(二)沒(méi)釋放的無(wú)用對(duì)象(監(jiān)聽(tīng)器、緩存)
沒(méi)有釋放的監(jiān)聽(tīng)器,會(huì)一直保存在內(nèi)存中,導(dǎo)致內(nèi)存無(wú)法釋放:
class Test {constructor() {this.init()}init() {emitter.addListener('message', function() {// 相關(guān)操作});}destroy() {// 沒(méi)有removeListener}}
使用內(nèi)存作為緩存時(shí),沒(méi)有釋放過(guò)期的緩存也是常見(jiàn)的情況:
const app = require('express')()const cache = {};// 設(shè)置緩存app.post('/data', (req, res) => {cache[req.body.key] = req.body.valueres.send('succ')})// 獲取緩存app.get('/data', (req, res) => {res.send(cache[req.params.key])})
(三)閉包
閉包也是導(dǎo)致內(nèi)存泄漏的常見(jiàn)原因。
const func = function () {const data = 'inner variable'return () => {return data}}const getData = func()console.log(getData()) // 此時(shí)func函數(shù)內(nèi)部的data變量無(wú)法釋放
五、相關(guān)工具介紹
(一)heapdump
(https://github.com/bnoordhuis/node-heapdump)
老牌內(nèi)存快照生成庫(kù),可以通過(guò)API或者系統(tǒng)信號(hào)的形式,生成內(nèi)存快照。缺點(diǎn)是只支持內(nèi)存快照生成,不支持生成CPU Profile文件。
使用API生成快照:
var heapdump = require('heapdump');heapdump.writeSnapshot('/var/local/' + Date.now() + '.heapsnapshot');
使用系統(tǒng)信號(hào)生成快照:kill -USR2 (二)v8-profiler
(https://github.com/hyj1991/v8-profiler-next)
支持生成CPU Profile/堆快照/Allocation Profile,缺點(diǎn)是需要登陸機(jī)器將生成的文件下載后,使用其他工具進(jìn)行分析。
生成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);
生成堆內(nèi)存快照:
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參數(shù)啟動(dòng)服務(wù),會(huì)默認(rèn)在9229端口啟動(dòng)一個(gè)websocket server,Chrome DevTool連接該端口后,可以對(duì)Node.js程序進(jìn)行Debug。Chrome DevTool功能齊全,缺點(diǎn)是線(xiàn)上機(jī)房網(wǎng)絡(luò)與本地開(kāi)發(fā)網(wǎng)絡(luò)不通,使用不便,通常只在DevCloud開(kāi)發(fā)機(jī)中使用。
開(kāi)啟inspect模式:
node --inspect=0.0.0.0:9229 app.js訪問(wèn)chrome://inspect/可以對(duì)指定進(jìn)程進(jìn)行調(diào)試,采集CPU Profile、堆快照等。


六、結(jié)語(yǔ)
雖然JavaScript、Java等語(yǔ)言能幫我們自動(dòng)回收內(nèi)存,提高了開(kāi)發(fā)效率,但是這并不意味著不會(huì)出現(xiàn)內(nèi)存泄漏的情況。作為開(kāi)發(fā)者,在開(kāi)發(fā)過(guò)程中也需要對(duì)可能的內(nèi)存泄漏,保持敏銳的嗅覺(jué)。同時(shí)還需要了解相關(guān)的問(wèn)題排查方法,即便是應(yīng)用上線(xiàn)之后才發(fā)現(xiàn)問(wèn)題,我們也能夠快速將它解決。
?作者簡(jiǎn)介
王思鴻
騰訊高級(jí)前端工程師
騰訊高級(jí)前端工程師,畢業(yè)于華中科技大學(xué),目前負(fù)責(zé)騰訊教育企鵝輔導(dǎo)業(yè)務(wù)的開(kāi)發(fā)工作。專(zhuān)注于前端性能優(yōu)化與全棧開(kāi)發(fā),在Node.js監(jiān)控領(lǐng)域有深入研究。
?推薦閱讀
超詳細(xì)教程!手把手帶你使用Raft分布式共識(shí)性算法
Pulsar與Rocketmq、Kafka、Inlong-TubeMQ,誰(shuí)才是消息中間件的王者?
gRPC如何在Golang和PHP中進(jìn)行實(shí)戰(zhàn)?7步教你上手!
詳細(xì)解答!從C++轉(zhuǎn)向Rust需要注意哪些問(wèn)題?


