一次NodeJS內(nèi)存泄漏排查的實戰(zhàn)記錄
前言
性能問題(內(nèi)存、CPU 飆升導(dǎo)致服務(wù)重啟、異常)排查一直是 Node.js 服務(wù)端開發(fā)的難點,去年在經(jīng)過調(diào)研后,在我們項目的 Node.js 服務(wù)上都接入了 Easy-Monitor 來幫助排查生產(chǎn)環(huán)境遇到的性能問題。前段時間遇到了兩例內(nèi)存泄漏的案例,在這里做一個排查經(jīng)過的整理。
案例一
故障現(xiàn)象
線上的某個服務(wù)發(fā)生了重啟,經(jīng)過觀察 Grafana 得到,該服務(wù)在 5 天內(nèi)內(nèi)存持續(xù)上漲到達 1.3G+ 從而觸發(fā)了自動重啟。
排查過程
在內(nèi)存處于高點時抓取了內(nèi)存快照,在 Easy-Monitor 平臺上進行分析。

圖1
在圖一中能夠看到抓取內(nèi)存快照的時候 V8 堆內(nèi)有 1273 個 TCP 對象沒有被釋放從而導(dǎo)致了內(nèi)存的上漲。接著,我們需要排查具體是哪里發(fā)生了內(nèi)存泄漏。

圖2
圖二是根據(jù)第一個 TCP 對象的內(nèi)存地址進行搜索得到的結(jié)果。簡單點來說:
- Edge 視圖展示了這個數(shù)據(jù)擁有的子數(shù)據(jù)結(jié)構(gòu)。
- Retainer 視圖展示了這個對象被那些數(shù)據(jù)結(jié)構(gòu)引用。
我們排查問題的思路就是從泄漏對象出發(fā),一級級向上搜索,直到找到我們眼熟的數(shù)據(jù)結(jié)構(gòu)來確定是哪一段代碼導(dǎo)致了內(nèi)存泄漏。
熟悉 Node.js 的同學(xué)應(yīng)該知道 TCP 對象是被 Socket 對象持有的,所以接下來搜索 Socket@328785 這個地址。

圖3
在 Retainer 視圖里顯示 SMTPConnection._socket 指向了我們搜索的 socket 地址,而 SMTP 很明顯和發(fā)送郵件相關(guān),這里我們將問題的范圍縮小到了 node-mailer 這個包上。

圖4
搜索圖三中 Retainer 視圖中的 SMTPConnection@328773,結(jié)果如圖4。 SMTPConnection@328773 又指向了 system/Context (上下文)中的 connection@328799 對象。

圖5
從圖5中能看到,這個上下文包含 connection、sendMessage、socketOptions、returned、connection 這些數(shù)據(jù)結(jié)構(gòu),經(jīng)過對 node-mailer 源碼的研究,我們能夠通過這個上下文對象定位到下面中的代碼片段。this.getSocket 函數(shù)的回調(diào)函數(shù)的執(zhí)行上下文即 system/Context@328799,回調(diào)函數(shù)中的 var connection = new SMTPConnection(options); 就是產(chǎn)生泄漏的對象。
/**
* Sends an e-mail using the selected settings
*
* @param {Object} mail Mail object
* @param {Function} callback Callback function
*/
SMTPTransport.prototype.send = function (mail, callback) {
this.getSocket(this.options, function (err, socketOptions) {
if (err) {
return callback(err);
}
var options = this.options;
if (socketOptions && socketOptions.connection) {
this.logger.info('Using proxied socket from %s:%s to %s:%s', socketOptions.connection.remoteAddress, socketOptions.connection.remotePort, options.host || '', options.port || '');
// only copy options if we need to modify it
options = assign(false, options);
Object.keys(socketOptions).forEach(function (key) {
options[key] = socketOptions[key];
});
}
// 這里的 connection 沒有被釋放。
var connection = new SMTPConnection(options);
var returned = false;
connection.once('error', function (err) {
if (returned) {
return;
}
returned = true;
connection.close();
return callback(err);
});
connection.once('end', function () {
if (returned) {
return;
}
returned = true;
return callback(new Error('Connection closed'));
});
var sendMessage = function () {
var envelope = mail.message.getEnvelope();
var messageId = (mail.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
var recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info('Sending message <%s> to <%s>', messageId, recipients.join(', '));
connection.send(envelope, mail.message.createReadStream(), function (err, info) {
if (returned) {
return;
}
returned = true;
connection.close();
if (err) {
return callback(err);
}
info.envelope = {
from: envelope.from,
to: envelope.to
};
info.messageId = messageId;
return callback(null, info);
});
}.bind(this);
connection.connect(function () {
if (returned) {
return;
}
if (this.options.auth) {
connection.login(this.options.auth, function (err) {
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return callback(err);
}
sendMessage();
});
} else {
sendMessage();
}
}.bind(this));
}.bind(this));
};為什么這里創(chuàng)建的 connection 會無法釋放,這個問題留到文章末尾再揭開答案。
案例二
故障現(xiàn)象
線上某個服務(wù)在啟動后在短時間(4 小時左右)內(nèi)存就達到了上限發(fā)生了重啟。
排查過程
同樣在高點抓取了內(nèi)存快照進行分析。

圖6
在圖6中能看到是因為 TLSSocket 沒有釋放導(dǎo)致了內(nèi)存泄漏,查詢第一個TLSSocket@4531505。

圖7
圖7中可以看到又指向了 SMTPConnection,由于在案例 1 排查問題的時候已經(jīng)研究過 node-mailer 包了,所以知道這里的 TLSSocket 是郵箱服務(wù)在連接的時候一些通信會使用 TLSSocket。于是接著看查詢SMTPConnection@4531545。

圖8
在圖8中,我們能夠看到 535 的報錯信息,在我們的業(yè)務(wù)代碼中,對 535 報錯設(shè)置了重試機制(調(diào)用 node-mailer 的 api 關(guān)閉舊的連接,然后重新發(fā)送),但是這里很明顯舊的連接并沒有被成功關(guān)閉。
問題原因
上文中的兩個案例都是因為 Socket/TLSSocket 無法釋放導(dǎo)致的,通過查看 node-mailer 源碼,可以發(fā)現(xiàn)無論是 Socket 發(fā)送郵件成功還是 TLSSocket 報錯后都會調(diào)用 SMTPConnection.close(),并最終調(diào)用 socket.end() 或者 TLSSocket.end() 來釋放連接。 看了很多源碼才發(fā)現(xiàn)原來問題出在了node-mailer 的版本和 Node.js 的版本問題上。項目中使用的node-mailer版本是比較早的 2.7.2 版本,支持 Node.js 版本也比較低,而 node-v10.x 后調(diào)整了流相關(guān)的實現(xiàn)邏輯,我們的線上環(huán)境最近也從 node-v8.x 升級到了 node-v12.x ,所以產(chǎn)生了上文中的兩個問題。
node-v9.x 以下的版本
node-v9.x(包括 9.x)以下版本在調(diào)用 socket.end() 后會同步調(diào)用 TCP.close() 直接銷毀連接。
Socket.prototype.end = function(data, encoding) {
// 調(diào)用雙工流(可寫流)的 end 函數(shù)會觸發(fā) finish 事件。
stream.Duplex.prototype.end.call(this, data, encoding);
this.writable = false;
// just in case we're waiting for an EOF.
if (this.readable && !this._readableState.endEmitted)
this.read(0);
else
maybeDestroy(this);
};
function maybeDestroy(socket) {
if (!socket.readable &&
!socket.writable &&
!socket.destroyed &&
!socket.connecting &&
!socket._writableState.length) {
// 這里調(diào)用的也是可寫流的 destroy 函數(shù)
socket.destroy();
}
}
// 可寫流 destroy 函數(shù)
function destroy(err, cb) {
// 省略其余代碼
// destroy 函數(shù)會調(diào)用 socket._destroy。
this._destroy(err || null, (err) => {
if (!cb && err) {
process.nextTick(emitErrorNT, this, err);
if (this._writableState) {
this._writableState.errorEmitted = true;
}
} else if (cb) {
cb(err);
}
});
}
Socket.prototype._destroy = function(exception, cb) {
this.connecting = false;
this.readable = this.writable = false;
if (this._handle) {
this[BYTES_READ] = this._handle.bytesRead;
// this._handle = TCP(),調(diào)用TCP.close函數(shù)來關(guān)閉連接。
this._handle.close(() => {
debug('emit close');
this.emit('close', isException);
});
this._handle.onread = noop;
this._handle = null;
this._sockname = null;
}
if (this._server) {
COUNTER_NET_SERVER_CONNECTION_CLOSE(this);
debug('has server');
this._server._connections--;
if (this._server._emitCloseIfDrained) {
this._server._emitCloseIfDrained();
}
}
};node-v10.x 以上的版本
// socket 實現(xiàn)了Duplex,end 函數(shù)直接調(diào)用了 writableStream.end
Socket.prototype.end = function(data, encoding, callback) {
stream.Duplex.prototype.end.call(this, data, encoding, callback);
DTRACE_NET_STREAM_END(this);
return this;
};
// _stream_writable.js
// writableStream.end 最終會調(diào)用如下函數(shù)
function finishMaybe(stream, state) {
const need = needFinish(state);
if (need) {
prefinish(stream, state);
if (state.pendingcb === 0) {
state.finished = true;
stream.emit('finish');
// 這里的 state 存放可讀流的狀態(tài)變量
// @node10 新增:autoDestroy 標(biāo)志流是否在調(diào)用 end()后自動調(diào)用自身的 destroy,在 v12 版本默認是 false。v14 版本開始默認為 true。
// 所以當(dāng)我們調(diào)用 socket.end()的時候,不會立刻銷毀自己,僅僅會觸發(fā) finish 事件。
if (state.autoDestroy) {
const rState = stream._readableState;
if (!rState || (rState.autoDestroy && rState.endEmitted)) {
stream.destroy();
}
}
}
}
return need;
}
// 那么 socket 什么時候會被銷毀呢?
// socket 構(gòu)造函數(shù)
function Socket(options) {
// 忽略
// 注冊了end事件,觸發(fā)的時候這個函數(shù)會調(diào)用自己的 destroy。
this.on('end', onReadableStreamEnd);
}
function onReadableStreamEnd() {
// 省略
if (!this.destroyed && !this.writable && !this.writableLength)
// 同樣會調(diào)用可寫流的 destroy 然后調(diào)用 socket._destory()
this.destroy();
}
// Socket 的 end 事件是可讀流 read()的時候觸發(fā)的。
// n 參數(shù)指定要讀取的特定字節(jié)數(shù),如果不傳,每次返回內(nèi)部buffer中的全部數(shù)據(jù)。
Readable.prototype.read = function(n){
const state = this._readableState;
// 計算可以從緩沖區(qū)中讀取多少數(shù)據(jù)。
n = howMuchToRead(n, state);
// 本次可以讀取的字節(jié)數(shù)為0
// 流內(nèi)部緩沖區(qū)buffer中的字節(jié)數(shù)為0
// 可讀流的 ended 狀態(tài)為 true
if (n === 0 && state.ended) {
if (state.length === 0)
// 結(jié)束自己
endReadable(this);
return null;
}
}
function endReadable(stream) {
const state = stream._readableState;
debug('endReadable', state.endEmitted);
if (!state.endEmitted) {
state.ended = true;
process.nextTick(endReadableNT, state, stream);
}
}
function endReadableNT(state, stream) {
debug('endReadableNT', state.endEmitted, state.length);
if (!state.endEmitted && state.length === 0) {
state.endEmitted = true;
stream.readable = false;
// 觸發(fā) stream(socket)的 end 事件。
stream.emit('end');
//這里和可寫流一樣也有個 autoDestroy 參數(shù),同樣是默認 false。
if (state.autoDestroy) {
// In case of duplex streams we need a way to detect
// if the writable side is ready for autoDestroy as well
const wState = stream._writableState;
if (!wState || (wState.autoDestroy && wState.finished)) {
stream.destroy();
}
}
}
}線上環(huán)境的 node-v12.x 版本中,由于 autoDestroy 默認是 false,所以在調(diào)用 socket.end() 的時候并不會同步的摧毀流,而是依賴 socket.read() 時觸發(fā) end 事件,然而在低版本的 node-mailer 實現(xiàn)邏輯里,會移除 socket 所有的監(jiān)聽器,所以也就導(dǎo)致了在 node-v12.x 環(huán)境下永遠無法觸發(fā) socket.destroy() 來銷毀連接。
SMTPConnection.prototype._onConnect = function () {
// 省略
// clear existing listeners for the socket
this._socket.removeAllListeners('data');
this._socket.removeAllListeners('timeout');
this._socket.removeAllListeners('close');
this._socket.removeAllListeners('end');
// 省略
};修復(fù)泄露
通過上述排查過程,從根因上找到了生產(chǎn)環(huán)境中 node-v12.x 運行低版本的 node-mailer 產(chǎn)生內(nèi)存泄露的原因,那么要解決此問題也變得非常簡單。
通過升級 node-mailer 的版本以支持 node-v12.x ,困擾多時的線上內(nèi)存泄露問題至此完美解決。
總結(jié)
到此這篇關(guān)于一次NodeJS內(nèi)存泄漏排查的文章就介紹到這了,更多相關(guān)NodeJS內(nèi)存泄漏排查內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
node.js學(xué)習(xí)之?dāng)嘌詀ssert的使用示例
assert 模塊主要用于編寫程序的單元測試時使用,通過斷言可以提早發(fā)現(xiàn)和排查出錯誤。下面這篇文章主要給大家介紹了關(guān)于node.js學(xué)習(xí)之?dāng)嘌詀ssert的相關(guān)資料,需要的朋友可以參考借鑒,下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-09-09
Node.js?express中的身份認證的實現(xiàn)
本文主要介紹了Node.js?express中的身份認證的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-01-01
node.js平臺下利用cookie實現(xiàn)記住密碼登陸(Express+Ejs+Mysql)
這篇文章主要介紹了node.js平臺下利用cookie實現(xiàn)記住密碼登陸(Express+Ejs+Mysql),具有一定的參考價值,感興趣的小伙伴們可以參考一下。2017-04-04

