我是如何搞定 NodeJS 記憶體洩漏問題的

kazaff's blog發表於2017-01-09

最近又用node寫了一個小工具,需要常駐程式,經過幾天的觀察,發現記憶體佔用有持續增加的趨勢(雖然不明顯,但還是讓我察覺到了,我真屌)。突然發現,我竟然不知道怎麼排查nodejs的記憶體洩漏,嚇死寶寶了!

花時間看了一下相關資料(google真好,外果仁真屌),看來這部分也已經有比較完善的方法論+工具了。所以這篇文章記錄一下自己從不懂到入門的經歷~~

我希望這篇文章不僅能提供具體的工具供大家使用,還提供足夠的理論知識來輔助大家思考,當然,也可能是我自己想多了~~哇哈

發現問題

由於沒有太多運維經驗,也不知道啥逆天的工具來幫我一鍵式監控所需要的指標,如果你和我情況一樣,那我們只能手動來造個簡陋的但夠用的監控指令碼了。

別告訴我你和我一樣shell也不熟,直接就node吧。少廢話~

先裝上pm2,然後寫一個指令碼,來定時列印目標應用的記憶體使用率,當然,前提是目標應用也都放在pm2中管理。

const exec = require('child_process').exec;
var Later = require('later');
var schedule = Later.parse.text('every 5 mins');	// 每5分鐘正點觸發
Later.setInterval(function(){
	exec('pm2 jlist', {	// 列印出pm2中應用的基本狀態資訊,輸出是json字串
		timeout: 2000
	}, (err, data, stderr)=>{
		if (err) {
			console.error(err, err.stack);	// an error occurred
			return;
		}
		//將結果寫入日誌
		data = JSON.parse(data);
		if(data[0]){	// 這裡取0是因為我希望監控的應用在pm2中的順序是第一位
			console.log(data[0].monit.memory/(1024*1024));	// 直接輸出到pm2的log中
		}
	});
}, schedule);

然後就等一段時間,就會在對應的log檔案中拿到相關的記憶體資料,然後只需要用電子表格生成一個圖示即可,我推薦使用google drive的spreadsheet:

上面的圖是我收集了大概2天的記憶體資料繪製成的圖示,可以看出記憶體使用量成上升趨勢。沒錯,就是洩漏了!!

友情提醒: 修復記憶體洩漏可能會耗時很久,你最好先找一個臨時方案來維持生計,例如定期重啟程式。

搭建環境

本著實戰為主的策略,我們先從搭建記憶體洩漏監控環境開始。剛開始參考node-memory-leak-tutorial,以為會很順利搭建好的,不過碰到了這個error。看Issus應該是個很常見的錯誤,按照別人的解決方案,嘗試切換成nodejs 6.3.1版本進行了測試,確實可以繞過那個錯誤:

// 在專案目錄下
node-debug leak.js

然後終端會啟動你的chrome,並停在程式碼的斷點位置,深吸一口氣你就可以點選執行了。

備註:若遇到無法建立快照問題,需要多重新整理幾次喲~

其它工具我也順便試了一下:

  1. node-memwatch
  2. node-webkit-agent
  3. node-heapdump

因為它們都需要根據作業系統進行編譯,我的本地環境是 win7 64bit,這並不是一個理想的nodejs環境,至少我這麼認為,否則也不會碰到噁心的“.net framework”問題。我勸大家千萬別學我輕易的就刪除了 .net framework 3.5 這個安裝包,因為這是win7自帶的,刪了以後就裝不上了,而裝更新的4.0+版本的話我這邊很重要的一個軟體就無法執行了(翻牆你懂的)。在Windows 7系統上安裝.NET Framework 3.5框架很不容易的!建議可以用上docker來搭建一個專門用來分析用的容器,這裡我就不折騰下去了,its your turn~~

nodejs記憶體分析的理論姿勢

在開始聽我正兒八經胡說八道之前,推薦你先看幾個文件:

一次性看完這些,可能要花很久,如此貼心的我已經幫你看過了,根據我的理解,總結如下:

  • javascript的v8記憶體管理和java jvm類似,都有新生代(To-Space and From-Space),老年代等;
  • 排查記憶體洩漏需要分析記憶體快照,可以使用已有的工具以devtool的profile皮膚或程式碼的方式建立snapshot;
  • 建立的快照檔案可以匯入devtool的profile進行分析;
  • 快照生成的最佳實踐是:先保證程式已經預熱,然後進行快照1(先觸發GC),然後對程式進行一些互動(例如:對於web服務即http請求),再次建立快照2,如此迴圈來生成多個版本的快照;
  • 合理的利用devtool的profile提供的功能,正確的選擇檢視;
  • 理解profie中的欄位含義:
    • 物件上的黃色標識表示的是javascript直接引用,紅色表示間接依賴引用,不太需要關注的是無底色物件,其代表被其它資源引用(如:natvie code);
    • profile會根據物件的構造方法對物件進行分組歸類,每個組對應的“Shallow Size”表示的是該組物件的直接記憶體佔用大小(例如:該類物件自身的原始型別資料的記憶體佔用),對應的“Retained Size”表示的是該組物件依賴的其它物件而造成的記憶體佔用總數(等於自身的Shallow Size + 依賴物件的Shallow Size [ + 依賴物件的依賴物件的Shallow Size [ + 遞迴下去]]);
    • 由於效能原因,profile中不會顯示物件的整型型別的屬性,但是它們並沒有丟失,僅僅是工具沒有顯示出來而已。
  • 應該警惕“distance”比較大或比較小的物件,總之和其它同型別物件的distance不一樣就意味著可能有問題;
  • 儘量不要用匿名函式,函式有名字會讓分析更容易,其實更推薦的是使用OOP,這樣會最容易定位需要追蹤的變數,畢竟都是構造器建立出來的嘛;
  • 閉包(匿名函式,定時器等)建立的上下文引用很容易造成不易察覺的記憶體洩漏;
  • console的相關函式(log, error等)在實際分析中發現其引用的變數無法釋放,可以參考#1741,所以你可以在測試程式碼中替換掉console的相關函式(這樣你就不需要改動被測程式碼邏輯了);
  • 物件上的事件監聽器的閉包最容易造成洩漏,即便是使用once,也可能一次都沒有觸發而導致該回撥函式無限期引用資料。

ok,一大堆姿勢足夠你花很久時間閱讀了。不過並不是說你看了這些內容,就可以輕鬆戰勝困難了。還有一個環節我們沒有討論:若你的專案足夠複雜(大),那要怎麼搭建專案的測試環境呢?

這裡我認為,大概需要按照下面的步驟來做:

  1. 將完整的專案拆解成獨立的不同塊,併為每個拆解後的小模組寫測試程式碼
  2. 針對定時器相關的邏輯,最好改成手動觸發,或利用測試庫(sinonjs)模擬時間片段
  3. 初期可以先儘可能排除依賴的第三方庫,最後酌情去測試它們(如果你懷疑是它們的問題的話)
  4. 低階別異常偽造(例如socket,file等)要靠偽造對應方法(不推薦使用sinonjs.stubs,因為它儲存每次呼叫時的引數資料,影響你觀察,不妨試試mockery
  5. 最終還是有必要放線上上環境實測一段時間來觀測問題是否真的修復了

我們主要來說一下第5條,其意味著你要線上上環境想辦法匯出快照到本地來分析。下面來看看怎麼做:

首先,你給線上環境中安裝v8-profiler庫,它用來提供建立快照的功能。

然後,看一下下面的這段樣板程式碼,其意義在於在你的專案中載入v8-profiler庫,並提供一個對外指令用來通知它建立快照檔案。

var fs = require('fs');
var profiler = require('v8-profiler');
// ---------------
// 測試目標
function LeakingClass(){}
var leaks = [];
setInterval(function(){
	for(var i = 0; i < 100; i++){
		leaks.push(new LeakingClass);
	}
	console.error('Leaks: %d', leaks.length);
}, 1000);
// ---------------
// 指令服務
var koa = require('koa');
var route = require('koa-route');
var service = koa();
var snapshotNum = 1;	// 用於為生成的快照進行編號
service.use(route.get('/snapshot', function *(){
	var response = this;
	var snapshot = profiler.takeSnapshot();
	snapshot.export(function(error, result) {
  	fs.writeFileSync((snapshotNum++) + '.heapsnapshot', result);
  	snapshot.delete();
		response.body = 'done';
	});
}));
service.listen(2333, '127.0.0.1');	// 推薦繫結內網ip,不要允許外網訪問該服務

每次請求http://127.0.0.1:2333/snapshot,你都會在專案根目錄生成一個快照檔案,然後把它下載到本地磁碟就可以在chrome裡隨時進行分析了。

總結

在實際排查過程中,發現最難測試的還是依賴的第三方庫的洩漏問題。畢竟你無法理解它們的實現。但不可能所有邏輯都自己來完成,所以面對各種各樣的第三方類庫,還是建議選擇儘可能權威的,主流的。剩下那些很小的功能模組,就只能花時間研讀其實現程式碼了。

如果你的業務採用了生產消費者模式,你的測試指令碼一定要保證任務的生產和消費的速率保持同速率(或者乾脆確保消費者處理完一批次的任務耗時一定要小於批次建立的間隔時間),不然由於任務得不到處理,必然會產生任務積累,看起來就好像有記憶體洩漏一樣,但其實這種情況其實是合理的,只是說明你的消費者太少了而已。

另外,一定要最大頻度的,儘可能長時間的執行測試程式碼,才能明顯的暴露出問題,例如:

setInterval(function memoryleakBlock(){
	// 待測試的程式碼塊
}, 100);

注意在上面memoryleakBlock中避免引用全域性變數喲,這樣你執行一夜,第二天上班來看結果(如果它還跑著的話)。

相關文章