輕鬆排查線上Node內存泄漏問題

I. 三種比較典型的內存泄漏

一. 閉包引用致使的泄漏

這段代碼已經在不少講解內存泄漏的地方引用了,很是經典,因此拿出來做爲第一個例子,如下是泄漏代碼:node

'use strict'; const express = require('express'); const app = express(); //如下是產生泄漏的代碼 let theThing = null; let replaceThing = function () { let leak = theThing; let unused = function () { if (leak) console.log("hi") }; // 不斷修改theThing的引用 theThing = { longStr: new Array(1000000), someMethod: function () { console.log('a'); } }; }; app.get('/leak', function closureLeak(req, res, next) { replaceThing(); res.send('Hello Node'); }); app.listen(8082);

js中的閉包很是有意思,經過打印heapsnapshot,在chrome的dev tools中展現,會發現閉包中真正存儲本做用域數據的是類型爲 closure 的一個函數(其__proto__指向的function)的 context 屬性指向的對象。c++

這個例子中泄漏引發的緣由就是v8對上述的 context 選擇性持有本做用域的數據的兩個特色:git

  • 父做用域的全部子做用域持有的閉包對象是同一個。
  • 該閉包對象是子做用域閉包對象中的 context 屬性指向的對象,而且其中只會包含全部的子做用域中使用到的父做用域變量。

二. 原生Socket重連策略不恰當致使的泄漏

這種類型的泄漏本質上node中的events模塊裏的偵聽器泄漏,由於比較隱蔽,因此放在第二個例子,如下是泄漏代碼:github

const net = require('net'); let client = new net.Socket(); function connect() { client.connect(26665, '127.0.0.1', function callbackListener() { console.log('connected!'); }); } //第一次鏈接 connect(); client.on('error', function (error) { // console.error(error.message); }); client.on('close', function () { //console.error('closed!'); //泄漏代碼 client.destroy(); setTimeout(connect, 1); });

泄漏產生的緣由其實也很簡單:event.js 核心模塊實現的事件發佈/訂閱本質上是一個js對象結構(在v6版本中爲了性能採用了new EventHandles(),而且把EventHandles的原型置爲null來節省原型鏈查找的消耗),所以咱們每一次調用 event.on 或者 event.once 至關於在這個對象結構中對應的 type 跟着的數組增長一個回調處理函數。chrome

那麼這個例子裏面的泄漏屬於很是隱蔽的一種:net 模塊的重連每一次都會給 client 增長一個 connect事件 的偵聽器,若是一直重連不上,偵聽器會無限增長,從而致使泄漏。express

三. 不恰當的全局緩存致使的泄漏

這個例子就比較簡單了,可是也屬於在失誤狀況下容易不當心寫出來的,如下是泄漏代碼npm

'use strict'; const easyMonitor = require('easy-monitor'); const express = require('express'); const app = express(); const _cached = []; app.get('/arr', function arrayLeak(req, res, next) { //泄漏代碼 _cached.push(new Array(1000000)); res.send('Hello World'); }); app.listen(8082);

若是咱們在項目中不恰當的使用了全局緩存:主要是指只有增長緩存的操做而沒有清除的操做,那麼就會引發泄漏。json

這種緩存引用不當的泄漏雖然簡單,可是我曾經親自排查過:Appium自動化測試工具中,某一個版本的日誌緩存策略有bug,致使搭建的server跑一段時間就重啓。數組

II. 常規排查方式

一. heapdump/v8-profiler + chrome dev tools

目前node上面用於排查內存泄漏的輔助工具也有一些,主要是:瀏覽器

  • heapdump
  • v8-profiler

這兩個工具的原理都是一致的:調用v8引擎暴露的接口: v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control) 而後將獲取的c++對象數據轉換爲js對象。

這個對象中其實就是一個很大的json,經過chrome提供的dev tools,能夠將這個json解析成可視化的樹或者統計概覽圖,經過屢次打印內存結構,compare出只增不減的對象,來定位到泄漏點。

二. Easy-Monitor工具自動定位疑似泄漏點

我以前項目中遇到疑似的內存泄漏基本都是這樣排查的,可是排查的過程當中也遇到了幾個比較困擾的問題:

  • 只能在線下進行,而線上狀況複雜,有些錯誤線下很難復現
  • 老是須要屢次插工具打印,而後對比,比較麻煩

因此後面花了點時間,詳細解析了下v8引擎輸出的heapsnapshot裏面的json結構,作了一個輕量級的線上內存泄漏排查工具,也是以前的Easy-monitor性能監控工具的一個補完。

對如何測試本身項目線上js代碼性能,以及找出js函數可優化點感興趣的朋友能夠參看這一篇:

本文下一節主要是以第I節中的三種很是典型的內存泄漏情況,來使用新一版的Easy-Monitor進行簡單的定位排查。

III. 使用Easy-Monitor快速定位泄漏點

一. 安裝&嵌入項目

Easy-Monitor的使用很是簡單,安裝啓動總共三步

1.安裝模塊

npm install easy-monitor

2.引入模塊

const easyMonitor = require('easy-monitor'); easyMonitor('你的項目名稱');

3.訪問監控頁面

打開你的瀏覽器,輸入如下地址,便可看到進程相關信息:

http://127.0.0.1:12333

二. 內存泄漏排查使用方式

Easy-Monitor能夠實時展現內存分析信息,因此在線上使用也是沒有問題的,下面就來使用此工具分析第I節中出現的問題。

1.閉包泄漏

在閉包泄漏的代碼中,按照上面的步驟引入easy-monitor,而後不停在瀏覽器中訪問:

http://127.0.0.1:8082/leak

那麼幾回後經過top或者別的自帶內存監控工具能看到內存明顯上升:

closure_mem_stat.jpeg

這裏我本地訪問屢次後,已經飆升到211MB。

此時,咱們能夠在Easy-Monitor的首頁,點擊對應Pid後面的 MEM 連接,便可自動進行當前業務進程的堆內內存快照打印以及泄漏點分析:

index_mem.jpeg

大約等待10s左右,頁面即會呈現出解析的結果。最上面的 Heap Status 一欄呈現的內容是一個對當前堆內內存解析後的概覽,大概看看就好了,比較重要的泄漏點定位在下面的 Memory Leak 一欄。

我對疑似的內存泄漏點推測是從計算獲得的 retainedSize 着手的:泄漏的感知首先是內存無端增長,且只增不減,那麼當前堆內內存結構中從 (GC roots) 節點出發開始,佔據的 retainedSize 最大的就多是疑似泄漏點的起始。

遵循這個規則,Memory Leak 第一個子欄目獲得的是疑似泄漏點的概覽:

closure_mem_point_index.jpeg

這裏按照 retainedSize 大小作了從大到小的排序,能夠看到,這幾個點基本上佔據了90%以上的堆內內存大小。

好了,下面的子欄目則是對這裏面的5個疑似泄漏點構建 引力圖,來找出泄漏鏈條,原理和前面同樣:佔據總堆內內存 retainedSize 最大的對象下面必定也有佔據其 retainedSize 最大的節點:

closure_mem_force.jpeg

根據引力圖能夠很清晰看到 retainedSize 最大的疑似泄漏鏈條,顏色和大小的一部分含義:

  • 藍色表示疑似的泄漏節點
  • 紫色表示普通節點
  • 最大的節點表示的是當前疑似泄漏鏈條的根節點

這裏的展現用了Echarts2,全部的節點均可以點擊展開/摺疊。當咱們把鼠標移動到疑似泄漏鏈條的最後一個子節點時,引力圖下面會用文字顯示出當前的泄漏鏈條的詳細指向信息 Reference List ,這裏簡單的解析下其內容:

[object] (Route::@122187) ' stack ---> [object] (Array::@124261) ' [0] ---> [object] (Layer::@124265) ' handle ---> [closure] (closureLeak::@124169) ' context ---> [object] (system / Context::@84427) ' theThing ---> [object] (Object::@122271) ' someMethod ---> [closure] (someMethod::@122275) ' context ---> [object] (system / Context::@122269) ' leak ---> [object] (Object::@122113) ' someMethod ---> [closure] (someMethod::@122117) ' context ---> [object] (system / Context::@122111)

每一行表示一個節點:[類型] (名稱::節點惟一id) ’ 屬性名稱或者index。 由於測試代碼用了Express框架,熟悉Express框架源碼的小夥伴都能看出來了:

  • 根節點是初始化express時構造的 Route 的實例。
  • 該 Route 實例的 stack 屬性對應的數組的第一個元素,即這裏的 [0] 對應的元素,其實也就是一箇中間件,因此是 Layer 的一個實例。
  • 該中間件的 handle 屬性指向 closureLeak 函數,這裏開始出現咱們本身編寫的Express框架外的代碼了,簡單分析下也很容易明白這個中間件其實就是咱們編寫的app.get 部分。
  • closureLeak 函數持有了上級做用域產生的閉包對象,這個閉包對象中 retainedSize 最大的變量爲 theThing
  • theThing 持有了 someMethod 的引用,someMethod 又經過上級做用域的閉包對象持有了 leak 變量,leak 變量又指向 theThing 變量指向的上一次的老對象,這個老對象中依舊包含了 someMethod …

經過這個引力圖和下面提供的 Reference List 分析,其實很容易發現泄漏點和泄漏緣由:正是由於第I節中提到的v8引擎做用域生成和持有閉包引用的規則,那麼 unused 函數的存在,致使了 leak 變量被 replaceThing 函數做用域生成的閉包對象存儲了,那麼 theThing 每一次指向的新對象裏面的 someMethod 函數持有了這個閉包對象,所以間接持有了上一次訪問 theThing 指向的老對象。因此每一次訪問後,老對象永遠由於被持有永遠沒法獲得釋放,從而引發了泄漏。

這裏也把關鍵詞整理出來,方便你們項目全局搜索排查:Leak Key

2.Socket重連泄漏

一樣的方式,第I節中的代碼保存後執行,注意 connect 操做的端口填寫一個本地不存在的端口,來模擬觸發客戶端的斷線重連。

那麼這段代碼跑大概一分鐘左右,即開始產生比較明顯的泄漏現象。一樣打開easy-monitor監控頁面進行堆內存分析,獲得以下結果:

socket_mem_index.jpeg

這個圖很容易看出來,佔據 retainedSize 最大的對象正是 socket 對象,幾乎佔到了堆內總內存的 50% 以上。

接着往下看引力圖,以下所示:

socket_mem_force.jpeg

其中的 Reference List 以下:

[object] (Socket::@97097) ' _events ---> [object] (EventHandlers::@97101) ' connect ---> [object] (Array::@102511)

這裏熟悉Node核心模塊 events 的小夥伴就能感到熟悉,_events 正是存儲訂閱事件/事件回調函數的屬性,那麼這邊很顯然是原生的socket觸發斷線重連時,會不停增長 connect 事件的處理,若是服務器一直掛掉,即客戶端沒法斷線重連成功,那麼內存就會不斷增長致使泄漏。

題外插一句,我翻了下net.js的代碼,這裏的 connect 事件是以 once 的方式添加的,因此只要重連過程當中可以連上一次,這部分偵聽器增長的內存就可以被回收掉。

3.全局緩存泄漏

這個是最簡單的緣由了,你們可使用Easy-Monitor自行嘗試一番~

IV. 如何修改避免泄漏

一. 斷掉閉包中的泄漏變量引用鏈條

根據第III節中的解析,明白了這種泄漏的原理,就比較容易對代碼進行修改了,斷掉 unused 函數對 leak 變量的引用,那麼 replaceThing 函數做用域的閉包對象中就不會有 leak 變量了,這樣 someMethod 即不會再對老對象間接產生引用致使泄漏,修改後代碼以下:

'use strict'; const express = require('express'); const app = express(); const easyMonitor = require('easy-monitor'); easyMonitor('Closure Leak'); let theThing = null; let replaceThing = function () { let leak = theThing; //斷掉leak的閉包引用便可解決這種泄漏 let unused = function (leak) { if (leak) console.log("hi") }; theThing = { longStr: new Array(1000000), someMethod: function () { console.log('a'); } }; }; app.get('/leak', function closureLeak(req, res, next) { replaceThing(); res.send('Hello Node'); }); app.listen(8082);

二. 斷線重連時去掉老偵聽器

修改主要目的是在重連時去掉鏈接失敗時添加的 connect 事件,修改後代碼以下:

const net = require('net'); const easyMonitor = require('easy-monitor'); easyMonitor('Socket Leak'); let client = new net.Socket(); function callbackListener() { console.log('connected!'); }); function connect() { client.connect(26665, '127.0.0.1', callbackListener} connect(); client.on('error', function (error) { // console.error(error.message); }); client.on('close', function () { //console.error('closed!'); //斷線時去掉本次偵聽的connect事件的偵聽器 client.removeListener('connect', callbackListener); client.destroy(); setTimeout(connect, 1); });

三.

修改和測試你們能夠自行嘗試一番。

V. 結語

作這個工具也讓本身對於v8的內存管理有了更深刻的認識,收穫挺大的,下一步的計劃是優化代碼邏輯和前臺呈現界面,提升易用性和開發者的體驗。

Easy-Monitor新版本下依舊支持線上部署和多項目cluster部署,最後項目的git地址在:

Easy-Monitor

若是你們以爲有幫助或者不錯,歡迎給個star 💕~

相關文章
相關標籤/搜索