排查 Node.js 服務(wù)內(nèi)存泄漏,沒(méi)想到竟是它?
今日文章由作者 @林樂(lè)揚(yáng) 投稿分享。
背景
團(tuán)隊(duì)最近將兩個(gè)項(xiàng)目遷移至 degg 2.0 中,兩個(gè)項(xiàng)目均出現(xiàn)比較嚴(yán)重的內(nèi)存泄漏問(wèn)題,此處以本人維護(hù)的埋點(diǎn)服務(wù)為例進(jìn)行排查。服務(wù)上線后內(nèi)存增長(zhǎng)如下圖,其中紅框?yàn)?degg 2.0 線上運(yùn)行的時(shí)間窗口,在短短 36 小時(shí)內(nèi),內(nèi)存已經(jīng)增長(zhǎng)到 50%,而平時(shí)內(nèi)存穩(wěn)定在 20%-30%,可知十之八九出現(xiàn)了內(nèi)存泄漏。

排查思路
由于兩個(gè)接入 degg 2.0 的服務(wù)均出現(xiàn)內(nèi)存泄漏問(wèn)題,因此初步將排查范圍鎖定在 degg 2.0引入或重寫的基礎(chǔ)組件上,重點(diǎn)懷疑對(duì)象為 nodex-logger 組件;同時(shí)為了排查內(nèi)存泄漏,我們需要獲取服務(wù)運(yùn)行進(jìn)程的堆快照(heapsnapshot),獲取方式可參看文章 hyj1991:Node 案發(fā)現(xiàn)場(chǎng)揭秘 —— 快速定位線上內(nèi)存泄漏https://zhuanlan.zhihu.com/p/36340263 。
排查過(guò)程
一、獲取堆快照
使用 alinode 獲取堆快照,服務(wù)啟動(dòng)后,使用小流量預(yù)熱一兩分鐘便記錄第1份堆快照(2020-4-16-16:52),接著設(shè)置 qps 為 125 對(duì)服務(wù)進(jìn)行施壓,經(jīng)過(guò)大約一個(gè)小時(shí)(2020-4-16-15:46)獲取第2份堆快照。使用 Chrome dev工具載入兩份堆快照,如下圖所示,發(fā)現(xiàn)服務(wù)僅短短運(yùn)行一小時(shí),其堆快照文件就增大了 45MB,而初始大小也不過(guò) 39.7MB;我們按 Retained Size 列進(jìn)行排序,很快就發(fā)現(xiàn)了一個(gè)『嫌疑犯』,即 generator;該項(xiàng)占用了 55% 的大小,同時(shí) Shallow Size 卻為 0%,一項(xiàng)一項(xiàng)地展開(kāi),鎖定到了圖中高亮的這行,但是繼續(xù)展開(kāi)卻提示 0%,線索突然斷了。

盯著 generator 進(jìn)入思考,我的服務(wù)代碼并沒(méi)有generator 語(yǔ)法,為什么會(huì)出現(xiàn) generator 對(duì)象的內(nèi)存泄漏呢?此時(shí)我把注意力轉(zhuǎn)到 node_modules 目錄中,由于最近一直在優(yōu)化 nodex-kafka 組件,有時(shí)直接在 node_modules 目錄中修改該組件的代碼進(jìn)行調(diào)試,因此幾乎每個(gè)文件頭部都有的一段代碼引起了我的注意:
"use?strict";
var?__awaiter?=?(this?&&?this.__awaiter)?||?function?(thisArg,?_arguments,?P,?generator)?{
????function?adopt(value)?{?return?value?instanceof?P???value?:?new?P(function?(resolve)?{?resolve(value);?});?}
????return?new?(P?||?(P?=?Promise))(function?(resolve,?reject)?{
????????function?fulfilled(value)?{?try?{?step(generator.next(value));?}?catch?(e)?{?reject(e);?}?}
????????function?rejected(value)?{?try?{?step(generator["throw"](value));?}?catch?(e)?{?reject(e);?}?}
????????function?step(result)?{?result.done???resolve(result.value)?:?adopt(result.value).then(fulfilled,?rejected);?}
????????step((generator?=?generator.apply(thisArg,?_arguments?||?[])).next());
????});
};
這個(gè)代碼是 typescript 源碼編譯后的產(chǎn)出,由于代碼使用了 async/await 語(yǔ)法,因此都編譯成 __awaiter 的形式,在源碼中使用 async 函數(shù)的地方,在編譯后都使用 __awaiter 進(jìn)行包裹:
//?編譯前
(async?function()?{
??await?Promise.resolve(1);
??await?Promise.resolve(2);
})()
//?編譯后
(function?()?{
??return?__awaiter(this,?void?0,?void?0,?function*?()?{
????yield?Promise.resolve(1);
????yield?Promise.resolve(2);
??});
})();
同時(shí)一個(gè)關(guān)于 generator 內(nèi)存泄漏的 #30753 generator functions - memory leak https://github.com/nodejs/node/issues/30753 也引起了我的注意,該 issue 遇到的問(wèn)題無(wú)論從 Node.js 的版本和內(nèi)存泄漏的表現(xiàn)都和我遇到的問(wèn)題十分相似。所以我在工程的 node_modules 中搜索所有 __awaiter 字符串,發(fā)現(xiàn)了 3 個(gè)模塊編譯出了上述代碼,分別是:
nodex-logger nodex-kafka nodex-apollo
由于模塊的 tsconfig.json 的 target 字段將目標(biāo)產(chǎn)出為es6,因此才會(huì)使用 generator 去模擬 async/await 語(yǔ)法,但是從 Node.js v8.10.0 開(kāi)始已經(jīng) 100% 支持了 ES2017 的所有特性,所以本不該編譯 async/await 語(yǔ)法,此處遂將這 3 個(gè)模塊的目標(biāo)產(chǎn)出配置改為 es2017,這樣 tsc 就不會(huì)編譯 async/await 語(yǔ)法。
二、驗(yàn)證
重復(fù)之前獲取堆快照的步驟,驚奇地發(fā)現(xiàn)即使過(guò)了一天,內(nèi)存也沒(méi)有增長(zhǎng),而且 generator 也沒(méi)有持有未釋放的內(nèi)存:
至此,內(nèi)存泄漏問(wèn)題已經(jīng)解決!那么如何避免遇到這個(gè)問(wèn)題呢?
如何避免
一、解決步驟
步驟一
該問(wèn)題僅在特定的 Node.js 版本中存在,請(qǐng)使用版本區(qū)間 (v11.0.0 - v12.16.0) 之外的 Node.js,從而防止二方 npm 組件、三方 npm 組件的 generator 語(yǔ)法使你的服務(wù)出問(wèn)題
步驟二將自己的 typescript 的目標(biāo)環(huán)境(target)編譯為 es2017 及以上,同時(shí)應(yīng)盡量使用 async/await 語(yǔ)法而不是 generator 語(yǔ)法,從而防止別人使用 (v11.0.0 - v12.16.0) 版本時(shí),引入你的 npm 組件而導(dǎo)致內(nèi)存泄漏
二、詳細(xì)說(shuō)明
前文說(shuō)了從 Node.js v8.10.0 開(kāi)始就已經(jīng)支持了 async/await 語(yǔ)法,經(jīng)查該版本于 2018-03-06 發(fā)布,由于所有服務(wù)也不可能一下全切換到新版本,因此為了兼容 Node.js v6 版本的環(huán)境,需要將代碼編譯到 es6。但是站在現(xiàn)在這個(gè) LTS 版本已經(jīng)是 v12 的時(shí)間節(jié)點(diǎn),完全可以排查現(xiàn)有使用 typescript 的 npm 組件是否都編譯到 es2017,甚至探討編譯到 es2019 的可能。
此外這個(gè)內(nèi)存泄漏問(wèn)題是從哪個(gè)版本開(kāi)始有的,現(xiàn)在是否解決了呢?編寫可驗(yàn)證的內(nèi)存泄漏的代碼如下:
//?no-leak.js
const?heapdump?=?require('heapdump')
class?Async?{
??async?run()?{
??????return?null;
??}
}
const?run?=?async?()?=>?{
??for?(let?index?=?0;?index?10000000;?index++)?{
??????if?(index?%?1000000?===?0)
??????????console.log(Math.floor(process.memoryUsage().heapUsed?/?10000),?index);
??????const?doer?=?new?Async();
??????await?doer.run();
??}
??heapdump.writeSnapshot((err,?filename)?=>?{
????console.log("Heap?dump?written?to",?filename);
??});
};
run();
//?leak.js?由?no-leak.js?編譯得來(lái)
var?__awaiter?=?(this?&&?this.__awaiter)?||?function?(thisArg,?_arguments,?P,?generator)?{
????function?adopt(value)?{?return?value?instanceof?P???value?:?new?P(function?(resolve)?{?resolve(value);?});?}
????return?new?(P?||?(P?=?Promise))(function?(resolve,?reject)?{
????????function?fulfilled(value)?{?try?{?step(generator.next(value));?}?catch?(e)?{?reject(e);?}?}
????????function?rejected(value)?{?try?{?step(generator["throw"](value));?}?catch?(e)?{?reject(e);?}?}
????????function?step(result)?{?result.done???resolve(result.value)?:?adopt(result.value).then(fulfilled,?rejected);?}
????????step((generator?=?generator.apply(thisArg,?_arguments?||?[])).next());
????});
};
class?Async?{
????run()?{
????????return?__awaiter(this,?void?0,?void?0,?function*?()?{
????????????return?null;
????????});
????}
}
const?run?=?()?=>?__awaiter(this,?void?0,?void?0,?function*?()?{
????const?now?=?Date.now();
????console.log('循環(huán)總次數(shù):?',?10000000);
?????for?(let?index?=?0;?index?10000000;?index++)?{
????????if?(index?%?1000000?===?0)?{
????????????console.log('第?%d?次循環(huán),此時(shí)內(nèi)存為?%d',?index,?Math.floor(process.memoryUsage().heapUsed?/?1000000));
????????}
????????const?instance?=?new?Async();
????????yield?instance.run();
????}
????console.log('總耗時(shí):?%d?秒',?(Date.now()?-?now)?/?1000);
});
run();
經(jīng)過(guò)二分排查,發(fā)現(xiàn)該泄漏問(wèn)題從 v11.0.0 引入,在 v12.16.0 解決;內(nèi)存泄漏版本執(zhí)行腳本時(shí),內(nèi)存占用逐步遞增直到 crash,而未泄漏版本則會(huì)及時(shí)回收內(nèi)存。

根本原因
根本原因是 v8 的一個(gè) bug,相關(guān)鏈接:
v8 issue: https://bugs.chromium.org/p/v8/issues/detail?id=10031
v8 commit: https://chromium.googlesource.com/v8/v8.git/+/d3a1a5b6c4916f22e076e3349ed3619bfb014f29
node issue: https://github.com/nodejs/node/issues/30753
node commit: https://github.com/nodejs/node/pull/31005/files
改進(jìn)后的代碼,在分配新增WeakArrayList 數(shù)組時(shí),即使返回沒(méi)有空閑數(shù)組的標(biāo)記( kNoEmptySlotsMarker ),仍需要調(diào)用 ScanForEmptySlots 方法重新掃描一次數(shù)組,因?yàn)樵摂?shù)組元素有可能有被 GC 回收,這些被回收的元素是可以重復(fù)使用的;僅當(dāng)返回 kNoEmptySlotsMarker 且數(shù)組中沒(méi)有被 GC 回收的元素,才真正執(zhí)行新增邏輯:
//?https://github.com/targos/node/blob/cceb2a87295724b7aa843363460ffcd10cda05b5/deps/v8/src/objects/objects.cc#L4042
//?static
Handle?PrototypeUsers::Add(Isolate*?isolate,
??????????????????????????????????????????Handle?array,
??????????????????????????????????????????Handle<Map>?value,
??????????????????????????????????????????int*?assigned_index)?{
??int?length?=?array->length();
??if?(length?==?0)?{
????//?Uninitialized?WeakArrayList;?need?to?initialize?empty_slot_index.
????array?=?WeakArrayList::EnsureSpace(isolate,?array,?kFirstIndex?+?1);
????set_empty_slot_index(*array,?kNoEmptySlotsMarker);
????array->Set(kFirstIndex,?HeapObjectReference::Weak(*value));
????array->set_length(kFirstIndex?+?1);
????if?(assigned_index?!=?nullptr)?*assigned_index?=?kFirstIndex;
????return?array;
??}
??//?If?the?array?has?unfilled?space?at?the?end,?use?it.
??if?(!array->IsFull())?{
????array->Set(length,?HeapObjectReference::Weak(*value));
????array->set_length(length?+?1);
?????if?(assigned_index?!=?nullptr)?*assigned_index?=?length;
????return?array;
??}
??//?If?there?are?empty?slots,?use?one?of?them.
??int?empty_slot?=?Smi::ToInt(empty_slot_index(*array));
??if?(empty_slot?==?kNoEmptySlotsMarker)?{
????//?GCs?might?have?cleared?some?references,?rescan?the?array?for?empty?slots.
????PrototypeUsers::ScanForEmptySlots(*array);
????empty_slot?=?Smi::ToInt(empty_slot_index(*array));
??}
???if?(empty_slot?!=?kNoEmptySlotsMarker)?{
????DCHECK_GE(empty_slot,?kFirstIndex);
????CHECK_LT(empty_slot,?array->length());
????int?next_empty_slot?=?array->Get(empty_slot).ToSmi().value();
????array->Set(empty_slot,?HeapObjectReference::Weak(*value));
????if?(assigned_index?!=?nullptr)?*assigned_index?=?empty_slot;
????set_empty_slot_index(*array,?next_empty_slot);
????return?array;
??}?else?{
????DCHECK_EQ(empty_slot,?kNoEmptySlotsMarker);
??}
??
???//?Array?full?and?no?empty?slots.?Grow?the?array.
??array?=?WeakArrayList::EnsureSpace(isolate,?array,?length?+?1);
??array->Set(length,?HeapObjectReference::Weak(*value));
??array->set_length(length?+?1);
??if?(assigned_index?!=?nullptr)?*assigned_index?=?length;
??return?array;
}
//?static
void?PrototypeUsers::ScanForEmptySlots(WeakArrayList?array)?{
??for?(int?i?=?kFirstIndex;?i?????if?(array.Get(i)->IsCleared())?{
??????PrototypeUsers::MarkSlotEmpty(array,?i);
????}
??}
}
不止內(nèi)存泄漏
在我測(cè)試內(nèi)存泄漏時(shí),有一個(gè)發(fā)現(xiàn),執(zhí)行發(fā)生內(nèi)存泄漏時(shí)的代碼(前文的 leak.js)和未發(fā)生內(nèi)存泄漏時(shí)的代碼(前文的 no-leak.js)時(shí),即使在已經(jīng)修復(fù)該問(wèn)題的 Node.js v12.16.2 版本下,generator 語(yǔ)法仍然有兩個(gè)問(wèn)題:
內(nèi)存回收效率低,導(dǎo)致執(zhí)行完后,仍有相當(dāng)大的內(nèi)存占用; 執(zhí)行效率非常慢, async/await版本僅需要 0.953 秒,而generator卻需要 17.754 秒;

這說(shuō)明,相比 generator 語(yǔ)法,async/await 語(yǔ)法無(wú)論從執(zhí)行效率還是內(nèi)存占用方面都有壓倒性優(yōu)勢(shì)。那么執(zhí)行效率對(duì)比如何呢?上 benchmark 工具比劃比劃:
//?benchmark.js
const?__awaiter?=?(this?&&?this.__awaiter)?||?function?(thisArg,?_arguments,?P,?generator)?{
??function?adopt(value)?{?return?value?instanceof?P???value?:?new?P(function?(resolve)?{?resolve(value);?});?}
??return?new?(P?||?(P?=?Promise))(function?(resolve,?reject)?{
??????function?fulfilled(value)?{?try?{?step(generator.next(value));?}?catch?(e)?{?reject(e);?}?}
??????function?rejected(value)?{?try?{?step(generator["throw"](value));?}?catch?(e)?{?reject(e);?}?}
??????function?step(result)?{?result.done???resolve(result.value)?:?adopt(result.value).then(fulfilled,?rejected);?}
??????step((generator?=?generator.apply(thisArg,?_arguments?||?[])).next());
??});
};
const?Benchmark?=?require('benchmark');
const?suite?=?new?Benchmark.Suite;
suite
??.add('generator',?{
????defer:?true,
????fn:?function?(deferred)?{
??????(function?()?{
????????return?__awaiter(this,?void?0,?void?0,?function*?()?{
????????????yield?Promise.resolve(1);
????????????yield?Promise.resolve(2);
????????????//?測(cè)試完成
????????????deferred.resolve();
????????});
??????})();
????}
??})
???.add('async/await',?{
????defer:?true,
????fn:?function(deferred)?{
??????(async?function()?{
????????await?Promise.resolve(1);
????????await?Promise.resolve(2);
????????//?測(cè)試完成
????????deferred.resolve();
??????})()
????}
??})
??.on('cycle',?function(event)?{
????console.log(String(event.target));
??})
??.run({
????'async':?false
??});
Node.js v12.16.2 的結(jié)果:
generator?x?443,891?ops/sec?±4.12%?(75?runs?sampled)
async/await?x?4,567,163?ops/sec?±1.96%?(79?runs?sampled)
generator 每秒執(zhí)行了 516,178 次操作,而 async/await 每秒執(zhí)行了 4,531,357 次操作,后者是前者的 10 倍多!我們看看其它 Node.js 版本表現(xiàn)如何:
電腦配置:MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)

二者執(zhí)行效率和 Node.js 版本成正比,而 Node.js v12 來(lái)了一次大躍進(jìn),直接高了一個(gè)數(shù)量級(jí),這個(gè)得益于 v8 7.2 的一個(gè)新特性,官網(wǎng)用了整整一篇文章 https://v8.dev/blog/fast-async#await-under-the-hood 說(shuō)明,有興趣的可以看看。
Chrome 也中招了嗎?
目前最新版:版本 81.0.4044.113(正式版本) (64 位) 已經(jīng)修復(fù)這個(gè)問(wèn)題
既然是 v8 的問(wèn)題,那么 chrome 瀏覽器也是有這個(gè)問(wèn)題的,打開(kāi)空白標(biāo)簽頁(yè),執(zhí)行前文給出的 leak.js 代碼:
推廣時(shí)間
我叫林樂(lè)揚(yáng),點(diǎn)擊閱讀我的更多文章,覺(jué)得有收獲記得打開(kāi)原文鏈接給我點(diǎn)個(gè)贊,或者關(guān)注我哦~
本文作者@林樂(lè)揚(yáng) | 原文@https://zhuanlan.zhihu.com/p/252689936
敬請(qǐng)關(guān)注「程序員成長(zhǎng)指北」微信公眾號(hào),獲取優(yōu)質(zhì)文章,如需投稿可在后臺(tái)留言與我取得聯(lián)系。
一杯茶的時(shí)間,上手 Express 框架開(kāi)發(fā)
Next.js + TypeScript 搭建一個(gè)簡(jiǎn)易的博客系統(tǒng)
??愛(ài)心三連擊
1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「點(diǎn)贊,在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)程序員成長(zhǎng)指北,回復(fù)「1」加入Node進(jìn)階交流群!「在這里有好多 Node 開(kāi)發(fā)者,會(huì)討論 Node 知識(shí),互相學(xué)習(xí)」!
3.也可添加微信【ikoala520】,一起成長(zhǎng)。
