Node.js 應(yīng)用故障排查手冊(cè) —— 冗余配置傳遞引發(fā)的內(nèi)存溢出
楔子
前面我們以一個(gè)真實(shí)的壓測(cè)案例來(lái)給大家講解如何利用 Node.js 性能平臺(tái) 生成的 CPU Profile 分析來(lái)進(jìn)行壓測(cè)時(shí)的性能調(diào)優(yōu)。那么與 CPU 相關(guān)的問(wèn)題相比,Node.js 應(yīng)用中由于不當(dāng)使用產(chǎn)生的內(nèi)存問(wèn)題是一個(gè)重災(zāi)區(qū),而且這些問(wèn)題往往都是出現(xiàn)在生產(chǎn)環(huán)境下,本地壓測(cè)都難以復(fù)現(xiàn),實(shí)際上這部分內(nèi)存問(wèn)題也成為了很多的 Node.js 開發(fā)者不敢去將 Node.js 這門技術(shù)棧深入運(yùn)用到后端的一大阻礙。
本節(jié)將以一個(gè)開發(fā)者容易忽略的生產(chǎn)內(nèi)存溢出案例,來(lái)展示如何借助于性能平臺(tái)實(shí)現(xiàn)對(duì)線上應(yīng)用 Node.js 應(yīng)用出現(xiàn)內(nèi)存泄漏時(shí)的發(fā)現(xiàn)、分析、定位問(wèn)題代碼以及修復(fù)的過(guò)程,希望能對(duì)大家有所啟發(fā)。
最小化復(fù)現(xiàn)代碼
因?yàn)閮?nèi)存問(wèn)題相對(duì) CPU 高的問(wèn)題來(lái)說(shuō)比較特殊,我們直接從問(wèn)題排查的描述可能不如結(jié)合問(wèn)題代碼來(lái)看比較直觀,因此在這里我們首先給出了最小化的復(fù)現(xiàn)代碼,大家運(yùn)行后結(jié)合下面的分析過(guò)程應(yīng)該能更有收獲,樣例基于 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 中增加一個(gè) Post 請(qǐng)求路由:
- router.post('/memory', controller.memory.index);
造成問(wèn)題的 Post 請(qǐng)求 Demo 這里也給出來(lái),如下所示:
- 'use strict';
- const fs = require('fs');
- const http = require('http');
- const postData = JSON.stringify({
- // 這里的 body.txt 可以放一個(gè)比較大 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);
***我們?cè)趩?dòng)完成最小化復(fù)現(xiàn)的 Demo 服務(wù)器后,再運(yùn)行這個(gè) Post 請(qǐng)求的客戶端,1s 發(fā)起一個(gè) Post 請(qǐng)求,在平臺(tái)控制臺(tái)可以看到堆內(nèi)存在一直增加,如果我們按照本書工具篇中的 Node.js 性能平臺(tái)使用指南 - 配置合適的告警 一節(jié)中配置了 Node.js 進(jìn)程堆內(nèi)存告警的話,過(guò)一會(huì)就會(huì)收到平臺(tái)的 短信/郵件 提醒。
問(wèn)題排查過(guò)程
收到性能平臺(tái)的進(jìn)程內(nèi)存告警后,我們登錄到控制臺(tái)并且進(jìn)入應(yīng)用首頁(yè),找到告警對(duì)應(yīng)實(shí)例上的問(wèn)題進(jìn)程,然后參照工具篇中的 Node.js 性能平臺(tái)使用指南 - 內(nèi)存泄漏 中的方法抓取堆快照,并且點(diǎn)擊 分析 按鈕查看 AliNode 定制后的分解結(jié)果展示:
這里默認(rèn)的報(bào)表頁(yè)面頂部的信息含義已經(jīng)提到過(guò)了,這里不再重復(fù),我們重點(diǎn)來(lái)看下這里的可疑點(diǎn)信息:提示有 18 個(gè)對(duì)象占據(jù)了 96.38% 的堆空間,顯然這里就是我們需要進(jìn)一步查看的點(diǎn)。我們可以點(diǎn)擊 對(duì)象名稱 來(lái)看到這18 個(gè) system/Context 對(duì)象的詳細(xì)內(nèi)容:
這里進(jìn)入的是分別以這 18 個(gè) system/Context 為根節(jié)點(diǎn)起始的支配樹視圖,因此展開后可以看到各個(gè)對(duì)象的實(shí)際內(nèi)存占用情況,上圖中顯然問(wèn)題集中在***個(gè)對(duì)象上,我們繼續(xù)展開查看:
很顯然,這里真正吃掉堆空間的是 451 個(gè) SomeClient 實(shí)例,面對(duì)這樣的問(wèn)題我們需要從兩個(gè)方面來(lái)判斷這是否真的是內(nèi)存異常的問(wèn)題:
- 當(dāng)前的 Node.js 應(yīng)用在正常的邏輯下,是否單個(gè)進(jìn)程需要 451 個(gè) SomeClient 實(shí)例
- 如果確實(shí)需要這么多 SomeClient 實(shí)例,那么每個(gè)實(shí)例占據(jù) 1.98MB 的空間是否合理
對(duì)于***個(gè)判斷,在對(duì)應(yīng)的實(shí)際生產(chǎn)面臨的問(wèn)題中,經(jīng)過(guò)代碼邏輯的重新確認(rèn),我們的應(yīng)用確實(shí)需要這么多的 Client 實(shí)例,顯然此時(shí)排查重點(diǎn)集中在每個(gè)實(shí)例的 1.98MB 的空間占用是否合理上,假如進(jìn)一步判斷還是合理的,這意味著 Node.js 默認(rèn)單進(jìn)程 1.4G 的堆上限在這個(gè)場(chǎng)景下是不適用的,需要我們來(lái)通過(guò)啟動(dòng) Flag 調(diào)大堆上限。
正是基于以上的判斷需求,我們繼續(xù)點(diǎn)開這些 SomeClient 實(shí)例進(jìn)行查看:
這里可以很清晰的看到,這個(gè) SomeClient 本身只有 1.97MB 的大小,但是下面的 options 屬性對(duì)應(yīng)的 Object@428973 對(duì)象一個(gè)就占掉了 1.98M,進(jìn)一步展開這個(gè)可疑的 Object@428973 對(duì)象可以看到,其 ctx 屬性對(duì)應(yīng)的 Object@428919 對(duì)象正是 SomeClient 實(shí)例占據(jù)掉如此大的對(duì)空間的根本原因所在!
我們可以點(diǎn)擊其它的 SomeClient 實(shí)例,可以看到每一個(gè)實(shí)例均是如此,此時(shí)我們需要結(jié)合代碼,判斷這里的 options.ctx 屬性掛載到 SomeClient 實(shí)例上是否也是合理的,點(diǎn)擊此問(wèn)題 Object 的地址:
進(jìn)入到這個(gè) Object 的關(guān)系圖中:
Node.js 應(yīng)用故障排查手冊(cè) —— 冗余配置傳遞引發(fā)的內(nèi)存溢出
Search 展示的視圖不同于 Dom 結(jié)果圖,它實(shí)際上展示的是從堆快中解析出來(lái)的原始對(duì)象關(guān)系圖,所以邊信息是一定會(huì)存在的,靠邊名稱和對(duì)象名稱,我們比較容易判斷對(duì)象在代碼中的位置。
但是在這個(gè)例子中,僅僅依靠以 Object@428973 為起始點(diǎn)的內(nèi)存原始關(guān)系圖,看不到很明確的代碼位置,畢竟不管是 Object.ctx 還是 Object.key 都是相當(dāng)常見的 JavaScript 代碼關(guān)系,因此我們繼續(xù)點(diǎn)擊 Retainer 視圖:
得到如下信息:
這里的 Retainer 信息和 Chrome Devtools 中的 Retainer 含義是一樣的,它代表了節(jié)點(diǎn)在堆內(nèi)存中的原始父引用關(guān)系,正如本文的內(nèi)存問(wèn)題案例中,僅靠可疑點(diǎn)本身以及其展開無(wú)法可靠地定位到問(wèn)題代碼的情況下,那么展開此對(duì)象的 Retainer 視圖,可以看到它的父節(jié)點(diǎn)鏈路可以比較方便的定位到問(wèn)題代碼。
這里我們顯然可以通過(guò)在 Retainer 視圖下的問(wèn)題對(duì)象父引用鏈路,很方便地找到代碼中創(chuàng)建此對(duì)象的代碼:
- function getClient(options) {
- if (!clients[options.key]) {
- clients[options.key] = new SomeClient(Object.assign({}, DEFAULT_OPTIONS, options));
- }
- return clients[options.key];
- }
結(jié)合看 SomeClient 的使用,看到用于初始化的 options 參數(shù)中實(shí)際上只是用到了其 key 屬性,其余的屬于冗余的配置信息,無(wú)需傳入。
代碼修復(fù)與確認(rèn)
知道了原因后修改起來(lái)就比較簡(jiǎn)單了,單獨(dú)生成一個(gè) SomeClient 使用的 options 參數(shù),并且僅將需要的數(shù)據(jù)從傳入的 options 參數(shù)上取過(guò)來(lái)以保證沒有冗余信息即可:
- 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];
- }
重新發(fā)布后運(yùn)行,可以到堆內(nèi)存下降至只有幾十兆,至此 Node.js 應(yīng)用的內(nèi)存異常的問(wèn)題***解決。
結(jié)尾
本節(jié)中也比較全面地給大家展示了如何使用 Node.js 性能平臺(tái) 來(lái)排查定位線上應(yīng)用內(nèi)存泄漏問(wèn)題,其實(shí)嚴(yán)格來(lái)說(shuō)本次問(wèn)題并不是真正意義上的內(nèi)存泄漏,像這種配置傳遞時(shí)開發(fā)者圖省事直接全量 Assign 的場(chǎng)景我們?cè)趯懘a時(shí)或多或少時(shí)都會(huì)遇到,這個(gè)問(wèn)題帶給我們的啟示還是:當(dāng)我們?nèi)ゾ帉懸粋€(gè)公共組件模塊時(shí),永遠(yuǎn)不要去相信使用者的傳入?yún)?shù),任何時(shí)候都應(yīng)當(dāng)只保留我們需要使用到的參數(shù)繼續(xù)往下傳遞,這樣可以避免掉很多問(wèn)題。
作者:奕鈞