天天看點

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

楔子

前面一小節我們以一個真實的壓測案例來給大家講解如何利用 

Node.js 性能平台

 生成的 CPU Profile 分析來進行壓測時的性能調優。那麼與 CPU 相關的問題相比,Node.js 應用中由于不當使用産生的記憶體問題是一個重災區,而且這些問題往往都是出現在生産環境下,本地壓測都難以複現,實際上這部分記憶體問題也成為了很多的 Node.js 開發者不敢去将 Node.js 這門技術棧深入運用到後端的一大阻礙。

本節将以一個開發者容易忽略的生産記憶體溢出案例,來展示如何借助于性能平台實作對線上應用 Node.js 應用出現記憶體洩漏時的發現、分析、定位問題代碼以及修複的過程,希望能對大家有所啟發。

本書首發在 Github,倉庫位址:

https://github.com/aliyun-node/Node.js-Troubleshooting-Guide

,雲栖社群會同步更新。

最小化複現代碼

因為記憶體問題相對 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 定制後的分解結果展示:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

這裡預設的報表頁面頂部的資訊含義已經提到過了,這裡不再重複,我們重點來看下這裡的可疑點資訊:提示有 18 個對象占據了 96.38% 的堆空間,顯然這裡就是我們需要進一步檢視的點。我們可以點選 對象名稱 來看到這18 個

system/Context

 對象的詳細内容:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

這裡進入的是分别以這 18 個

system/Context

  為根節點起始的支配樹視圖,是以展開後可以看到各個對象的實際記憶體占用情況,上圖中顯然問題集中在第一個對象上,我們繼續展開檢視:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

很顯然,這裡真正吃掉堆空間的是 451 個 SomeClient 執行個體,面對這樣的問題我們需要從兩個方面來判斷這是否真的是記憶體異常的問題:

  • 目前的 Node.js 應用在正常的邏輯下,是否單個程序需要 451 個 SomeClient 執行個體
  • 如果确實需要這麼多 SomeClient 執行個體,那麼每個執行個體占據 1.98MB 的空間是否合理

對于第一個判斷,在對應的實際生産面臨的問題中,經過代碼邏輯的重新确認,我們的應用确實需要這麼多的 Client 執行個體,顯然此時排查重點集中在每個執行個體的 1.98MB 的空間占用是否合理上,假如進一步判斷還是合理的,這意味着 Node.js 預設單程序 1.4G 的堆上限在這個場景下是不适用的,需要我們來通過啟動 Flag 調大堆上限。

正是基于以上的判斷需求,我們繼續點開這些 SomeClient 執行個體進行檢視:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

這裡可以很清晰的看到,這個 SomeClient 本身隻有 1.97MB 的大小,但是下面的

options

 屬性對應的 Object@428973 對象一個就占掉了 1.98M,進一步展開這個可疑的 Object@428973 對象可以看到,其

ctx

 屬性對應的 Object@428919 對象正是 SomeClient 執行個體占據掉如此大的對空間的根本原因所在!

我們可以點選其它的 SomeClient 執行個體,可以看到每一個執行個體均是如此,此時我們需要結合代碼,判斷這裡的

options.ctx

 屬性挂載到 SomeClient 執行個體上是否也是合理的,點選此問題 Object 的位址:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

進入到這個 Object 的關系圖中:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

Search 展示的視圖不同于 Dom 結果圖,它實際上展示的是從堆快中解析出來的原始對象關系圖,是以邊資訊是一定會存在的,靠邊名稱和對象名稱,我們比較容易判斷對象在代碼中的位置。

但是在這個例子中,僅僅依靠以 Object@428973 為起始點的記憶體原始關系圖,看不到很明确的代碼位置,畢竟不管是

Object.ctx

 還是

Object.key

 都是相當常見的 JavaScript 代碼關系,是以我們繼續點選 Retainer 視圖:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出

得到如下資訊:

Node.js 應用故障排查手冊 —— 備援配置傳遞引發的記憶體溢出
這裡的 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 應用的記憶體異常的問題完美解決。

結尾

本節中也比較全面地給大家展示了如何使用 

 來排查定位線上應用記憶體洩漏問題,其實嚴格來說本次問題并不是真正意義上的記憶體洩漏,像這種配置傳遞時開發者圖省事直接全量 Assign 的場景我們在寫代碼時或多或少時都會遇到,這個問題帶給我們的啟示還是:當我們去編寫一個公共元件子產品時,永遠不要去相信使用者的傳入參數,任何時候都應當隻保留我們需要使用到的參數繼續往下傳遞,這樣可以避免掉很多問題。

繼續閱讀