
導語 | 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需要注意哪些問題?


轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/342237.html
標籤:其他
