Node 案發(fā)現(xiàn)場揭秘 —— 未定義 “window” 對象引發(fā)的 SSR 內(nèi)存泄露
大廠技術(shù) 高級前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
泄露現(xiàn)象
在生產(chǎn)中遇到一枚全新的內(nèi)存泄露場景,因?yàn)樯婕暗较鄬ζ俚?react ssr,因此記錄下排查的過程作為以后相關(guān)的 vue / react ssr 場景內(nèi)存問題參考。
先來看下現(xiàn)象,內(nèi)部某應(yīng)用當(dāng)天 10 點(diǎn)開始內(nèi)存就一直不停往上漲,一直到凌晨超過閾值觸發(fā)告警:
經(jīng)過相關(guān)業(yè)務(wù)同學(xué)通過觀察了各個(gè)接口的請求訪問量,發(fā)現(xiàn)有一個(gè)實(shí)人認(rèn)證的頁面訪問量也在當(dāng)天 10 點(diǎn)突增:
于是判斷是業(yè)務(wù)放量導(dǎo)致此頁面的請求量上漲,而這個(gè)頁面的 ssr 可能存在問題導(dǎo)致了應(yīng)用的內(nèi)存泄露,并且與這個(gè)頁面相關(guān)有大量 window is not defined 的錯(cuò)誤信息:
server render bundle error, try client render, the server render error is:
ReferenceError: window is not defined.
這個(gè)錯(cuò)誤其實(shí)也很容易理解,因?yàn)?ssr 場景下一部分頁面邏輯構(gòu)造會(huì)在服務(wù)端執(zhí)行,這里顯然是編寫對應(yīng)代碼的時(shí)候沒有處理好 window 對象,導(dǎo)致在 ssr 的時(shí)候報(bào)錯(cuò)。
猜想修復(fù)
對于業(yè)務(wù)來說,第一優(yōu)先級需要做的是線上止血,內(nèi)存泄露的原因暫時(shí)不明,但是伴隨泄露的大量錯(cuò)誤是擺在這邊的,因此業(yè)務(wù)同學(xué)第一反應(yīng)是先修復(fù)掉這個(gè) window is not defined 錯(cuò)誤來觀察效果:
重新發(fā)布后經(jīng)過一天觀察,這個(gè)錯(cuò)誤修復(fù)后,內(nèi)存趨勢確實(shí)穩(wěn)定了下來。
雖然看似修復(fù)了這個(gè)泄露問題,但是一線同學(xué)都知道,不怕出問題,就怕問題莫名其妙修復(fù),所以在線上穩(wěn)定的情況下,接下來通過本地壓測這個(gè)疑似泄露的人臉識別頁面來復(fù)現(xiàn)當(dāng)時(shí)的場景,進(jìn)行更加深入的故障原因探究。
第一次分析
既然是內(nèi)存泄露,那肯定是先抓堆快照進(jìn)行分析,在線下復(fù)現(xiàn)的應(yīng)用上通過工具獲取到泄露場景復(fù)原時(shí)對應(yīng)的堆快照,進(jìn)行分析:
第一眼看到這個(gè)泄露支配樹視圖的時(shí)候就猜測是類似 輕松排查線上 Node 內(nèi)存泄露問題[1] 中提到的 閉包引用導(dǎo)致的泄露,因?yàn)檫@種類型的泄露原始引用鏈路是非常長的,要靠完全展開看細(xì)節(jié)比較難。
在這個(gè)圖又可以發(fā)現(xiàn)每一層上下文中都具有一些特征的context類型變量:
mobileRealNameAuth.js 正是這個(gè)人臉識別頁面對應(yīng)的 react ssr 組件,由業(yè)務(wù)編寫的 tsx 打包而成,首先還是考慮在這個(gè) js bundle 中通過上述的context關(guān)鍵字比對來看看是哪里產(chǎn)生了循環(huán)持有context 的泄露。
可惜的是這個(gè) bundle 因?yàn)楣惨蕾嚾即蛄诉M(jìn)去,有數(shù)萬行代碼,因此混淆后基本不具備可讀性,艱難嘗試了一會(huì)遂選擇放棄。
到這里這個(gè)泄露問題似乎陷入了僵局,已經(jīng)知道是泄露產(chǎn)生的原因看起來是 ssr 的時(shí)候在重復(fù)引入mobileRealNameAuth.js這個(gè)打包文件,但是不知道業(yè)務(wù)源頭是哪里。
第二次分析
因?yàn)榉治龆芽煺瘴募萑肓私┚?,因此開始嘗試各種比如業(yè)務(wù)邏輯屏蔽縮小問題范圍等隔靴搔癢的方法,然而均無果,不得不繼續(xù)回來啃堆快照。
這時(shí)候可能是排查這個(gè)問題時(shí)間比較長,突然靈光一閃,既然混淆后的context變量名不具備可讀性,那我們可以 在 webpack 配置中修改只打包不混淆變量名 不就可以了。
相關(guān)的業(yè)務(wù)同學(xué)相當(dāng)高效,將minimize: false 打包配置修改后迅速發(fā)上線重新進(jìn)行壓測,果然這次抓到的堆快照信息充足很多:
至此其實(shí)這里具體產(chǎn)生閉包引用的元兇已經(jīng)找到,正是lodash模塊在重復(fù)生成,接下來我們要進(jìn)一步分析下lodash 這樣的一個(gè)公共模塊,為什么會(huì)造成內(nèi)存泄露。
定位原因
新的快照中context變量名是oldDash,這是一個(gè)比較關(guān)鍵的信息。
繼續(xù)查看未混淆的線上打包后mobileRealNameAuth.js 里 lodash 模塊的引入邏輯,此文件約 3.6w 行代碼,經(jīng)過分析抽象 lodash在其中的抽象邏輯可以簡化為:
;(function () {
// 這里的 root 在 ssr 服務(wù)端就是 global 對象
var freeGlobal = typeof global == 'object' && global && global.Object === Object && global;
var freeSelf = typeof self == 'object' && self && self.Object === Object && self;
var root = freeGlobal || freeSelf || Function('return this')();
var oldDash = root._;
function noConflict() {
if (root._ === this) {
root._ = oldDash;
}
return this;
}
var runInContext = (function runInContext(context) {
function lodash() {
}
// lodash 的各種公共方法...
lodash.after = function () { };
lodash.ary = function () { };
return lodash;
});
var _ = runInContext();
root._ = _;
}).call(this);
抽象完 bundle 中 lodash 的引入邏輯,泄露原因就很清晰了,noConflict雖然沒有被執(zhí)行,但是它將oldDash加入到了頂層context中,這會(huì)導(dǎo)致lodash實(shí)例的方法函數(shù)指向的閉包對象鏈路也包含oldDash。
這樣mobileRealNameAuth.js每次被重復(fù)執(zhí)行,都會(huì)創(chuàng)建一個(gè)新的lodash對象,且其實(shí)例方法的閉包對象又會(huì)指向上次創(chuàng)建的lodash對象,從而造成泄露。
簡單說明下為什么
lodash的實(shí)例方法中沒有使用到oldDash也會(huì)在其對應(yīng)的閉包中存在oldDash的引用,這是因?yàn)?v8 的閉包對象實(shí)現(xiàn)本質(zhì)上是一個(gè)FixedArray鏈表,而不是每個(gè)作用域提供單獨(dú)的context的實(shí)現(xiàn)方式。
驗(yàn)證泄露
為了讓上面的原因定位更加具有說服力,我們來以上面簡化的lodash 重復(fù)加載,通過觀察堆內(nèi)存狀態(tài)驗(yàn)證下是不是真的因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(92, 157, 255);">noConflict導(dǎo)致的循環(huán)上下文持有,首先編寫 leak.js:
// leak.js
const lodash = function () {
var root = global;
var oldDash = root._;
// 添加 oldDash 進(jìn)閉包的元兇
function noConflict() {
if (root._ === this) {
root._ = oldDash;
}
return this;
}
var runInContext = (function runInContext(context) {
function lodash() {
}
// 方便看到內(nèi)存狀態(tài),給 lodash 添加一個(gè)大字符串
lodash.longStr = new Array(1000000).fill('*');
lodash.after = function () { }
return lodash;
});
var _ = runInContext();
root._ = _;
}
// 每 100ms 生成一個(gè) lodash
setInterval(() => lodash.call(this), 100);
// 1s 一次打印堆狀況
setInterval(() => {
const { heapUsed } = process.memoryUsage();
const used = heapUsed / 1024 / 1024;
console.log('------', `${used.toFixed(2)}MB...`);
}, 1000);
接著執(zhí)行node \--max-old-space-size=1024 leak.js,可以通過 1s 輸出的堆內(nèi)存狀態(tài)日志,看到此時(shí)堆內(nèi)存完全無法回收,一路上漲很快就 OOM。
接著我們斷開noConflict對oldDash的引用繼續(xù)進(jìn)行觀察:
// leak.js
const lodash = function () {
var root = global;
var oldDash = root._;
// 注釋點(diǎn)開對 oldDash 引用
function noConflict() {
// if (root._ === this) {
// root._ = oldDash;
// }
// return this;
}
var runInContext = (function runInContext(context) {
function lodash() {
}
// 方便看到內(nèi)存狀態(tài),給 lodash 添加一個(gè)大字符串
lodash.longStr = new Array(1000000).fill('*');
lodash.after = function () { }
return lodash;
});
var _ = runInContext();
root._ = _;
}
// 每 100ms 生成一個(gè) lodash
setInterval(() => lodash.call(this), 100);
setInterval(() => {
const { heapUsed } = process.memoryUsage();
const used = heapUsed / 1024 / 1024;
console.log('------', `${used.toFixed(2)}MB...`);
}, 1000);
依舊執(zhí)行 node --max-old-space-size=1024 leak.js,觀察到斷開閉包對 oldDash 對象的引用后,內(nèi)存可以被正?;厥眨?/p>
------ 64.40MB...
------ 102.82MB...
------ 25.99MB...
------ 26.16MB...
------ 26.15MB...
------ 25.91MB...
------ 18.52MB...
------ 18.52MB...
這樣我們就確認(rèn)了:即使noConflict沒有被任何地方調(diào)用,只要它存在就會(huì)將oldDash添加到頂層閉包對象中造成循環(huán)持有上一次lodash實(shí)例,進(jìn)而導(dǎo)致內(nèi)存泄露。
為什么重復(fù)
到這里還沒結(jié)束,快照中的mobileRealNameAuth.js泄露產(chǎn)生的原因找到了,那么下一個(gè)問題其實(shí)是為什么這個(gè)app/view下的打包后的頁面文件會(huì)被重復(fù)執(zhí)行呢?
這個(gè)問題涉及到 react ssr 的實(shí)現(xiàn),因?yàn)榇隧?xiàng)目為一個(gè) Egg.js[2] 的應(yīng)用,因此我們接下來去跟蹤下對應(yīng)的 render 方法實(shí)現(xiàn)插件 egg-view-react-ssr[3] 的具體實(shí)現(xiàn):
async render(name, locals, options) {
const reactElement = require(name);
return this.renderElement(reactElement, locals, options);
}
name其實(shí)對應(yīng)的就是mobileRealNameAuth.js在服務(wù)器上的全路徑。
問題又來了,Node.js 實(shí)現(xiàn)的 CommonJS 規(guī)范中對于require模塊的結(jié)果是有緩存的,也就是mobileRealNameAuth.js并不應(yīng)該被重復(fù)多次編譯執(zhí)行。
回想下最開始看到的 window is not defined 錯(cuò)誤信息:
server render bundle error, try client render, the server render error is:
ReferenceError: window is not defined.
可以發(fā)現(xiàn)由于人臉識別的前端 tsx 代碼最終是和lodash等依賴模塊一起打包進(jìn)mobileRealNameAuth.js的,且lodash等公共依賴先執(zhí)行,tsx 業(yè)務(wù)邏輯后執(zhí)行。
這樣 tsx 打包后的 js bundle 拋錯(cuò),導(dǎo)致了require('/path/to/mobileRealNameAuth.js')始終沒有執(zhí)行成功生成 CommonJS 緩存。
因此用戶每訪問一次人臉識別頁面業(yè)務(wù)邏輯前的lodash等公共模塊就會(huì)重新編譯加載一次,最后因?yàn)樯厦娣治龅玫降拈]包對象循環(huán)持有 oldDash 產(chǎn)生了內(nèi)存泄露。
修復(fù)問題
其實(shí)分析到這邊,整個(gè)泄露產(chǎn)生鏈條和原因都非常清晰了,修復(fù)就變得很容易,只要將 tsx 中在 ssr 服務(wù)端生命周期執(zhí)行的 window 未定義的進(jìn)行一些處理不讓其出錯(cuò)即可。
更進(jìn)一步的,可以在業(yè)務(wù)中加入 CI 校驗(yàn),對每一個(gè)前端 ssr entry 入口打包后的 js bundle 在 CI 中進(jìn)行一次 require 保證其可以被正常加載緩存起來防止重復(fù)加載。
一些總結(jié)
這次的問題是在實(shí)際生產(chǎn)中遇到了比較經(jīng)典的閉包泄露模型,對于 vue/react ssr,除了要注意在服務(wù)端 ssr 生命周期能觸發(fā)的鉤子內(nèi)不要?jiǎng)?chuàng)建資源屬性的內(nèi)容防止內(nèi)存泄露,也要尤其注意到這類的執(zhí)行錯(cuò)誤可能引發(fā)公共模塊重復(fù)編譯導(dǎo)致的閉包泄露。
關(guān)注我們
我們將為你帶來最前沿的前端資訊。
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章
2. 訂閱官方博客 www.inode.club 讓我們一起成長
點(diǎn)贊和在看就是最大的支持??
參考資料
輕松排查線上 Node 內(nèi)存泄露問題: https://cnodejs.org/topic/58eb5d378cda07442731569f
[2]Egg.js: https://eggjs.org/
[3]egg-view-react-ssr: https://github.com/easy-team/egg-view-react-ssr/blob/master/lib/engine.js#L70-L73
