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