前陣子,公司已經上線的產品持續出現效能問題,服務運作一段時間後會慢得讓人難以忍受,甚至超過 node 記憶體上限而直接中止,當時專案還沒實裝監測系統,只能邊調整邊測試,搞得團隊焦頭爛額。這時同事講出明燈 般的一句話:「該不會是傳說中的 Memory leak 吧?」
經過工程師們同心協力,總算是解決了這個問題,看到記憶體使用圖不再步步高升的那一刻,真的很感動 ⋯⋯ 這篇文章希望能留下記錄,並說明:
memory leak 的起因 如何找出造成 memory leak 的程式碼 我們遇到的實例,與解決方式
小心!Memory leak 離我們並不遙遠!
Memory leak(記憶體流失) ,是指程式運行的過程中,不再使用的記憶體空間沒有正常被釋放,持續佔用空間而造成的記憶體浪費。如果這種狀況不斷發生,就會使可用的記憶體越來越少,而降低電腦的效能,最後可能導致程式崩潰 。
那很好啊!代表我不需要擔心記憶體流失的問題了,對吧?
並不是這樣的。Garbage collection 的簡單流程是:定期從根物件 (root,在瀏覽器中是 window,node 則是 global) 開始往下探詢每一個子節點,並清除沒有被探詢到、或是沒有被探詢物件參考的物件 ,也就是所謂「無法到達的物件 (unreachable objects)」 ,而在程式中,要製造出一個「可被探詢到的垃圾資料」簡直輕而易舉。
如果 root - F 的參考消失,導致 F 變成「無法到達的物件」
那麼 F 與其子節點們就會被自動回收。
舉例來說,我們寫一個簡單的 Server:
Code 每次收到請求,都會
使 requests 的資料量成長 ,而 request 又存在於 global scope,屬於可從根探詢到的程式碼,因此它
不會被清除 ,如果 requests 是沒有用的資料,就會造成記憶體空間的浪費,甚至隨著請求數增多而用盡記憶體空間。
其它像是 timer 、closure 的誤用也會造成 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
等它跑完。
回來觀察 DevTool 的變化
每次跑完壓力測試,就回到 DevTool 快照一下,如果你發現 heap 不斷增長且沒有回到正常數字的話,那八成是抓到兇手了 !
觀察兩個 snapshot 之間的變化,在這個例子中不難發現是 global scope 底下的資料不斷成長。實際狀況下,leak 的記憶體量可能很少,你得發送更多請求數才能看出端倪 。點開之後發現 DevTool 連變數名稱都告訴你了,接下來,只需要把問題修正,就能順利解決 memory leak 問題了。
點開 global 物件,可以看到 requests 這個 Array 正式佔用記憶體的元兇
我們實際遇到的狀況
看到這裡,你可能心想:
「我是一個有紀律的程式設計師,才不會隨便污染 global scope!」
或
「我用的套件都是精挑細選,至少都 10,000 stars 欸,這些套件應該不會隨便就出現 memory leak 問題吧?」
沒想到呀沒想到,就真的被我們遇見了來自套件 的 memory leak。下圖是經過 V6 壓測兩分鐘前後的 snapshot,可以看到 instance 這項資料成長了近一倍,且隨著請求數而增長,不會自動被回收。
第二次壓測,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 變得更低了 🤩🤩!
延伸閱讀