EventHandle
概要
在幫助客戶排查問題的過程中,我們發(fā)現(xiàn)很多客戶對于 Node.js 中的事件偵聽器的使用存在一定的誤區(qū),所以事件偵聽器的泄漏是編寫 Node.js 代碼的一大定時(shí)炸彈,下面我們通過一個(gè)真實(shí)的客戶案例來詳細(xì)解讀下此類泄漏,以幫助大家避免類似的問題。
發(fā)現(xiàn)問題
接入 Node.js 性能平臺(tái)后,我們在全局告警中看到某個(gè)客戶的應(yīng)用頻繁提醒堆內(nèi)使用內(nèi)存占據(jù)堆上限超過 80%,這種情況基本上大概率就是發(fā)生內(nèi)存泄漏了,聯(lián)系到對應(yīng)的客戶后,經(jīng)過客戶的授權(quán),我們看到了有問題的進(jìn)程內(nèi)存狀況,如下圖所示:
雖然圖中依舊顯示健康態(tài),但是依舊可以看到趨勢是堆內(nèi)內(nèi)存穩(wěn)步上升,一些問題比較嚴(yán)重的業(yè)務(wù)進(jìn)程直接達(dá)到堆內(nèi)限制上限從而 OOM 掉。
定位問題
堆快照分析
排查內(nèi)存泄漏,首先需要的就是堆快照,因?yàn)榇舜翁暨x的進(jìn)程堆內(nèi)內(nèi)存大小約 225M,因此能順利通過 Node.js 性能平臺(tái)打印堆快照獲得 HeapSnapshot,并且這份快照也能反映出內(nèi)存中的一些問題。經(jīng)過性能平臺(tái)提供的在線分析,可以獲取如下信息。
第一個(gè)信息是當(dāng)前的堆結(jié)構(gòu)概覽:
第二個(gè)信息是內(nèi)存泄漏報(bào)表:
展開引力圖,看到疑似的泄露點(diǎn)引用關(guān)系如下圖所示:
進(jìn)一步根據(jù)引力圖詳細(xì)信息,可以看到內(nèi)存堆積的引用文字關(guān)系如下所示(順序):
(context) of function /home/xxxx/app/controller/home.js() / home.js @345463 -> Client @46073 的 _events 屬性 -> EventHandlers @46075 的 error 屬性 -> Array @46089
看到這里,熟悉 Node.js 的 Event 類實(shí)現(xiàn)的小伙伴就能直接判斷出是 socket 創(chuàng)建時(shí)的 error 事件偵聽器策略不當(dāng)引發(fā)的內(nèi)存泄漏,更簡單的說,就是在同一個(gè) socket 創(chuàng)建中不斷偵聽 error 事件導(dǎo)致的內(nèi)存泄漏。
第三個(gè)信息是對象簇視圖:
可以看到,確實(shí)和上面猜測的一樣,app/controller/home.js 中的某個(gè) socket 對象的 error 事件偵聽器回調(diào)函數(shù)在不停增加。
代碼分析
到這里可以去代碼中定位具體有問題的代碼了,因此又經(jīng)過與此應(yīng)用負(fù)責(zé)人溝通后,拿到了項(xiàng)目代碼倉庫的查看權(quán)限,查看 app/controller/home.js 文件,搜索 error ,直接找到了出問題的地方,以下是問題最小化代碼:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//開發(fā)環(huán)境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
}
client.on('error', err => {
//error 處理...
})
//其余邏輯處理...
}
}
}
return HomeController;
};
并且在 router.js 中定義的對應(yīng)這個(gè) controller 的路由如下:
app.get(/.*/, 'home.demo');
好了,可以看到,由于 client 是全局變量,此時(shí)用戶每訪問一次網(wǎng)站首頁,都會(huì)給 client._events.error 對應(yīng)的數(shù)組增加一個(gè) error 處理函數(shù),雖然每個(gè) error 處理函數(shù) 26KB 左右,但是流量上來后,很容易累積觸發(fā) OOM 。
解決問題
理解內(nèi)存泄漏產(chǎn)生的原因后,要解決這個(gè)問題就比較簡單了,一種通用的解決辦法是在 error 偵聽操作放入 client 的初始化里面:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//開發(fā)環(huán)境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
client.on('error', err => {
//error 處理...
})
}
//其余邏輯處理...
}
}
}
return HomeController;
};
這樣保證全局只有一個(gè) error 事件偵聽器,性能也比較好。還有一種處理方式是每次 controller 處理完成后移除偵聽器:
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
if (ENV === DEVELOPMENT) {
//開發(fā)環(huán)境下操作...
} else {
if (!client) {
client = Client.create({
refreshInterval: 30000,
requestTimeout: 5000,
urllib: urllib
})
}
//定義 error 處理句柄
const errorHandle = err => {
//error 處理...
}
client.on('error', errorHandle);
//其余邏輯處理...
//移除 error 偵聽器
client.removeListener('error', errorHandle);
}
}
}
return HomeController;
};
但是這樣子比第一種耗費(fèi)一些額外的性能,只是作為解決事件偵聽器內(nèi)存泄漏的方式寫出來供大家參考。
最后一種是 egg 框架推薦的寫法,也是本問題的最佳解決辦法,像這種進(jìn)程生命周期只需要一次連接的可以放到 app/extend/application.js 中去由框架保證全局單例:
// app/extend/application.js
const CLIENT = Symbol('Application#xxClient');
module.exports = {
get xxClient() {
if (!this[CLIENT]) {
this[CLIENT] = Client.create({});
// this[CLIENT].on('error', fn);
}
return this[CLIENT];
}
}
// app/controller/home.js
module.exports = app => {
class HomeController extends app.Controller {
* demo() {
this.app.xxClient.xx();
}
}
return HomeController;
};