Node除錯指南-記憶體篇

小平果118發表於2019-02-16

主要摘自: - Node.js 除錯指南 Node 案發現場揭祕

Node.js 發展到今天,已經被越來越廣泛地應用到 BFF 前後端分離全棧開發客戶端工具 等領域。然而,相對於應用層的蓬勃發展,其 Runtime 對於絕大部分前端出身的開發者來說,處於黑盒的狀態,這一點並沒有得到很好的改善,從而也阻礙了 Node.js 在業務中的應用和推廣。

記憶體洩漏問題

  • 對於緩慢上漲最終 OOM 這種型別的記憶體洩漏,我們有充足的時間去抓 Heapsnapshot,進而分析堆快照來定位洩漏點。(可參見之前的文章 『Node 案發現場揭祕 —— 快速定位線上記憶體洩漏』 )

  • 對於諸如 while 迴圈跳出條件失敗 、 長正則執行導致程式假死 、以及 由於異常請求導致應用短時間內 OOM 的情況,往往來不及抓取 Heapsnapshot,一直沒有特別好的辦法進行處理。

生成 Coredump 檔案有兩種方式

  • 當我們的應用意外崩潰終止時,作業系統將自動記錄。 這種方式一般用於 「死後驗屍」,用於分析由雪崩觸發 OOM,來對出現未捕獲的異常時也進行自動 Core dump。

這裡需要注意的是,這是一個並沒有那麼安全的操作:線上一般會 pm2 等具備自動重啟功能的守護工具進行程式守護,這意味著如果我們的程式在某些情況下頻繁 crash 和重啟,那麼會生成大量的 Coredump 檔案,甚至可能會將伺服器磁碟寫滿。所以開啟這個選項後,請務必記得對伺服器磁碟進行監控和告警。

  • 手動呼叫 gcore <pid> 的方式來手動生成。 這種方式一般用於 「活體檢驗」,用於 Node.js 程式假死狀態 下的問題定位。

本文將介紹幾種Node除錯記憶體指南

1 gcore + llnode

1.1 Core & Core Dump

在開始之前,我們先了解下什麼是 Core 和 Core Dump。

什麼是 Core?

在使用半導體作為記憶體材料前,人類是利用線圈當作記憶體的材料,線圈就叫作 core ,用線圈做的記憶體就叫作 core memory。如今 ,半導體工業澎勃發展,已經沒有人用 core memory 了,不過在許多情況下, 人們還是把記憶體叫作 core 。

什麼是 Core Dump?

當程式執行的過程中異常終止或崩潰,作業系統會將程式當時的記憶體狀態記錄下來,儲存在一個檔案中,這種行為就叫做 Core Dump(中文有的翻譯成 “核心轉儲”)。我們可以認為 Core Dump 是 “記憶體快照”,但實際上,除了記憶體資訊之外,還有些關鍵的程式執行狀態也會同時 dump 下來,例如暫存器資訊(包括程式指標、棧指標等)、記憶體管理資訊、其他處理器和作業系統狀態和資訊。Core Dump 對於程式設計人員診斷和除錯程式是非常有幫助的,因為對於有些程式錯誤是很難重現的,例如指標異常,而 Core Dump 檔案可以再現程式出錯時的情景。

1.2 測試環境

$ uname -a
Darwin xiaopinguodeMBP 16.7.0 Darwin Kernel Version 16.7.0: Wed Oct 10 20:06:00 PDT 2018; root:xnu-3789.73.24~1/RELEASE_X86_64 x86_64
複製程式碼

1.3 開啟 Core Dump

在終端中輸入:

$ ulimit -c
複製程式碼

檢視允許 Core Dump 生成的檔案的大小,如果是 0 則表示關閉了 Core Dump。使用以下命令開啟 Core Dump 功能,並且不限制 Core Dump 生成的檔案大小:

$ ulimit -c unlimited
複製程式碼

以上命令只針對當前終端環境有效,如果想永久生效,需要修改 /etc/security/limits.conf 檔案,如下:

Node除錯指南-記憶體篇

1.4 gcore

使用 gcore 可以不重啟程式而 dump 出特定程式的 core 檔案。gcore 使用方法如下:

$ gcore [-o filename] pid
# 用法如下
$gcore
gcore: no pid specified
usage:
        gcore [-s] [-v] [[-o file] | [-c pathfmt ]] [-b size] pid
複製程式碼

Core Dump 時,預設會在執行 gcore 命令的目錄生成 core.pid 的檔案。

1.5 llnode

什麼是 llnode?

Node.js v4.x+ C++ plugin for LLDB - a next generation, high-performance debugger.

什麼是 LLDB?

LLDB is a next generation, high-performance debugger. It is built as a set of reusable components which highly leverage existing libraries in the larger LLVM Project, such as the Clang expression parser and LLVM disassembler.

安裝 llnode + lldb:

github.com/nodejs/llno…

# Prerequisites: Install LLDB and its Library
brew update && brew install --with-lldb --with-toolchain llvm
# instal
npm install -g llnode
複製程式碼

1.6 測試記憶體例項

下面用一個典型的全域性變數快取導致的記憶體洩漏的例子來測試 llnode 的用法。程式碼如下:

const leaks = []
function LeakingClass() {
  this.name = Math.random().toString(36)
  this.age = Math.floor(Math.random() * 100)
}
setInterval(() => {
  for (let i = 0; i < 100; i++) {
    leaks.push(new LeakingClass)
  }
  console.warn('Leaks: %d', leaks.length)
}, 1000)
複製程式碼

執行該程式:

$ node app.js
複製程式碼

等待幾秒,開啟另一個終端執行 gcore:

$ ulimit -c unlimited
$ pgrep -n node
$ 33833
$ sudo gcore -c core.33833  33833
複製程式碼

生成 core.33833 檔案。

1.7 分析 Core 檔案

使用 lldb 載入剛才生成的 Core 檔案:

llnode -c ./core.33833 
(lldb) target create --core "./core.33833"
Core file '/Users/xiaopingguo/repos/my_repos/node_repos/node-in-debugging/./core.33833' (x86_64) was loaded.
(lldb) plugin load '/usr/local/lib/node_modules/llnode/llnode.dylib'
複製程式碼

輸入 v8 檢視使用文件,有以下幾條命令:

v8
The following subcommands are supported:
      bt                -- Show a backtrace with node.js JavaScript functions and their args. An optional argument is accepted; if that argument is a number, it
                           specifies the number of frames to display. Otherwise all frames will be dumped.
                           Syntax: v8 bt [number]
      findjsinstances   -- List every object with the specified type name.
                           Flags:
                           * -v, --verbose                  - display detailed `v8 inspect` output for each object.
                           * -n <num>  --output-limit <num> - limit the number of entries displayed to `num` (use 0 to show all). To get next page repeat
                           command or press [ENTER].
                           Accepts the same options as `v8 inspect`
      findjsobjects     -- List all object types and instance counts grouped by type name and sorted by instance count. Use -d or --detailed to get an output
                           grouped by type name, properties, and array length, as well as more information regarding each type.
      findrefs          -- Finds all the object properties which meet the search criteria.
                           The default is to list all the object properties that reference the specified value.
                           Flags:
                           * -v, --value expr     - all properties that refer to the specified JavaScript object (default)
                           * -n, --name  name     - all properties with the specified name
                           * -s, --string string  - all properties that refer to the specified JavaScript string value
      getactivehandles  -- Print all pending handles in the queue. Equivalent to running process._getActiveHandles() on the living process.
      getactiverequests -- Print all pending requests in the queue. Equivalent to running process._getActiveRequests() on the living process.
      inspect           -- Print detailed description and contents of the JavaScript value.
                           Possible flags (all optional):
                           * -F, --full-string    - print whole string without adding ellipsis
                           * -m, --print-map      - print object's map address
                           * -s, --print-source   - print source code for function objects
                           * -l num, --length num - print maximum of `num` elements from string/array
                           Syntax: v8 inspect [flags] expr
      nodeinfo          -- Print information about Node.js
      print             -- Print short description of the JavaScript value.
                           Syntax: v8 print expr
      settings          -- Interpreter settings
      source            -- Source code information
For more help on any particular subcommand, type 'help <command> <subcommand>'.
複製程式碼
  • bt
  • findjsinstances
  • findjsobjects
  • findrefs
  • inspect
  • nodeinfo
  • print
  • source

執行 v8 findjsobjects 檢視所有物件例項及總共佔記憶體大小

(llnode) v8 findjsobjects
 Instances  Total Size Name
 ---------- ---------- ----
        ...
        356      11392 (Array)
        632      35776 Object
       8300     332000 LeakingClass
      14953      53360 (String)
 ---------- ---------- 
      24399     442680
      
複製程式碼

可以看出:LeakingClass 有8300 個例項,佔記憶體332000 byte。使用v8 findjsinstances 檢視所有 LeakingClass 例項:

(lldb) v8 findjsinstances LeakingClass
...
0x221fb297fbb9:<Object: LeakingClass>
0x221fb297fc29:<Object: LeakingClass>
0x221fb297fc99:<Object: LeakingClass>
0x221fb297fd09:<Object: LeakingClass>
0x221fb297fd79:<Object: LeakingClass>
0x221fb297fde9:<Object: LeakingClass>
0x221fb297fe59:<Object: LeakingClass>
0x221fb297fec9:<Object: LeakingClass>
0x221fb297ff39:<Object: LeakingClass>
0x221fb297ffa9:<Object: LeakingClass>
(Showing 1 to 8300 of 8300 instances)
複製程式碼

使用 v8 i檢索例項的具體內容

(llnode) v8 i 0x221fb297ffa9
0x221fb297ffa9:<Object: LeakingClass properties {
    .name=0x221f9bc82201:<String: "0.s3psjp4ctzj">,
    .age=<Smi: 95>}>
(llnode) v8 i 0x221fb297ff39
0x221fb297ff39:<Object: LeakingClass properties {
    .name=0x221fb297ff71:<String: "0.q1t4gikp9a">,
    .age=<Smi: 6>}>
(llnode) v8 i 0x221fb297fec9
0x221fb297fec9:<Object: LeakingClass properties {
    .name=0x221fb297ff01:<String: "0.zzomfpcmgn">,
    .age=<Smi: 52>}>
複製程式碼

可以看到每個 LeakingClass 例項的 name 和 age 欄位的值。

使用 v8 findrefs 檢視引用

(llnode) v8 findrefs 0x221fb297ffa9
0x221fd136cb51: (Array)[7041]=0x221fb297ffa9
(llnode) v8 i 0x221fd136cb51
0x221fd136cb51:<Array: length=10018 {
    [0]=0x221f9b627171:<Object: LeakingClass>,
    [1]=0x221f9b627199:<Object: LeakingClass>,
    [2]=0x221f9b6271c1:<Object: LeakingClass>,
    [3]=0x221f9b6271e9:<Object: LeakingClass>,
    [4]=0x221f9b627211:<Object: LeakingClass>,
    [5]=0x221f9b627239:<Object: LeakingClass>,
    [6]=0x221f9b627261:<Object: LeakingClass>,
    [7]=0x221f9b627289:<Object: LeakingClass>,
    [8]=0x221f9b6272b1:<Object: LeakingClass>,
    [9]=0x221f9b6272d9:<Object: LeakingClass>,
    [10]=0x221f9b627301:<Object: LeakingClass>,
    [11]=0x221f9b627329:<Object: LeakingClass>,
    [12]=0x221f9b627351:<Object: LeakingClass>,
    [13]=0x221f9b627379:<Object: LeakingClass>,
    [14]=0x221f9b6273a1:<Object: LeakingClass>,
    [15]=0x221f9b6273c9:<Object: LeakingClass>}>
複製程式碼

可以看出:通過一個 LeakingClass 例項的記憶體地址,我們使用 v8 findrefs找到了引用它的陣列的記憶體地址,然後通過這個地址去檢索陣列,得到這個陣列長度為10018,每一項都是一個 LeakingClass 例項,這不就是我們程式碼中的 leaks 陣列嗎?

小提示: v8 i 是 v8 inspect的縮寫,v8 p是 v8 print的縮寫。

1.8 --abort-on-uncaught-exception

在 Node.js 程式啟動時新增 —-abort-on-uncaught-exception 引數,當程式 crash 的時候,會自動 Core Dump,方便 “死後驗屍”。

新增 --abort-on-uncaught-exception 引數,啟動測試程式:

$ ulimit -c unlimited
$ node --abort-on-uncaught-exception app.js
複製程式碼

啟動另外一個終端執行:

$ kill -BUS `pgrep -n node`
複製程式碼

第 1 個終端會顯示:

Leaks: 100
Leaks: 200
Leaks: 300
Leaks: 400
Leaks: 500
Leaks: 600
Leaks: 700
Leaks: 800
Bus error (core dumped)
複製程式碼

除錯步驟與上面一致:

(llnode) v8 findjsobjects
 Instances  Total Size Name
 ---------- ---------- ----
        ...
        356      11392 (Array)
        632      35776 Object
       8300     332000 LeakingClass
      14953      53360 (String)
 ---------- ---------- 
      24399     442680
      
複製程式碼

1.9 總結

我們的測試程式碼很簡單,沒有引用任何第三方模組,如果專案較大且引用的模組較多,則 v8 findjsobjects 的結果將難以甄別,這個時候可以多次使用 gcore 進行 Core Dump,對比發現增長的物件,再進行診斷。

2 使用 heapdump

heapdump 是一個 dump V8 堆資訊的工具。v8-profiler 也包含了這個功能,這兩個工具的原理都是一致的,都是 v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control),但是 heapdump 的使用簡單些。下面我們以 heapdump 為例講解如何分析 Node.js 的記憶體洩漏。

這裡以一段經典的記憶體洩漏程式碼作為測試程式碼:

const heapdump = require('heapdump')
let leakObject = null
let count = 0
setInterval(function testMemoryLeak() {
  const originLeakObject = leakObject
  const unused = function () {
    if (originLeakObject) {
      console.log('originLeakObject')
    }
  }
  leakObject = {
    count: String(count++),
    leakStr: new Array(1e7).join(''),
    leakMethod: function () {
      console.log('leakMessage')
    }
  }
}, 1000)
複製程式碼

為什麼這段程式會發生記憶體洩漏呢?首先我們要明白閉包的原理:同一個函式內部的閉包作用域只有一個,所有閉包共享。在執行函式的時候,如果遇到閉包,則會建立閉包作用域的記憶體空間,將該閉包所用到的區域性變數新增進去,然後再遇到閉包,會在之前建立好的作用域空間新增此閉包會用到而前閉包沒用到的變數。函式結束時,清除沒有被閉包作用域引用的變數。

這段程式碼記憶體洩露原因是:在 testMemoryLeak 函式內有兩個閉包:unused 和 leakMethod。unused 這個閉包引用了父作用域中的 originLeakObject 變數,如果沒有後面的 leakMethod,則會在函式結束後被清除,閉包作用域也跟著被清除了。因為後面的 leakObject 是全域性變數,即 leakMethod 是全域性變數,它引用的閉包作用域(包含了 unused 所引用的 originLeakObject)不會釋放。而隨著 testMemoryLeak 不斷的呼叫,originLeakObject 指向前一次的 leakObject,下次的 leakObject.leakMethod 又會引用之前的 originLeakObject,從而形成一個閉包引用鏈,而 leakStr 是一個大字串,得不到釋放,從而造成了記憶體洩漏。

解決方法:在 testMemoryLeak 函式內部的最後新增originLeakObject = null即可。

執行測試程式碼:

$ node app
複製程式碼

然後先後執行兩次:

$ kill -USR2 `pgrep -n node`
複製程式碼

在當前目錄下生成了兩個 heapsnapshot 檔案:

heapdump-100427359.61348.heapsnapshot
heapdump-100438986.797085.heapsnapshot
複製程式碼

2.1 Chrome DevTools

我們使用 Chrome DevTools 來分析前面生成的 heapsnapshot 檔案。調出 Chrome DevTools -> Memory -> Load,按順序依次載入前面生成的 heapsnapshot 檔案。單擊第 2 個堆快照,在左上角有個下拉選單,有如下 4 個選項:

  • Summary:以建構函式名分類顯示。
  • Comparison:比較多個快照之間的差異。
  • Containment:檢視整個 GC 路徑。
  • Statistics:以餅狀圖顯示記憶體佔用資訊。 通常我們只會用前兩個選項;第 3 個選項一般用不到,因為在展開 Summary 和 Comparison 中的每一項時,都可以看到從 GC roots 到這個物件的路徑;第 4 個選項只能看到記憶體佔用比,如下圖所示:

Node除錯指南-記憶體篇

切換到 Summary 頁,可以看到有如下 5 個屬性:

  • Contructor:建構函式名,例如 Object、Module、Socket,(array)、(string)、(regexp) 等加了括號的分別代表內建的 Array、String 和 Regexp。
  • Distance:到 GC roots (GC 根物件)的距離。GC 根物件在瀏覽器中一般是 window 物件,在 Node.js 中是 global 物件。距離越大,則說明引用越深,有必要重點關注一下,極有可能是記憶體洩漏的物件。
  • Objects Count:物件個數。
  • Shallow Size:物件自身的大小,不包括它引用的物件。
  • Retained Size:物件自身的大小和它引用的物件的大小,即該物件被 GC 之後所能回收的記憶體大小。

小提示:

  • 一個物件的 Retained Size = 該物件的 Shallow Size + 該物件可直接或間接引用到的物件的 Shallow Size 之和。
  • Shallow Size == Retained Size 的有 (boolean)、(number)、(string),它們無法引用其他值,並且始終是葉子節點。

我們單擊 Retained Size 選擇降序展示,可以看到 (closure) 這一項引用的內容達到 99%,繼續展開如下:

Node除錯指南-記憶體篇

可以看出:一個 leakStr 佔了 5% 的記憶體,而 leakMethod 引用了 88% 的記憶體。物件保留樹(Retainers,老版本 Chrome 叫 Object’s retaining tree)展示了物件的 GC path,單擊如上圖中的 leakStr(Distance 是 13),Retainers 會自動展開,Distance 從 13 遞減到 1。

我們繼續展開 leakMethod,如下所示:

Node除錯指南-記憶體篇

可以看出:有一個 count=”18”originLeakObject 的 leakMethod 函式的 context(即上下文) 引用了一個 count=”17”originLeakObject 物件,而這個 originLeakObject 物件的 leakMethod 函式的 context 又引用了 count=”16”originLeakObject 物件,以此類推。而每個 originLeakObject 物件上都有一個大字串 leakStr(佔用 8% 的記憶體),從而造成記憶體洩漏,符合我們之前的推斷。

小提示:如果背景色是黃色的,則表示這個物件在 JavaScript 中還存在引用,所以可能沒有被清除。如果背景色是紅色的,則表示這個物件在 JavaScript 中不存在引用,但是依然存活在記憶體中,一般常見於 DOM 物件,它們存放的位置和 JavaScript 中的物件還是有不同的,在 Node.js 中很少遇見。

2.2 對比快照

切換到 Comparison 檢視下,可以看到一些 #New、#Deleted、#Delta 等屬性,+ 和 - 代表相對於比較的堆快照而言。我們對比第 2 個快照和第 1 個快照,如下所示:

Node除錯指南-記憶體篇

可以看出:(string) 增加了 5 個,每個 string 大小為 10000024 位元組。

3 使用 memwatch-next

memwatch-next(以下簡稱 memwatch)是一個用來監測 Node.js 的記憶體洩漏和堆資訊比較的模組。下面我們以一段事件監聽器導致記憶體洩漏的程式碼為例,講解如何使用 memwatch。

測試程式碼如下:

let count = 1
const memwatch = require('memwatch-next')
memwatch.on('stats', (stats) => { 
  console.log(count++, stats)
})
memwatch.on('leak', (info) => {
  console.log('---')
  console.log(info)
  console.log('---')
})
const http = require('http')
const server = http.createServer((req, res) => {
  for (let i = 0; i < 10000; i++) {
    server.on('request', function leakEventCallback() {})
  }
  res.end('Hello World')
  global.gc()
}).listen(3000)
複製程式碼

在每個請求到來時,給 server 註冊 10000 個 request 事件的監聽函式(大量的事件監聽函式儲存到記憶體中,造成了記憶體洩漏),然後手動觸發一次 GC。

執行該程式:

$ node --expose-gc app.js
複製程式碼

注意:這裡新增 —expose-gc 引數啟動程式,這樣我們才可以在程式中手動觸發 GC。

memwatch 可以監聽兩個事件:

  • stats: GC 事件,每執行一次 GC,都會觸發該函式,列印 heap 相關的資訊。如下:
{
  num_full_gc: 1,// 完整的垃圾回收次數
  num_inc_gc: 1,// 增長的垃圾回收次數
  heap_compactions: 1,// 記憶體壓縮次數
  usage_trend: 0,// 使用趨勢
  estimated_base: 5350136,// 預期基數
  current_base: 5350136,// 當前基數
  min: 0,// 最小值
  max: 0// 最大值
}
複製程式碼
  • leak: 記憶體洩露事件,觸發該事件的條件是:連續 5 次 GC 後記憶體都是增長的。列印如下:
{ 
  growth: 3616040,
  reason: 'heap growth over 5 consecutive GCs (0s) - -2147483648 bytes/hr' 
}
複製程式碼

執行:

$ ab -c 1 -n 5 http://localhost:3000/
複製程式碼

輸出:

(node:35513) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 request listeners added. Use emitter.setMaxListeners() to increase limit
1 { num_full_gc: 1,
  num_inc_gc: 2,
  heap_compactions: 1,
  usage_trend: 0,
  estimated_base: 5674608,
  current_base: 5674608,
  min: 0,
  max: 0 }
2 { num_full_gc: 2,
  num_inc_gc: 4,
  heap_compactions: 2,
  usage_trend: 0,
  estimated_base: 6668760,
  current_base: 6668760,
  min: 0,
  max: 0 }
3 { num_full_gc: 3,
  num_inc_gc: 5,
  heap_compactions: 3,
  usage_trend: 0,
  estimated_base: 7570424,
  current_base: 7570424,
  min: 7570424,
  max: 7570424 }
4 { num_full_gc: 4,
  num_inc_gc: 7,
  heap_compactions: 4,
  usage_trend: 0,
  estimated_base: 8488368,
  current_base: 8488368,
  min: 7570424,
  max: 8488368 }
--------------
{ growth: 3616040,
  reason: 'heap growth over 5 consecutive GCs (0s) - -2147483648 bytes/hr' }
--------------
5 { num_full_gc: 5,
  num_inc_gc: 9,
  heap_compactions: 5,
  usage_trend: 0,
  estimated_base: 9290648,
  current_base: 9290648,
  min: 7570424,
  max: 9290648 }
  
複製程式碼

可以看出:Node.js 已經警告我們事件監聽器超過了 11 個,可能造成記憶體洩露。連續 5 次記憶體增長觸發 leak 事件列印出增長了多少記憶體(bytes)和預估每小時增長多少 bytes。

3.1 Heap Diffing

memwatch 有一個 HeapDiff 函式,用來對比並計算出兩次堆快照的差異。修改測試程式碼如下:

const memwatch = require('memwatch-next')
const http = require('http')
const server = http.createServer((req, res) => {
  for (let i = 0; i < 10000; i++) {
    server.on('request', function leakEventCallback() {})
  }
  res.end('Hello World')
  global.gc()
}).listen(3000)
const hd = new memwatch.HeapDiff()
memwatch.on('leak', (info) => {
  const diff = hd.end()
  console.dir(diff, { depth: 10 })
})
執行這段程式碼並執行同樣的 ab 命令,列印如下:

(node:35690) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 request listeners added. Use emitter.setMaxListeners() to increase limit
{ before: { nodes: 35864, size_bytes: 4737664, size: '4.52 mb' },
  after: { nodes: 87476, size_bytes: 8946784, size: '8.53 mb' },
  change: 
   { size_bytes: 4209120,
     size: '4.01 mb',
     freed_nodes: 894,
     allocated_nodes: 52506,
     details: 
      [ ...
        { what: 'Array',
          size_bytes: 533008,
          size: '520.52 kb',
          '+': 1038,
          '-': 517 },
        { what: 'Closure',
          size_bytes: 3599856,
          size: '3.43 mb',
          '+': 50001,
          '-': 3 }
        ...
      ]
    }
}
複製程式碼

可以看出:記憶體由 4.52mb 漲到了 8.53mb,其中 Closure 和 Array 漲了絕大部分,而我們知道註冊事件監聽函式的本質就是將事件函式(Closure)push 到相應的陣列(Array)裡。

3.2 結合 heapdump

memwatch 在結合 heapdump 使用時才能發揮更好的作用。通常用 memwatch 監測到發生記憶體洩漏,用 heapdump 匯出多份堆快照,然後用 Chrome DevTools 分析和比較,定位記憶體洩漏的元凶。

修改程式碼如下:

const memwatch = require('memwatch-next')
const heapdump = require('heapdump')
const http = require('http')
const server = http.createServer((req, res) => {
  for (let i = 0; i < 10000; i++) {
    server.on('request', function leakEventCallback() {})
  }
  res.end('Hello World')
  global.gc()
}).listen(3000)
dump()
memwatch.on('leak', () => {
  dump()
})
function dump() {
  const filename = `${__dirname}/heapdump-${process.pid}-${Date.now()}.heapsnapshot`
  heapdump.writeSnapshot(filename, () => {
    console.log(`${filename} dump completed.`)
  })
}
複製程式碼

以上程式在啟動後先執行一次 heap dump,當觸發 leak 事件時再執行一次 heap dump。執行這段程式碼並執行同樣的 ab 命令,生成兩個 heapsnapshot 檔案:

heapdump-21126-1519545957879.heapsnapshot
heapdump-21126-1519545975702.heapsnapshot
複製程式碼

用 Chrome DevTools 載入這兩個 heapsnapshot 檔案,選擇 comparison 比較檢視,如下所示:

Node除錯指南-記憶體篇
可以看出:增加了 5 萬個 leakEventCallback 函式,單擊其中任意一個,可以從 Retainers 中看到更詳細的資訊,例如 GC path 和所在的檔案等資訊。

前面介紹了 heapdumpmemwatch-next 的用法,但在實際使用時並不那麼方便,我們總不能一直盯著伺服器的狀況,在發現記憶體持續增長並超過心裡的閾值時,再手動去觸發 Core Dump 吧?在大多數情況下發現問題時,就已經錯過了現場。所以,我們可能需要 cpu-memory-monitor。顧名思義,這個模組可以用來監控 CPU 和 Memory 的使用情況,並可以根據配置策略自動 dump CPU 的使用情況(cpuprofile)和記憶體快照(heapsnapshot)。

4 使用 cpu-memory-monitor

我們先來看看如何使用 cpu-memory-monitor,其實很簡單,只需在程式啟動的入口檔案中引入以下程式碼:

require('cpu-memory-monitor')({
  cpu: {
    interval: 1000,
    duration: 30000,
    threshold: 60,
    profileDir: '/tmp',
    counter: 3,
    limiter: [5, 'hour']
  }
})
複製程式碼

上述程式碼的作用是:每 1000ms(interval)檢查一次 CPU 的使用情況,如果發現連續 3(counter)次 CPU 使用率大於 60%(threshold),則 dump 30000ms(duration) CPU 的使用情況,生成 cpu-${process.pid}-${Date.now()}.cpuprofile 到/tmp(profileDir) 目錄下,1(limiter[1]) 小時最多 dump 5(limiter[0]) 次。

以上是自動 dump CPU 使用情況的策略。dump Memory 使用情況的策略同理:

require('cpu-memory-monitor')({
  memory: {
    interval: 1000,
    threshold: '1.2gb',
    profileDir: '/tmp',
    counter: 3,
    limiter: [3, 'hour']
  }
})
複製程式碼

上述程式碼的作用是:每 1000ms(interval) 檢查一次 Memory 的使用情況,如果發現連續 3(counter) 次 Memory 大於 1.2gb(threshold),則 dump 一次 Memory,生成memory-${process.pid}-${Date.now()}.heapsnapshot 到 /tmp(profileDir) 目錄下,1(limiter[1]) 小時最多 dump 3(limiter[0]) 次。

注意:memory 的配置沒有 duration 引數,因為 Memroy 的 dump 只是某一時刻的,而不是一段時間的。

那聰明的你肯定會問了:能不能將 cpu 和 memory 配置一塊使用?比如:

require('cpu-memory-monitor')({
  cpu: {
    interval: 1000,
    duration: 30000,
    threshold: 60,
    ...
  },
  memory: {
    interval: 10000,
    threshold: '1.2gb',
    ...
  }
})
複製程式碼

答案是:可以,但不要這麼做。因為這樣做可能會出現這種情況:

記憶體高了且達到設定的閾值 -> 觸發 Memory Dump/GC -> 導致 CPU 使用率高且達到設定的閾值 -> 觸發 CPU Dump -> 導致堆積的請求越來越多(比如記憶體中堆積了很多 SQL 查詢)-> 觸發 Memory Dump -> 導致雪崩。

通常情況下,只使用其中一種就可以了。

4.1 原始碼解讀

cpu-memory-monitor 的原始碼不過百餘行,大體邏輯如下:

const processing = {
  cpu: false,
  memory: false
}
const counter = {
  cpu: 0,
  memory: 0
}
function dumpCpu(cpuProfileDir, cpuDuration) { ... }
function dumpMemory(memProfileDir) { ... }
module.exports = function cpuMemoryMonitor(options = {}) {
  ...
  if (options.cpu) {
    const cpuTimer = setInterval(() => {
      if (processing.cpu) {
        return
      }
      pusage.stat(process.pid, (err, stat) => {
        if (err) {
          clearInterval(cpuTimer)
          return
        }
        if (stat.cpu > cpuThreshold) {
          counter.cpu += 1
          if (counter.cpu >= cpuCounter) {
            memLimiter.removeTokens(1, (limiterErr, remaining) => {
              if (limiterErr) {
                return
              }
              if (remaining > -1) {
                dumpCpu(cpuProfileDir, cpuDuration)
                counter.cpu = 0
              }
            })
          } else {
            counter.cpu = 0
          }
        }
      })
    }, cpuInterval)
  }
  if (options.memory) {
    ...
    memwatch.on('leak', () => {
      dumpMemory(...)
    })
  }
}
複製程式碼

可以看出:cpu-memory-monitor 沒有用到什麼新鮮的東西,還是之前講解過的 v8-profilerheapdumpmemwatch-next 的組合使用而已。

有以下幾點需要注意:

只有傳入了 cpu 或者 memory 的配置,才會去監聽相應的 CPU 或者 Memory。 在傳入 memory 配置時,用了 memwatch-next 額外監聽了 leak 事件,也會 dump Memory,格式是 leak-memory-${process.pid}-${Date.now()}.heapsnapshot。 頂部引入了 heapdump,所以即使沒有 memory 配置,也可以通過kill -USR2 <PID>手動觸發 Memory Dump。

參考連結

相關文章