Node.js 應用故障排查手冊 —— 冗余配置傳遞引發的內存溢出
楔子
前面我們以一個真實的壓測案例來給大家講解如何利用 Node.js 性能平臺 生成的 CPU Profile 分析來進行壓測時的性能調優。那么與 CPU 相關的問題相比,Node.js 應用中由于不當使用產生的內存問題是一個重災區,而且這些問題往往都是出現在生產環境下,本地壓測都難以復現,實際上這部分內存問題也成為了很多的 Node.js 開發者不敢去將 Node.js 這門技術棧深入運用到后端的一大阻礙。
本節將以一個開發者容易忽略的生產內存溢出案例,來展示如何借助于性能平臺實現對線上應用 Node.js 應用出現內存泄漏時的發現、分析、定位問題代碼以及修復的過程,希望能對大家有所啟發。
最小化復現代碼
因為內存問題相對 CPU 高的問題來說比較特殊,我們直接從問題排查的描述可能不如結合問題代碼來看比較直觀,因此在這里我們首先給出了最小化的復現代碼,大家運行后結合下面的分析過程應該能更有收獲,樣例基于 Egg.js:如下所示:
- 'use strict';
- const Controller = require('egg').Controller;
- const DEFAULT_OPTIONS = { logger: console };
- class SomeClient {
- constructor(options) {
- this.options = options;
- }
- async fetchSomething() {
- return this.options.key;
- }
- }
- const clients = {};
- function getClient(options) {
- if (!clients[options.key]) {
- clients[options.key] = new SomeClient(Object.assign({}, DEFAULT_OPTIONS, options));
- }
- return clients[options.key];
- }
- class MemoryController extends Controller {
- async index() {
- const { ctx } = this;
- const options = { ctx, key: Math.random().toString(16).slice(2) };
- const data = await getClient(options).fetchSomething();
- ctx.body = data;
- }
- }
- module.exports = MemoryController;
然后在 app/router.js 中增加一個 Post 請求路由:
- router.post('/memory', controller.memory.index);
造成問題的 Post 請求 Demo 這里也給出來,如下所示:
- 'use strict';
- const fs = require('fs');
- const http = require('http');
- const postData = JSON.stringify({
- // 這里的 body.txt 可以放一個比較大 2M 左右的字符串
- data: fs.readFileSync('./body.txt').toString()
- });
- function post() {
- const req = http.request({
- method: 'POST',
- host: 'localhost',
- port: '7001',
- path: '/memory',
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(postData)
- }
- });
- req.write(postData);
- req.end();
- req.on('error', function (err) {
- console.log(12333, err);
- });
- }
- setInterval(post, 1000);
***我們在啟動完成最小化復現的 Demo 服務器后,再運行這個 Post 請求的客戶端,1s 發起一個 Post 請求,在平臺控制臺可以看到堆內存在一直增加,如果我們按照本書工具篇中的 Node.js 性能平臺使用指南 - 配置合適的告警 一節中配置了 Node.js 進程堆內存告警的話,過一會就會收到平臺的 短信/郵件 提醒。
問題排查過程
收到性能平臺的進程內存告警后,我們登錄到控制臺并且進入應用首頁,找到告警對應實例上的問題進程,然后參照工具篇中的 Node.js 性能平臺使用指南 - 內存泄漏 中的方法抓取堆快照,并且點擊 分析 按鈕查看 AliNode 定制后的分解結果展示:
這里默認的報表頁面頂部的信息含義已經提到過了,這里不再重復,我們重點來看下這里的可疑點信息:提示有 18 個對象占據了 96.38% 的堆空間,顯然這里就是我們需要進一步查看的點。我們可以點擊 對象名稱 來看到這18 個 system/Context 對象的詳細內容:
這里進入的是分別以這 18 個 system/Context 為根節點起始的支配樹視圖,因此展開后可以看到各個對象的實際內存占用情況,上圖中顯然問題集中在***個對象上,我們繼續展開查看:
很顯然,這里真正吃掉堆空間的是 451 個 SomeClient 實例,面對這樣的問題我們需要從兩個方面來判斷這是否真的是內存異常的問題:
- 當前的 Node.js 應用在正常的邏輯下,是否單個進程需要 451 個 SomeClient 實例
- 如果確實需要這么多 SomeClient 實例,那么每個實例占據 1.98MB 的空間是否合理
對于***個判斷,在對應的實際生產面臨的問題中,經過代碼邏輯的重新確認,我們的應用確實需要這么多的 Client 實例,顯然此時排查重點集中在每個實例的 1.98MB 的空間占用是否合理上,假如進一步判斷還是合理的,這意味著 Node.js 默認單進程 1.4G 的堆上限在這個場景下是不適用的,需要我們來通過啟動 Flag 調大堆上限。
正是基于以上的判斷需求,我們繼續點開這些 SomeClient 實例進行查看:
這里可以很清晰的看到,這個 SomeClient 本身只有 1.97MB 的大小,但是下面的 options 屬性對應的 Object@428973 對象一個就占掉了 1.98M,進一步展開這個可疑的 Object@428973 對象可以看到,其 ctx 屬性對應的 Object@428919 對象正是 SomeClient 實例占據掉如此大的對空間的根本原因所在!
我們可以點擊其它的 SomeClient 實例,可以看到每一個實例均是如此,此時我們需要結合代碼,判斷這里的 options.ctx 屬性掛載到 SomeClient 實例上是否也是合理的,點擊此問題 Object 的地址:
進入到這個 Object 的關系圖中:
Node.js 應用故障排查手冊 —— 冗余配置傳遞引發的內存溢出
Search 展示的視圖不同于 Dom 結果圖,它實際上展示的是從堆快中解析出來的原始對象關系圖,所以邊信息是一定會存在的,靠邊名稱和對象名稱,我們比較容易判斷對象在代碼中的位置。
但是在這個例子中,僅僅依靠以 Object@428973 為起始點的內存原始關系圖,看不到很明確的代碼位置,畢竟不管是 Object.ctx 還是 Object.key 都是相當常見的 JavaScript 代碼關系,因此我們繼續點擊 Retainer 視圖:
得到如下信息:
這里的 Retainer 信息和 Chrome Devtools 中的 Retainer 含義是一樣的,它代表了節點在堆內存中的原始父引用關系,正如本文的內存問題案例中,僅靠可疑點本身以及其展開無法可靠地定位到問題代碼的情況下,那么展開此對象的 Retainer 視圖,可以看到它的父節點鏈路可以比較方便的定位到問題代碼。
這里我們顯然可以通過在 Retainer 視圖下的問題對象父引用鏈路,很方便地找到代碼中創建此對象的代碼:
- function getClient(options) {
- if (!clients[options.key]) {
- clients[options.key] = new SomeClient(Object.assign({}, DEFAULT_OPTIONS, options));
- }
- return clients[options.key];
- }
結合看 SomeClient 的使用,看到用于初始化的 options 參數中實際上只是用到了其 key 屬性,其余的屬于冗余的配置信息,無需傳入。
代碼修復與確認
知道了原因后修改起來就比較簡單了,單獨生成一個 SomeClient 使用的 options 參數,并且僅將需要的數據從傳入的 options 參數上取過來以保證沒有冗余信息即可:
- function getClient(options) {
- const someClientOptions = Object.assign({ key: options.key }, DEFAULT_OPTIONS);
- if (!clients[options.key]) {
- clients[options.key] = new SomeClient(someClientOptions);
- }
- return clients[options.key];
- }
重新發布后運行,可以到堆內存下降至只有幾十兆,至此 Node.js 應用的內存異常的問題***解決。
結尾
本節中也比較全面地給大家展示了如何使用 Node.js 性能平臺 來排查定位線上應用內存泄漏問題,其實嚴格來說本次問題并不是真正意義上的內存泄漏,像這種配置傳遞時開發者圖省事直接全量 Assign 的場景我們在寫代碼時或多或少時都會遇到,這個問題帶給我們的啟示還是:當我們去編寫一個公共組件模塊時,永遠不要去相信使用者的傳入參數,任何時候都應當只保留我們需要使用到的參數繼續往下傳遞,這樣可以避免掉很多問題。
作者:奕鈞