調試 Node.js 應用程序中的內存洩漏

已發表: 2022-03-11

我曾經開過一輛裝有 V8 雙渦輪增壓發動機的奧迪,它的性能令人難以置信。 凌晨 3 點,我在芝加哥附近的 IL-80 高速公路上以 140 英里/小時的速度行駛,當時路上沒有人。 從那時起,“V8”這個詞對我來說就與高性能聯繫在一起了。

Node.js 是一個基於 Chrome 的 V8 JavaScript 引擎構建的平台,用於輕鬆構建快速且可擴展的網絡應用程序。

儘管奧迪的 V8 非常強大,但您的油箱容量仍然有限。 Google 的 V8 也是如此——Node.js 背後的 JavaScript 引擎。 它的性能令人難以置信,Node.js 在許多用例中運行良好的原因有很多,但你總是受到堆大小的限制。 當您需要在 Node.js 應用程序中處理更多請求時,您有兩種選擇:垂直擴展或水平擴展。 水平擴展意味著您必須運行更多的並發應用程序實例。 如果做得好,您最終將能夠處理更多請求。 垂直擴展意味著您必須提高應用程序的內存使用率和性能,或者增加應用程序實例的可用資源。

調試 Node.js 應用程序中的內存洩漏

調試 Node.js 應用程序中的內存洩漏
鳴叫

最近,我被要求為我的一個 Toptal 客戶開發 Node.js 應用程序,以解決內存洩漏問題。 該應用程序是一個 API 服務器,旨在每分鐘能夠處理數十萬個請求。 原始應用程序佔用了將近 600MB 的 RAM,因此我們決定採用熱 API 端點並重新實現它們。 當您需要服務許多請求時,開銷變得非常昂貴。

對於新的 API,我們選擇了帶有原生 MongoDB 驅動程序的 restify 和用於後台作業的 Kue。 聽起來像一個非常輕量級的堆棧,對吧? 不完全的。 在峰值負載期間,一個新的應用程序實例可能會消耗多達 270MB 的 RAM。 因此,我對每 1X Heroku Dyno 擁有兩個應用程序實例的夢想破滅了。

Node.js 內存洩漏調試庫

內存表

如果您搜索“如何在節點中查找洩漏”,您可能會找到的第一個工具是memwatch 。 原來的包很久以前就被廢棄了,不再維護。 但是,您可以在 GitHub 的存儲庫 fork 列表中輕鬆找到它的更新版本。 這個模塊很有用,因為如果它看到堆增長超過 5 個連續的垃圾回收,它就會發出洩漏事件。

堆轉儲

很棒的工具,它允許 Node.js 開發人員拍攝堆快照並在以後使用 Chrome 開發人員工具檢查它們。

節點檢查器

甚至是 heapdump 更有用的替代方案,因為它允許您連接到正在運行的應用程序、進行堆轉儲,甚至可以即時調試和重新編譯它。

使用“節點檢查器”進行旋轉

不幸的是,您將無法連接到在 Heroku 上運行的生產應用程序,因為它不允許將信號發送到正在運行的進程。 然而,Heroku 並不是唯一的託管平台。

為了體驗 node-inspector 的實際應用,我們將使用 restify 編寫一個簡單的 Node.js 應用程序,並在其中放置一點內存洩漏源。 這裡所有的實驗都是用 Node.js v0.12.7 進行的,它是針對 V8 v3.28.71.19 編譯的。

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

這裡的應用程序非常簡單,並且有非常明顯的洩漏。 陣列任務將在應用程序生命週期內增長,導致它變慢並最終崩潰。 問題是我們不僅洩露了閉包,還洩露了整個請求對象。

V8 中的 GC 採用 stop-the-world 策略,因此這意味著您在內存中擁有的對象越多,收集垃圾所需的時間就越長。 在下面的日誌中,您可以清楚地看到,在應用程序生命的開始,收集垃圾平均需要 20 毫秒,但後來幾十萬個請求大約需要 230 毫秒。 由於 GC,嘗試訪問我們應用程序的人現在必須再等待230 毫秒。 您還可以看到每隔幾秒調用一次 GC,這意味著每隔幾秒用戶訪問我們的應用程序時就會遇到問題。 並且延遲會增加,直到應用程序崩潰。

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

當使用–trace_gc標誌啟動 Node.js 應用程序時,會打印這些日誌行:

 node --trace_gc app.js

讓我們假設我們已經使用這個標誌啟動了我們的 Node.js 應用程序。 在使用 node-inspector 連接應用程序之前,我們需要將 SIGUSR1 信號發送給正在運行的進程。 如果您在集群中運行 Node.js,請確保連接到從屬進程之一。

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

通過這樣做,我們使 Node.js 應用程序(準確地說是 V8)進入調試模式。 在此模式下,應用程序會使用 V8 調試協議自動打開端口 5858。

我們的下一步是運行 node-inspector,它將連接到正在運行的應用程序的調試界面,並在端口 8080 上打開另一個 Web 界面。

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

如果應用程序在生產環境中運行並且您有防火牆,我們可以將遠程端口 8080 隧道傳輸到 localhost:

 ssh -L 8080:localhost:8080 [email protected]

現在,您可以打開 Chrome 網絡瀏覽器並獲得對附加到遠程生產應用程序的 Chrome 開發工具的完全訪問權限。 不幸的是,Chrome 開發者工具無法在其他瀏覽器中運行。

讓我們找出漏洞!

V8 中的內存洩漏並不是我們從 C/C++ 應用程序中知道的真正的內存洩漏。 在 JavaScript 中,變量不會消失在虛無中,它們只是被“遺忘”。 我們的目標是找到這些被遺忘的變量,並提醒他們 Dobby 是免費的。

在 Chrome 開發者工具中,我們可以訪問多個分析器。 我們對隨時間運行並獲取多個堆快照的記錄堆分配特別感興趣。 這讓我們可以清楚地看到哪些對象正在洩漏。

開始記錄堆分配,讓我們在主頁上使用 Apache Benchmark 模擬 50 個並髮用戶。

截屏

 ab -c 50 -n 1000000 -k http://example.com/

在拍攝新快照之前,V8 會執行標記掃描垃圾回收,因此我們肯定知道快照中沒有舊垃圾。

即時修復洩漏

3 分鐘內收集堆分配快照後,我們最終得到如下內容:

截屏

我們可以清楚地看到堆中有一些巨大的數組,大量的 IncomingMessage、ReadableState、ServerResponse 和 Domain 對象。 讓我們嘗試分析洩漏的來源。

在從 20s 到 40s 選擇圖表上的 heap diff 後,我們只會看到從您啟動分析器開始 20s 之後添加的對象。 這樣您就可以排除所有正常數據。

記下系統中有多少每種類型的對象,我們將過濾器從 20 秒擴展到 1 分鐘。 我們可以看到,已經相當龐大的數組還在繼續增長。 在“(數組)”下,我們可以看到有很多距離相等的對象“(對象屬性)”。 這些對像是我們內存洩漏的根源。

我們還可以看到“(閉包)”對像也迅速增長。

查看字符串可能也很方便。 在字符串列表下有很多“Hi Leaky Master”短語。 這些也可能給我們一些線索。

在我們的例子中,我們知道字符串“Hi Leaky Master”只能在“GET /”路徑下組裝。

如果你打開保持器路徑,你會看到這個字符串以某種方式通過req引用,然後創建了上下文,所有這些都添加到了一些巨大的閉包數組中。

截屏

所以在這一點上,我們知道我們有一些巨大的閉包數組。 讓我們在源選項卡下實時為所有閉包命名。

截屏

完成代碼編輯後,我們可以按 CTRL+S 即時保存和重新編譯代碼!

現在讓我們記錄另一個Heap Allocations Snapshot ,看看哪些閉包佔用了內存。

很明顯SomeKindOfClojure()是我們的反派。 現在我們可以看到SomeKindOfClojure()閉包被添加到全局空間中一些名為任務的數組中。

很容易看出這個數組只是沒用。 我們可以註釋掉。 但是我們如何釋放已經佔用的內存呢? 非常簡單,我們只需為任務分配一個空數組,下一次請求將覆蓋它,並且在下一次 GC 事件後釋放內存。

截屏

多比是免費的!

V8 中的垃圾壽命

好吧,V8 JS 沒有內存洩漏,只有被遺忘的變量。

好吧,V8 JS 沒有內存洩漏,只有被遺忘的變量。
鳴叫

V8 堆分為幾個不同的空間:

  • 新空間:這個空間比較小,大小在 1MB 到 8MB 之間。 大多數對像都分配在這裡。
  • 舊指針空間:具有可能具有指向其他對象的指針的對象。 如果對像在新空間中存活足夠長的時間,它就會被提升到舊指針空間。
  • 舊數據空間:僅包含原始數據,如字符串、裝箱數字和未裝箱雙精度數組。 在新空間中經過 GC 足夠長的時間的對像也會被移到這裡。
  • 大型對象空間:在此空間中創建太大而無法放入其他空間的對象。 每個對像在內存中都有自己的mmap區域
  • 代碼空間:包含由 JIT 編譯器生成的彙編代碼。
  • 單元格空間、屬性單元格空間、地圖空間:該空間包含CellPropertyCellMap 。 這用於簡化垃圾收集。

每個空間由頁面組成。 頁面是操作系統使用 mmap 分配的內存區域。 除了大對象空間中的頁面外,每個頁面的大小始終為 1MB。

V8 有兩種內置的垃圾回收機制:Scavenge、Mark-Sweep 和 Mark-Compact。

Scavenge 是一種非常快速的垃圾收集技術,可以對New Space中的對象進行操作。 Scavenge 是切尼算法的實現。 思路很簡單, New Space被分成兩個相等的半空間:To-Space 和 From-Space。 當 To-Space 已滿時,會發生 Scavenge GC。 它只是交換 To 和 From 空間並將所有活動對象複製到 To-Space 或將它們提升到舊空間之一,如果它們在兩次清除中倖存下來,然後從空間中完全刪除。 清除速度非常快,但是它們具有保持雙倍大小的堆和不斷在內存中復制對象的開銷。 使用 scavenges 的原因是因為大多數對像都是在年輕時死去的。

Mark-Sweep & Mark-Compact 是 V8 中使用的另一種垃圾收集器。 另一個名稱是完整的垃圾收集器。 它標記所有活動節點,然後掃描所有死節點並整理內存。

GC 性能和調試技巧

雖然對於 Web 應用程序來說,高性能可能不是一個大問題,但您仍然希望不惜一切代價避免洩漏。 在完全 GC 的標記階段,應用程序實際上會暫停,直到垃圾收集完成。 這意味著堆中的對象越多,執行 GC 所需的時間就越長,用戶等待的時間就越長。

總是給閉包和函數命名

當所有閉包和函數都有名稱時,檢查堆棧跟踪和堆會容易得多。

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

避免熱函數中的大對象

理想情況下,您希望避免在熱函數中使用大對象,以便所有數據都適合New Space 。 所有 CPU 和內存綁定操作都應在後台執行。 還要避免熱函數的反優化觸發器,優化的熱函數比非優化的熱函數使用更少的內存。

熱功能應該優化

運行速度更快但消耗內存更少的熱函數會導致 GC 運行頻率降低。 V8 提供了一些有用的調試工具來發現未優化的函數或未優化的函數。

避免熱函數中 IC 的多態性

內聯緩存 (IC) 用於加速某些代碼塊的執行,通過緩存對象屬性訪問obj.key或一些簡單的函數。

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

x(a,b)第一次運行時,V8 創建了一個單態 IC。 當您第二次調用x時,V8 會擦除舊 IC 並創建一個新的多態 IC,它支持整數和字符串兩種類型的操作數。 當您第三次調用 IC 時,V8 重複相同的過程並創建另一個級別 3 的多態 IC。

但是,有一個限制。 在 IC 級別達到 5 後(可以使用–max_inlining_levels標誌更改),該函數變為超態並且不再被認為是可優化的。

直觀可以理解,單態函數運行速度最快,內存佔用也更小。

不要將大文件添加到內存中

這是顯而易見且眾所周知的。 如果您要處理大文件,例如大型 CSV 文件,請逐行讀取並分小塊進行處理,而不是將整個文件加載到內存中。 在極少數情況下,單行 csv 會大於 1mb,因此您可以將其放入New Space中。

不要阻塞主服務器線程

如果您有一些需要一些時間來處理的熱門 API,例如用於調整圖像大小的 API,請將其移動到單獨的線程或將其轉換為後台作業。 CPU 密集型操作會阻塞主線程,迫使所有其他客戶等待並繼續發送請求。 未處理的請求數據會堆積在內存中,從而迫使 Full GC 需要更長的時間才能完成。

不要創建不必要的數據

我曾經對restify有過一次奇怪的經歷。 如果你向一個無效的 URL 發送了幾十萬個請求,那麼應用程序內存將迅速增長到數百兆字節,直到幾秒鐘後一個完整的 GC 啟動,這時一切都會恢復正常。 事實證明,對於每個無效的 URL,restify 都會生成一個新的錯誤對象,其中包含長堆棧跟踪。 這迫使新創建的對象分配在大對象空間而不是新空間中。

在開發過程中訪問這些數據可能非常有用,但在生產過程中顯然不需要。 因此規則很簡單——除非確實需要,否則不要生成數據。

了解你的工具

最後但肯定不是最不重要的一點是了解您的工具。 有各種調試器、洩漏導管和使用圖生成器。 所有這些工具都可以幫助您使您的軟件更快、更高效。

結論

了解 V8 的垃圾收集和代碼優化器的工作原理是應用程序性能的關鍵。 V8 將 JavaScript 編譯為本機程序集,在某些情況下,編寫良好的代碼可以實現與 GCC 編譯的應用程序相當的性能。

如果您想知道的話,我的 Toptal 客戶端的新 API 應用程序雖然還有改進的餘地,但運行良好!

Joyent 最近發布了一個新版本的 Node.js,它使用了 V8 的最新版本之一。 為 Node.js v0.12.x 編寫的某些應用程序可能與新的 v4.x 版本不兼容。 但是,應用程序將在新版本的 Node.js 中體驗到巨大的性能和內存使用改進。