一次NodeJS內(nèi)存泄漏排查的實(shí)戰(zhàn)記錄
前言
性能問(wèn)題(內(nèi)存、CPU 飆升導(dǎo)致服務(wù)重啟、異常)排查一直是 Node.js 服務(wù)端開發(fā)的難點(diǎn),去年在經(jīng)過(guò)調(diào)研后,在我們項(xiàng)目的 Node.js 服務(wù)上都接入了 Easy-Monitor 來(lái)幫助排查生產(chǎn)環(huán)境遇到的性能問(wèn)題。前段時(shí)間遇到了兩例內(nèi)存泄漏的案例,在這里做一個(gè)排查經(jīng)過(guò)的整理。
案例一
故障現(xiàn)象
線上的某個(gè)服務(wù)發(fā)生了重啟,經(jīng)過(guò)觀察 Grafana 得到,該服務(wù)在 5 天內(nèi)內(nèi)存持續(xù)上漲到達(dá) 1.3G+ 從而觸發(fā)了自動(dòng)重啟。
排查過(guò)程
在內(nèi)存處于高點(diǎn)時(shí)抓取了內(nèi)存快照,在 Easy-Monitor 平臺(tái)上進(jìn)行分析。
圖1
在圖一中能夠看到抓取內(nèi)存快照的時(shí)候 V8 堆內(nèi)有 1273 個(gè) TCP 對(duì)象沒(méi)有被釋放從而導(dǎo)致了內(nèi)存的上漲。接著,我們需要排查具體是哪里發(fā)生了內(nèi)存泄漏。
圖2
圖二是根據(jù)第一個(gè) TCP 對(duì)象的內(nèi)存地址進(jìn)行搜索得到的結(jié)果。簡(jiǎn)單點(diǎn)來(lái)說(shuō):
- Edge 視圖展示了這個(gè)數(shù)據(jù)擁有的子數(shù)據(jù)結(jié)構(gòu)。
- Retainer 視圖展示了這個(gè)對(duì)象被那些數(shù)據(jù)結(jié)構(gòu)引用。
我們排查問(wèn)題的思路就是從泄漏對(duì)象出發(fā),一級(jí)級(jí)向上搜索,直到找到我們眼熟的數(shù)據(jù)結(jié)構(gòu)來(lái)確定是哪一段代碼導(dǎo)致了內(nèi)存泄漏。
熟悉 Node.js 的同學(xué)應(yīng)該知道 TCP 對(duì)象是被 Socket 對(duì)象持有的,所以接下來(lái)搜索 Socket@328785 這個(gè)地址。
圖3
在 Retainer 視圖里顯示 SMTPConnection._socket 指向了我們搜索的 socket 地址,而 SMTP 很明顯和發(fā)送郵件相關(guān),這里我們將問(wèn)題的范圍縮小到了 node-mailer
這個(gè)包上。
圖4
搜索圖三中 Retainer 視圖中的 SMTPConnection@328773,結(jié)果如圖4。 SMTPConnection@328773 又指向了 system/Context (上下文)中的 connection@328799 對(duì)象。
圖5
從圖5中能看到,這個(gè)上下文包含 connection、sendMessage、socketOptions、returned、connection 這些數(shù)據(jù)結(jié)構(gòu),經(jīng)過(guò)對(duì) node-mailer
源碼的研究,我們能夠通過(guò)這個(gè)上下文對(duì)象定位到下面中的代碼片段。this.getSocket 函數(shù)的回調(diào)函數(shù)的執(zhí)行上下文即 system/Context@328799,回調(diào)函數(shù)中的 var connection = new SMTPConnection(options); 就是產(chǎn)生泄漏的對(duì)象。
/** * 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 沒(méi)有被釋放。 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 會(huì)無(wú)法釋放,這個(gè)問(wèn)題留到文章末尾再揭開答案。
案例二
故障現(xiàn)象
線上某個(gè)服務(wù)在啟動(dòng)后在短時(shí)間(4 小時(shí)左右)內(nèi)存就達(dá)到了上限發(fā)生了重啟。
排查過(guò)程
同樣在高點(diǎn)抓取了內(nèi)存快照進(jìn)行分析。
圖6
在圖6中能看到是因?yàn)?nbsp;TLSSocket 沒(méi)有釋放導(dǎo)致了內(nèi)存泄漏,查詢第一個(gè)TLSSocket@4531505。
圖7
圖7中可以看到又指向了 SMTPConnection,由于在案例 1 排查問(wèn)題的時(shí)候已經(jīng)研究過(guò) node-mailer
包了,所以知道這里的 TLSSocket 是郵箱服務(wù)在連接的時(shí)候一些通信會(huì)使用 TLSSocket。于是接著看查詢SMTPConnection@4531545。
圖8
在圖8中,我們能夠看到 535 的報(bào)錯(cuò)信息,在我們的業(yè)務(wù)代碼中,對(duì) 535 報(bào)錯(cuò)設(shè)置了重試機(jī)制(調(diào)用 node-mailer
的 api 關(guān)閉舊的連接,然后重新發(fā)送),但是這里很明顯舊的連接并沒(méi)有被成功關(guān)閉。
問(wèn)題原因
上文中的兩個(gè)案例都是因?yàn)?nbsp;Socket/TLSSocket 無(wú)法釋放導(dǎo)致的,通過(guò)查看 node-mailer
源碼,可以發(fā)現(xiàn)無(wú)論是 Socket 發(fā)送郵件成功還是 TLSSocket 報(bào)錯(cuò)后都會(huì)調(diào)用 SMTPConnection.close(),并最終調(diào)用 socket.end() 或者 TLSSocket.end() 來(lái)釋放連接。 看了很多源碼才發(fā)現(xiàn)原來(lái)問(wèn)題出在了node-mailer
的版本和 Node.js 的版本問(wèn)題上。項(xiàng)目中使用的node-mailer
版本是比較早的 2.7.2 版本,支持 Node.js 版本也比較低,而 node-v10.x
后調(diào)整了流相關(guān)的實(shí)現(xiàn)邏輯,我們的線上環(huán)境最近也從 node-v8.x
升級(jí)到了 node-v12.x
,所以產(chǎn)生了上文中的兩個(gè)問(wèn)題。
node-v9.x 以下的版本
node-v9.x
(包括 9.x)以下版本在調(diào)用 socket.end() 后會(huì)同步調(diào)用 TCP.close() 直接銷毀連接。
Socket.prototype.end = function(data, encoding) { // 調(diào)用雙工流(可寫流)的 end 函數(shù)會(huì)觸發(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ù)會(huì)調(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ù)來(lái)關(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 實(shí)現(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 最終會(huì)調(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()后自動(dòng)調(diào)用自身的 destroy,在 v12 版本默認(rèn)是 false。v14 版本開始默認(rèn)為 true。 // 所以當(dāng)我們調(diào)用 socket.end()的時(shí)候,不會(huì)立刻銷毀自己,僅僅會(huì)觸發(fā) finish 事件。 if (state.autoDestroy) { const rState = stream._readableState; if (!rState || (rState.autoDestroy && rState.endEmitted)) { stream.destroy(); } } } } return need; } // 那么 socket 什么時(shí)候會(huì)被銷毀呢? // socket 構(gòu)造函數(shù) function Socket(options) { // 忽略 // 注冊(cè)了end事件,觸發(fā)的時(shí)候這個(gè)函數(shù)會(huì)調(diào)用自己的 destroy。 this.on('end', onReadableStreamEnd); } function onReadableStreamEnd() { // 省略 if (!this.destroyed && !this.writable && !this.writableLength) // 同樣會(huì)調(diào)用可寫流的 destroy 然后調(diào)用 socket._destory() this.destroy(); } // Socket 的 end 事件是可讀流 read()的時(shí)候觸發(fā)的。 // n 參數(shù)指定要讀取的特定字節(jié)數(shù),如果不傳,每次返回內(nèi)部buffer中的全部數(shù)據(jù)。 Readable.prototype.read = function(n){ const state = this._readableState; // 計(jì)算可以從緩沖區(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'); //這里和可寫流一樣也有個(gè) autoDestroy 參數(shù),同樣是默認(rèn) 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 默認(rèn)是 false,所以在調(diào)用 socket.end() 的時(shí)候并不會(huì)同步的摧毀流,而是依賴 socket.read() 時(shí)觸發(fā) end 事件,然而在低版本的 node-mailer
實(shí)現(xiàn)邏輯里,會(huì)移除 socket 所有的監(jiān)聽(tīng)器,所以也就導(dǎo)致了在 node-v12.x
環(huán)境下永遠(yuǎn)無(wú)法觸發(fā) socket.destroy() 來(lái)銷毀連接。
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ù)泄露
通過(guò)上述排查過(guò)程,從根因上找到了生產(chǎn)環(huán)境中 node-v12.x
運(yùn)行低版本的 node-mailer
產(chǎn)生內(nèi)存泄露的原因,那么要解決此問(wèn)題也變得非常簡(jiǎn)單。
通過(guò)升級(jí) node-mailer
的版本以支持 node-v12.x
,困擾多時(shí)的線上內(nèi)存泄露問(wèn)題至此完美解決。
總結(jié)
到此這篇關(guān)于一次NodeJS內(nèi)存泄漏排查的文章就介紹到這了,更多相關(guān)NodeJS內(nèi)存泄漏排查內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
node.js學(xué)習(xí)之?dāng)嘌詀ssert的使用示例
assert 模塊主要用于編寫程序的單元測(cè)試時(shí)使用,通過(guò)斷言可以提早發(fā)現(xiàn)和排查出錯(cuò)誤。下面這篇文章主要給大家介紹了關(guān)于node.js學(xué)習(xí)之?dāng)嘌詀ssert的相關(guān)資料,需要的朋友可以參考借鑒,下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧。2017-09-09Node.js?express中的身份認(rèn)證的實(shí)現(xiàn)
本文主要介紹了Node.js?express中的身份認(rèn)證的實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-01-01node.js解決獲取圖片真實(shí)文件類型的問(wèn)題
這篇文章主要介紹了node.js解決獲取圖片真實(shí)文件類型的問(wèn)題,本文根據(jù)二進(jìn)制流及文件頭獲取文件類型mime-type,然后讀取文件二進(jìn)制的頭信息,獲取其真實(shí)的文件類型,需要的朋友可以參考下2014-12-12Node.js 使用request模塊下載文件的實(shí)例
今天小編就為大家分享一篇Node.js 使用request模塊下載文件的實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-09-09node.js平臺(tái)下利用cookie實(shí)現(xiàn)記住密碼登陸(Express+Ejs+Mysql)
這篇文章主要介紹了node.js平臺(tái)下利用cookie實(shí)現(xiàn)記住密碼登陸(Express+Ejs+Mysql),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下。2017-04-04