0. 背景
之前在這篇文章里說過做了個 SSR 《論如何像素級直出具有 14W 行代碼量的前端頁面》,本以為今天順順利利,高高興興。
沒想到項目放到線上后,隨著請求量的增多,卻感覺到首屏速度越來越慢,并且是在持續性地變慢。而且在發布完后(也就是容器重建了),耗時又陡然降下來了。
因此很合理地懷疑是內存泄漏了。故而在 STKE 的監控面板瞧一瞧,內存確實是一波一波似浪花。
1. 復現問題
知道是內存泄漏,我們就需要找到泄漏的點。因為不能輕易操作線上環境,線上代碼也是壓縮的,因此我們需要先搭建本地環境看能否方便調試問題。這里我們我們可以在本地起 Server 后,寫腳本發起請求,來模擬線上環境。(但是看過上篇文章的小伙伴都知道,我們還有個骨架屏的模式,可以跳過發起 CGI 請求的步驟,大大降低單次請求耗時,讓這個結果幾秒鐘就出來了)
我們可以使用 heapdump
包來將堆棧信息寫入本地文件。heapdump
的基本使用姿勢是這樣的:
1 2 3 |
const heapdump = require('heapdump'); heapdump.writeSnapshot('./test.heapsnapshot'); |
然后就可以將堆棧文件導入到 Chrome 開發者工具的 Memory
欄來分析。這里我選擇了分別是運行了 1 次、50 次、100 次 以及等待幾秒鐘垃圾回收后再寫個 101 次的堆棧信息??梢钥吹蕉褩N募阶冊酱?,從 35M 增大到 249M。
選擇兩個堆棧文件做比較來分析,這里有個技巧就是按內存大小排序,然后看到同一個大小的對象個數非常多,那么很有可能就是它被引用了很多次,泄漏的點就可能在那里。然后就發現了問題可能出在 console
對象上。
2. 分析問題
正常地使用 console
對象不會造成內存泄漏,因此就懷疑是否是對 console
做了什么操作。搜索了一番代碼,排除正常調用外,發現有個賦值的操作,就類似于下面這段代碼:
1 2 3 4 5 6 |
const nativeError = console.error; console.error = (...argv) => { // 省略一些操作 nativeError(...argv); }; |
這段代碼在前端開發中其實是比較常見的,比如需要在 log 中自動添加時間:
1 2 3 4 5 6 7 8 |
const nativeError = console.error; console.error = (...argv) => { nativeError(`[${(new Date()).toTimeString()}]`, ...argv); }; console.error('Test'); // [20:58:17 GMT+0800 (中國標準時間)] Test |
還有一個更常見的場景是,我們要在生產環境下屏蔽大部分的 log 輸出,但是又要保留一個 log 函數引用,用來有時候在瀏覽器終端上輸出一些關鍵信息,這時候會這么寫:
1 2 3 4 5 6 7 |
// 引用,用來有時候在需要的時候上報 const logger = console.log; // 必需用函數賦值,原有的一大堆使用 console.log('...') 的地方才不會報錯 console.log = () => {}; logger('瀏覽器終端 AlloyTeam 招聘信息'); |
但是在我們的環境下,原來客戶端的代碼是被編譯后放在 vm 里反復運行的,這會帶來什么問題呢?
這里附個代碼,感興趣的小伙伴可以跑一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
const vm = require('vm'); const heapdump = require('heapdump'); const total = 5000; const writeSnapshot = (count) => { heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`); }; const code = ` const nativeError = console.error; console.error = (...argv) => { nativeError(argv); } `; const script = new vm.Script(code); for (let i = 1; i <= total; i++) { script.runInNewContext({ console, }); console.log(`${i}/${total}`); switch (i) { case 1: case Math.floor(total * 0.5): case total: writeSnapshot(i); } } setTimeout(() => { writeSnapshot(total + 1); }, 3000); |
很小一段代碼,運行 5000 次后內存占用到了 1G 多,并且還沒有回收的跡象。
我們先來考慮在 vm 的環境下,差異點在于:
- vm 里是沒有 console 對象的,vm 里的 console 對象是宿主環境傳遞進去的,在 vm 里針對 console 的修改,也會反映在宿主環境的 console 對象上;
- 在同一段代碼多次執行的情況下,也就意味著這幾次執行環境是共享 console 對象的,而在瀏覽器環境下,刷新頁面后,代碼被多次執行,環境都是獨立的;
那么我們的問題就會出現如上圖所示:
- 在宿主環境上,
console.error
原來指向的是原生的 error 方法; - 在 vm 第一次執行的時候(假設這個過程要賦值的函數是 Func1),先是引用了
console.error
,也就是引用了原生的 error 方法,同時通過賦值操作將宿主環境上的console.error
指向了 Func1; - 在 vm 第二次執行的時候,也是先引用了
console.error
方法,但是引用到的已經是第 2 步設置的 Func1,也就是 Func2 引用了 Func1。同時它又將宿主環境上的console.error
設置成了 Func2; - 同理,Func3 引用了 Func2,并且
console.error
指向了 Func3;
所以聰明的小伙伴們發現問題沒有,這變成了一個鏈式引用。這條鏈上的對象一個都別想被回收,都被牢牢綁死了。
如果我們要解決這個問題,理想的引用模型應該是什么樣的呢?
理想的一個引用模型應該是無論 vm 代碼被執行了多少次,在我們取值和賦值操作應該做到:
- 取值操作始終取的是原生的 error 方法,因為如果取到了上次運行賦值的方法,那么就會存在引用關系;
- 賦值操作將不能操作到宿主環境的 console 對象,因為這樣將會影響到其他批次 vm 里的全局 console 對象;
- 賦值操作后的取值操作將需要取到賦值后的方法,這樣才能執行到自定義的邏輯;
這其實就要求我們不僅對 vm 的上下文做隔離,對 vm 創建的上下文所傳遞的屬于宿主環境的引用對象也要做隔離。
3. 解決問題
有什么簡單的解決辦法嗎?假設我們很清楚的認識到代碼執行環境(多次執行且共享宿主對象),那么只需要做個標志位防止多次執行就可以了:
1 2 3 4 5 6 7 8 |
const nativeError = console.error; if (!nativeError.hasBeenRewrite) { console.error = (...argv) => { nativeError(argv); }; console.error.hasBeenRewrite = true; } |
但是在原來運行于客戶端的代碼里會這么寫的,感覺要么是已經遭遇過了這個問題,要么只能說優秀,一開始就有了這個意識!
那么當我們要做一個基礎運行庫的時候,可以做到不需要業務關心這么細的問題嗎?也就是我們可能對對象隔離出上下文環境里的上下文環境嗎?有這么幾個條件是支持我們這么做的:
- 我們傳遞到 vm 里屬于宿主環境的引用對象其實很有限,因此可以對這么幾個有限的對象做隔離;
- 我們需要隔離的對象是跟隨著 vm 創建的上下文的;
那么回到我們上文提到的理想模型,這里先附上代碼,再來對整個方案做解讀:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
const vm = require('vm'); const heapdump = require('heapdump'); const total = 5000; const writeSnapshot = (count) => { heapdump.writeSnapshot(`./${count}-${total}.heapsnapshot`); }; const code = ` const nativeError = console.error; console.error = (...argv) => { nativeError(...argv); } `; const script = new vm.Script(code); const vmProxy = (context, obj, name) => { const proxyStore = {}; const proxyObj = new Proxy(obj, { get: function (target, propKey) { if (proxyStore[name] && proxyStore[name][propKey]) { return proxyStore[name][propKey]; } return target[propKey]; }, set: function (target, propKey, value) { if (!proxyStore[name]) { proxyStore[name] = {}; } const defineObj = proxyStore[name]; if ((typeof value === 'function' || typeof value === 'object') && value !== null) { defineObj[propKey] = value; } }, }); context[name] = proxyObj; context.proxyStore = proxyStore; return context; }; for (let i = 1; i <= total; i++) { const context = vmProxy({}, console, 'console'); script.runInNewContext(context); console.log(`${i}/${total}`); switch (i) { case 1: case Math.floor(total * 0.5): case total: writeSnapshot(i); } } setTimeout(() => { writeSnapshot(total + 1); }, 3000); |
這里有幾個關鍵的點:
- 用
Proxy
方法,對 console 的屬性 get 操作做攔截; - 我們將在 vm 上下文對象上設置
proxyStore
對象用來存儲 set 操作設置的值,這個proxyStore
將跟隨著上下文的回收而回收; - 對 console 的 set 操作將不會設置到 console 上而影響宿主環境的引用對象,但是又需要做存儲;
分步驟來看:
- 對
console.error
的取值操作,我們判斷 ProxyStore 里是否被當前環境設置過了,這時候沒有,那么我們給取值操作返回原生的 error 方法;
- 對
console.error
賦值 Func1 的操作,我們判斷 ProxyStore 里沒有存儲對這個屬性的賦值,那么將 Func1 存儲到 ProxyStore,這里注意我們不能將 Func1 設置到console.error
上;
- 在后續的調用
console.error
操作,又會被我們攔截 get 方法,我們判斷到 ProxyStore 里有被賦值過 Func1,這時候返回 Func1,調用console.error
就變成了調用Func1
;
通過以上的操作,我們維持了 console.error
始終指向原生 error 方法,每次的引用也都是引用的原生的 error 方法,而不是上一次設置的方法。
然后我們就解決了這個內存泄漏的問題:
4. 規避問題
用這么個聰明的方法解決了這個問題,貌似都有點欣賞自己了呢。
但是我們再來考慮 Proxy
會帶來什么問題,會有性能問題嗎?
實踐出真知,我們對比上面兩種解決方法的性能差異:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
const vm = require('vm'); const total = 10000; const vmProxy = (context, obj, name) => { const proxyStore = {}; const proxyObj = new Proxy(obj, { get: function (target, propKey) { if (proxyStore[name] && proxyStore[name][propKey]) { return proxyStore[name][propKey]; } return target[propKey]; }, set: function (target, propKey, value) { if (!proxyStore[name]) { proxyStore[name] = {}; } const defineObj = proxyStore[name]; if ((typeof value === 'function' || typeof value === 'object') && value !== null) { defineObj[propKey] = value; } }, }); context[name] = proxyObj; context.proxyStore = proxyStore; return context; }; (() => { const code = ` const nativeError = console.error; console.error = (...argv) => { nativeError(...argv); } `; const script = new vm.Script(code); console.time('proxy'); for (let i = 1; i <= total; i++) { const context = vmProxy({}, console, 'console'); script.runInNewContext(context); } console.timeEnd('proxy'); })(); (() => { let code = ` const nativeError = console.error; if (!nativeError.hasBeenRewrite) { console.error = (...argv) => { nativeError(argv); }; console.error.hasBeenRewrite = true; } `; let script = new vm.Script(code); console.time('flag'); for (let i = 1; i <= total; i++) { script.runInNewContext({ console, }); } console.timeEnd('flag'); })(); |
看起來幾乎沒什么性能差異
但是 Proxy
有個 this
指向的問題,因為 Proxy
不是個透明代理,被 Proxy
代理的對象內部的 this 指向會指向 proxy 實例,因此如果是這么個簡單例子還好,但是放到線上代理比較復雜的對象,心里還是毛毛的。(還需要考慮對象里的對象)
有沒有可能在開發階段就能發現類似的內存泄漏問題,而不是等到發布線上才發現呢?
當然是想到了辦法我才會說了,之前想這個問題的時候想了一下午,想得太復雜了,所以試了好多種方法也沒有想出來。我們先來澄清一點,這里是因為要賦值的函數里又調用了存儲的 nativeError
嗎?其實是無關的,即使你將 nativeError(...argv)
注釋掉,仍然是會存在內存泄漏的問題。
1 2 3 4 5 |
const nativeError = console.error; console.error = (...argv) => { nativeError(...argv); } |
這里的原因在于只要同一個 vm 虛擬機里對宿主環境的引用對象的同一個 key 同時做 get
和 set
操作,那么就會存在內存泄漏。我們來考慮下面這三種情況是否會存在內存泄漏:
相同的 key:
1 2 3 4 5 |
const nativeError = console.undefined; console.undefined = (...argv) => { nativeError(argv); } |
不同的 key:
1 2 3 4 5 |
const nativeError = console.undefined; console.notExist = (...argv) => { nativeError(argv); } |
設置的不是引用對象:
1 2 3 |
const nativeError = console.error; console.error = 'AlloyTeam'; |
答案是第一個會存在內存泄漏,第二和第三不會。好奇的小伙伴可以用上面的例子代碼跑一下。
我們將這個問題簡化了,再來看檢測的方案,照例先上代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
const { workerData, Worker, isMainThread } = require('worker_threads'); const vm = require('vm'); const log = console.log; const memoryCheckStore = {}; const isReferenced = value => !!(value && typeof value === 'object' || typeof value === 'function'); const vmProxy = (context, obj, name) => { const proxyObj = new Proxy(obj, { get: function (target, propKey) { const propValue = target[propKey]; if (!memoryCheckStore[obj]) { memoryCheckStore[obj] = {}; } // todo: 需要處理數組和迭代子對象 if (!memoryCheckStore[obj][propKey]) { memoryCheckStore[obj][propKey] = 1; } return propValue; }, set: function (target, propKey, value) { if (isReferenced(value) && memoryCheckStore[obj][propKey]) { log(new Error('[警告] 可能存在內存泄漏')); } target[propKey] = value; }, }); context[name] = proxyObj; return context; }; const code1 = ` const nativeError = console.undefined; // 泄漏 console.undefined = (...argv) => {} `; const code2 = ` const nativeError = console.undefined; // 不會泄漏 console.notExist = (...argv) => {} `; const code3 = ` const nativeError = console.undefined; // 不會泄漏 console.error = 'AlloyTeam'; `; const code4 = ` const nativeError = console.error; // 泄漏 console.error = (...argv) => {} `; if (isMainThread) { for (let i = 1; i <= 4; i++) { new Worker(__filename, { workerData: { code: eval(`code${i}`), flag: i, }, }); } } else { const { code, flag } = workerData; const script = new vm.Script(code, { filename: `code${flag}`, }); const context = vmProxy({}, console, 'console'); script.runInNewContext(context); } |
僅一次運行,就知道 code1、code4 可能存在內存泄漏:
方案圖解 1,get 階段:
- 一開始
console.error
指向原生的 error 方法; - 我們在全局設置個 GlobalGetStore 對象,用來記錄被引用的對象和被引用的屬性名;
- 第一次運行,攔截的 get 方法里判斷 store 里沒有這個對象,就記錄對象到 store,同時也記錄被引用的 key 值;
方案圖解 2,set 階段:
- 攔截的 set 方法里判斷了 store 里已經有存儲了被引用的對象,同時當次操作的 key 值也已經被引用過了,因此判定在 vm 這樣多次執行的環境里,可能存在內存泄漏,打印出告警信息;
這樣我們就可以在開發階段部署這樣內存檢測代碼(demo 代碼仍然需要處理數組和對象屬性是引用類型的情況),在生產環境上移除或失效。
當然了,一個較優秀的項目,上線前后仍然有兩件相關的事情可以做:
- 自動化測試,通過模擬發起多個用戶請求,檢測內存變化,上線前檢測到可能的內存泄漏;
- 設置告警策略,在內存超限時告警,查看內存變化,確認是否泄漏;
5. 后記
遇到這樣一個問題,其實還挺有趣的,雖然是一個小點,但是梳理了一個比較完整的思考過程,希望能對小伙伴們解決相關問題帶來參考和想法。
我們是在做騰訊文檔的 AlloyTeam,歡迎有技術想法的小伙伴來撩~
土錘2號 2021 年 1 月 8 日
前面 1/3 看著還是津津有味的,但看到后來,我嘴里的橘子都掉了,然后顫顫巍巍關掉了頁面