從你的 Node.js 專案裡找出 Memory leak,及早發現、及早治療!

Kuan
Kuan
13
2021-08-14
|閱讀時間 ‧ 約 11 分鐘
前陣子,公司已經上線的產品持續出現效能問題,服務運作一段時間後會慢得讓人難以忍受,甚至超過 node 記憶體上限而直接中止,當時專案還沒實裝監測系統,只能邊調整邊測試,搞得團隊焦頭爛額。這時同事講出明燈般的一句話:「該不會是傳說中的 Memory leak 吧?」
經過工程師們同心協力,總算是解決了這個問題,看到記憶體使用圖不再步步高升的那一刻,真的很感動 ⋯⋯ 這篇文章希望能留下記錄,並說明:
  • memory leak 的起因
  • 如何找出造成 memory leak 的程式碼
  • 我們遇到的實例,與解決方式


小心!Memory leak 離我們並不遙遠!

Memory leak(記憶體流失),是指程式運行的過程中,不再使用的記憶體空間沒有正常被釋放,持續佔用空間而造成的記憶體浪費。如果這種狀況不斷發生,就會使可用的記憶體越來越少,而降低電腦的效能,最後可能導致程式崩潰
在 JavaScript 裡,記憶體回收的工作是交由自動化的 Garbage Collection 來完成,它就像記憶體裡的清道夫,會判斷不再使用的記憶體並將其回收。
那很好啊!代表我不需要擔心記憶體流失的問題了,對吧?
並不是這樣的。Garbage collection 的簡單流程是:定期從根物件 (root,在瀏覽器中是 window,node 則是 global) 開始往下探詢每一個子節點,並清除沒有被探詢到、或是沒有被探詢物件參考的物件,也就是所謂「無法到達的物件 (unreachable objects)」,而在程式中,要製造出一個「可被探詢到的垃圾資料」簡直輕而易舉。
如果 root -> F 的參考消失,導致 F 變成「無法到達的物件」 那麼 F 與其子節點們就會被自動回收。
舉例來說,我們寫一個簡單的 Server:
Code 每次收到請求,都會使 requests 的資料量成長,而 request 又存在於 global scope,屬於可從根探詢到的程式碼,因此它不會被清除,如果 requests 是沒有用的資料,就會造成記憶體空間的浪費,甚至隨著請求數增多而用盡記憶體空間。
其它像是 timerclosure 的誤用也會造成 memory leak,我們這邊先不詳述。畢竟,要直接從整個專案的程式碼中翻找出 memory leak 簡直是大海撈針 ⋯⋯ 我覺得更有效率的方式應該是使用工具來找出不斷增長的資料,縮小範圍後再修正造成問題的程式碼
若你的記憶體使用圖表是這個形狀,你可能就是 memory leak 的受害者!


使用 DevTool 找出病灶!

Chrome 提供的 DevTool 除了能監測運行在瀏覽器中的程式外,也能監測運行在本地端的 Node.js 程式。如果你使用 Node.js 運行 API Server,或是你的前端使用像 Next.js 的 SSR 框架(它會常駐一個 Node Server),就能使用 Devtool 監測 Heap 的使用狀況。
我們先以上面的簡單 Server 為例,使用 --inspect flag 來啟動它。
node --inspect app.js
在 console 裡,你會看到 Node.js 已經幫我們打開一個監聽 9229 port 的 debugger。
接著,打開你的 Chrome 並輸入 chrome://inspect ,你應該能夠在 Remote Target 裡找到 app.js,按下 inspect 來打開 DevTool。
切換到 Memory 頁籤,我們會在下方看到目前運行中的 VM instance,按下 Take snapshot,它就會幫我們分析此刻的 heap 使用狀況,並將結果儲存在左側邊欄。

從這張圖中,我們可以看到:

  • Constructor:此物件的建構子 (或 DevTool 幫我們進行的分類)。
  • Distance:從根 (root) 開始探訪的深度。
  • Shallow Size:物件本身佔用的記憶體量(bytes),通常 shallow size 很大的都是 String 或是裝著 Primitive Type Data 的陣列,如果是物件裡存著 reference 則不會被算到 shallow size 裡面。
  • Retained Size:物件本身佔用的記憶體空間,加上依賴此物件的所有資料所佔用的記憶體量(bytes)。文件裡有一句淺顯易懂的解釋:你刪除這個物件後,他總共會釋放的記憶體量,因此在查找 memory leak 問題時,我們會以這個欄位為判斷點。
點開左邊的箭頭,就能繼續往下探詢,相當方便。我們先將物件以 Retained Size 排序,沒意外的話佔用最多的應該是 (compiled code),因為我們引用了一些套件,這些物件也會被存在 Heap 中。
接下來,我們就要手動重現 memory leak


使用 K6 進行壓力測試

如果你的專案是 API 或 SSR Server,就可以用發送大量請求的方式來重現 memory leak,這邊我推薦使用 K6,也可以直接使用你熟悉的壓力測試服務。
我們先寫一個簡單的測試。一般來說,測試單一 Endpoint 可能無法重現問題,你可能需要將 API Server 所有提供的(或是你懷疑的) Endpoints 測試一遍,就能知道是哪一段程式碼造成問題。
接著從終端機啟動壓力測試,使用下面的參數:
  • duration:執行測試的時間
  • vus:同時測試的虛擬使用者數量 (virtual users)
k6 run --duration 2m --vus 100 request.js
K6 壓測執行中的畫面
等它跑完。
等。

回來觀察 DevTool 的變化

每次跑完壓力測試,就回到 DevTool 快照一下,如果你發現 heap 不斷增長且沒有回到正常數字的話,那八成是抓到兇手了
觀察兩個 snapshot 之間的變化,在這個例子中不難發現是 global scope 底下的資料不斷成長。實際狀況下,leak 的記憶體量可能很少,你得發送更多請求數才能看出端倪。點開之後發現 DevTool 連變數名稱都告訴你了,接下來,只需要把問題修正,就能順利解決 memory leak 問題了。
壓測前,compiled code 佔用最多空間
壓測後,global 佔用了大部分的空間
點開 global 物件,可以看到 requests 這個 Array 正式佔用記憶體的元兇


我們實際遇到的狀況

看到這裡,你可能心想:
「我是一個有紀律的程式設計師,才不會隨便污染 global scope!」
「我用的套件都是精挑細選,至少都 10,000 stars 欸,這些套件應該不會隨便就出現 memory leak 問題吧?」
沒想到呀沒想到,就真的被我們遇見了來自套件的 memory leak。下圖是經過 V6 壓測兩分鐘前後的 snapshot,可以看到 instance 這項資料成長了近一倍,且隨著請求數而增長,不會自動被回收。
第一次壓測,instance 佔用 16%
第二次壓測,instance 成長到 23%,佔用 19 MB 的 heap
從圖中我們看見資料型態 Map,以及子元素的資料型態 HashArrayMapNode,這些都來自 immutable.js 套件。而在我們專案中,相依 immutable 的套件只有 Facebook 開源的文本編輯器 draft.js
經過搜尋,發現我們不是第一個遇到此問題的人,早在 2020 就有人回報這個issue,trace code 就會發現 draft.js 有兩項 global scope 資料是沒有主動回收的:

DraftEntity.js

在這個檔案中,我們首先看到一個宣告在 global scope 的資料 instance
接著,從這個模組的 __add 方法,我們看見 instance 資料量會不斷增長。
但它並沒有提供一個清除 instance 的方法,還好有一個 setter 可以讓我們利用,只要將它初始化就能順利觸發 JavaScript 的回收機制,把所有子節點佔用的記憶體都釋放掉。
Entity.__loadWithEntities(Map());

CharacterMetadata.js

這裡 我們一樣看到不斷增長的 global scope 物件 pool
比較麻煩的是,這項資料連個 setter 都沒有,我們只能手動增加清除方法將其初始化:
老實說,如果 draft.js 是單純在瀏覽器上執行,應該是不會有問題的,因為使用上並不會一直開新的文本編輯器 instance。只是我們的 Next.js Server 在收到請求並編譯時,就意外地將初始化的資料留一份在 Server 端了(而且也完全用不到這項資料)。隨著使用者在網站上打開文本編輯器,記憶體空間也逐漸變少,最後導致程式崩潰。
修正完程式碼後,終於讓記憶體使用量回歸正常,看看下面這張圖,真的是讓人法喜充滿啊!如果你也因 memory leak 困擾的話,就用 DevTool 來找出病灶吧,希望這篇記錄有幫助到大家,謝謝一起苦惱的隊友們 XD。
終於 ⋯⋯ 看見平原了啊! 稍晚後端又加上了機器分流,讓 loading 變得更低了 🤩🤩!


延伸閱讀


分享至
成為作者繼續創作的動力吧!
Kuan
現職程式設計,也是「桌遊拌飯 Podcast」的主持人之一,熱愛桌上遊戲、搖滾樂。


13收藏
分享