騰訊文檔表格內(nèi)存優(yōu)化總結(jié)
在騰訊文檔表格中,如果用戶打開表格的內(nèi)容非常多,比如有幾萬(wàn)行或者幾十萬(wàn)個(gè)單元格。內(nèi)存占用會(huì)居高不下,在使用的過(guò)程中非常容易出現(xiàn)崩潰,卡頓等問(wèn)題,在進(jìn)行一系列的優(yōu)化之后,寫下這篇文章,總結(jié)下期間用到的一些優(yōu)化方案,希望可以或多或少幫助或者啟發(fā)一下他人。
怎么找出內(nèi)存問(wèn)題
在內(nèi)存這里,我們一般是借助 Google 瀏覽器的開發(fā)者工具,然后獲取下頁(yè)面的內(nèi)存快照,然后分享具體占用過(guò)大內(nèi)存的對(duì)象。


除了查看內(nèi)存占用過(guò)大的對(duì)象之外,也可以切換到 Containment 這里查看一些占用內(nèi)存過(guò)大的全局對(duì)象。

常見(jiàn)的內(nèi)存優(yōu)化方案
按需加載
按需加載可能是能夠想到的最簡(jiǎn)單的內(nèi)存優(yōu)化的方式,比如當(dāng)某一個(gè)功能沒(méi)有使用的時(shí)候,不加載這個(gè)功能模塊,也不進(jìn)行初始化。舉個(gè)例子,比如帳號(hào)管理模塊,在沒(méi)有點(diǎn)擊右上角頭像部分的時(shí)候,是不需要加載的,這個(gè)時(shí)候就可以改成按需加載。

在騰訊文檔表格的優(yōu)化中,部分改造為按需加載的模塊使用率:
Account 組件改成按需加載 用戶使用率:0.8%
QuickFill 組件改成按需加載 用戶使用率:1.2%
優(yōu)化了這些模塊之后,內(nèi)存占用大概降低了 10M 左右,優(yōu)化效果并不明顯。老板說(shuō):


對(duì)象池
對(duì)象池模式在初始化的時(shí)候會(huì)創(chuàng)建固定數(shù)量的緩存,并且每個(gè)對(duì)象都可以復(fù)用,對(duì)象沒(méi)有更新的需求。

對(duì)象池這種模式主要用于游戲或者數(shù)據(jù)庫(kù)連接池等場(chǎng)景。因?yàn)轵v訊文檔表格優(yōu)化的時(shí)候沒(méi)有使用到對(duì)象池的方式,這里就不詳細(xì)描述了,有需要的可以自行 Google。
享元
享元模式的特點(diǎn):
屬性全部只讀或者私有化
提供創(chuàng)建對(duì)象實(shí)例的工廠方法
修改時(shí)重新創(chuàng)建對(duì)象實(shí)例
使用 key 緩存對(duì)象,key 相同直接返回緩存對(duì)象實(shí)例
舉個(gè)例子:假設(shè)目前有三個(gè)變量 ABC,引用的都是同一個(gè)內(nèi)存中的對(duì)象。

假設(shè)這個(gè)時(shí)候需要修改對(duì)象 A 的屬性,則需要使用新的屬性參數(shù),創(chuàng)建一個(gè)新的對(duì)象,講 A 指向新創(chuàng)建的對(duì)象,此時(shí)內(nèi)存當(dāng)中的對(duì)象應(yīng)該是這樣的:

這個(gè)時(shí)候,內(nèi)存當(dāng)中就有兩個(gè)對(duì)象了,BC 還是指向的之前的對(duì)象,但是這個(gè)時(shí)候的 A 已經(jīng)指向新創(chuàng)建的對(duì)象了。
下圖是在騰訊文檔表格中,部分使用享元模式優(yōu)化之后的內(nèi)存對(duì)比,可以發(fā)現(xiàn)優(yōu)化效果還是比較明顯的。

享元遇到的問(wèn)題
以為用享元就能解決問(wèn)題了?

直到看到這個(gè)圖:

用戶在操作設(shè)置數(shù)據(jù)格式之后,前端提交到數(shù)據(jù)后臺(tái),落盤失敗!

在做享元優(yōu)化的時(shí)候,遇到一個(gè)特殊的問(wèn)題,這里也記錄下,方便大家后面使用享元的時(shí)候注意。我們有一個(gè)對(duì)象,用來(lái)描述單元格的值,以及值的類型。如下所示:

type 表示類型,value 就是存儲(chǔ)的值。
type 的類型有如下幾種

這個(gè)對(duì)象的 key=type+,+value

假設(shè)此時(shí)實(shí)用如下參數(shù)創(chuàng)建一個(gè)對(duì)象:
{type: 1,value: 123,}
此時(shí)生成的 key='1,123'
很明顯,這個(gè)時(shí)候的 value 和 type 的類型對(duì)應(yīng)不上,但是由于我們沒(méi)有任何校驗(yàn),導(dǎo)致錯(cuò)誤的創(chuàng)建了一個(gè)對(duì)象,并且以 key='1,123' 被緩存起來(lái)了。
當(dāng)下次創(chuàng)建的一個(gè)對(duì)象的時(shí)候,傳入如下的參數(shù):
{type: 1,value: '123',}
這里的參數(shù),跟上面的區(qū)別是 value 和 type 是對(duì)應(yīng)上的,但是注意,這個(gè)時(shí)候生存的 key='1,123'。嗯?發(fā)現(xiàn)這個(gè) key 在之前已經(jīng)創(chuàng)建過(guò)了,并且緩存了一個(gè)對(duì)象。這個(gè)時(shí)候,不會(huì)再去創(chuàng)建一個(gè)新的對(duì)象,而是將上面的對(duì)象直接返回給到使用方,然后報(bào)錯(cuò)了。
這個(gè)情況在騰訊文檔表格中就出現(xiàn)過(guò),有的業(yè)務(wù)模塊創(chuàng)建了臨時(shí)的 ExtenedValue 對(duì)象,在傳參數(shù)的時(shí)候,類型和值對(duì)應(yīng)不上,導(dǎo)致數(shù)據(jù)層在提交數(shù)據(jù)的時(shí)候報(bào)錯(cuò)。
那如何解決這個(gè)問(wèn)題呢? 在創(chuàng)建 key 的時(shí)候,獲取到 value 的真實(shí)類型,將真實(shí)類型作為 key 的一部分,這樣,上述的兩個(gè)參數(shù)創(chuàng)建的就會(huì)是兩個(gè)不同的對(duì)象了。
進(jìn)階版內(nèi)存優(yōu)化方案
對(duì)象存儲(chǔ)優(yōu)化
一個(gè) demo

上圖中的代碼,占用內(nèi)存 4.8M。

上圖中的代碼,占用內(nèi)存 3.6M。

對(duì)比兩者的內(nèi)存快照發(fā)現(xiàn)相差的就是 1.2M。這是為什么呢?
對(duì)象在 v8 中的存儲(chǔ)方式

首先來(lái)看下對(duì)象在 v8 中的存儲(chǔ)方式,在 v8 中,js 的對(duì)象使用 JSObject 對(duì)象來(lái)描述。這個(gè)對(duì)象有下面幾個(gè)主要屬性:
HiddenClass 用來(lái)存儲(chǔ)對(duì)象屬性存儲(chǔ)的位置信息
properties 用來(lái)存儲(chǔ)具名屬性
elements 用來(lái)存儲(chǔ)有順序的屬性,類似數(shù)組 在這種情況下,存儲(chǔ)在 properties 中的屬性,都是快屬性模式(使用數(shù)組的形式存儲(chǔ))。
那針對(duì)上面的問(wèn)題,猜測(cè)可能是因?yàn)?/p>this.a = null
初始化屬性 a 的時(shí)候,HiddenClass 的結(jié)構(gòu)會(huì)變化,從而增加內(nèi)存。接著使用 v8 的調(diào)試工具 d8 來(lái)驗(yàn)證下:


從上面的 debug 信息中可以看出,初始化的時(shí)候,如果賦值為 null,會(huì)在 HiddenClass 的 DescriptorArray 中添加記錄,用來(lái)描述屬性 a 的初始值。 所以,在初始化屬性的時(shí)候,如果參數(shù)沒(méi)有傳遞該屬性的值,那就不要初始化為 null,因?yàn)檫@樣會(huì)占用內(nèi)存。
優(yōu)化驗(yàn)證
針對(duì)上述的情況,我們優(yōu)化了騰訊文檔表格的單元格對(duì)象 CellData 的初始化過(guò)程。

可以發(fā)現(xiàn),優(yōu)化的效果非常明顯。30w 單元格的表格,CellData 的內(nèi)存占用從 24M 減少到了 12M。
優(yōu)化到到這里的時(shí)候,老板再問(wèn)我能達(dá)到預(yù)期目標(biāo)的時(shí)候:

清除 delete 操作
一個(gè) demo
有 delete 操作


沒(méi)有 delete 操作


從上面的圖中可以看出,沒(méi)有 delete 操作會(huì)比有 delete 操作的內(nèi)存占用少非常多。這又是為什么呢?
v8 HiddenClass 的轉(zhuǎn)化過(guò)程
v8 在初始化對(duì)象,給對(duì)象添加屬性的時(shí)候,會(huì)變更 hiddenClass 指針的指向,將其指向最新的節(jié)點(diǎn)。先看一個(gè) v8 官方網(wǎng)站的圖:

在這個(gè)圖中,當(dāng)我們給對(duì)象 o 添加屬性的時(shí)候,hiddenClass 的指針一直在變化,也就是說(shuō)每次給對(duì)象添加新的屬性,都會(huì)生成一個(gè)新的 HiddenClass 節(jié)點(diǎn),所有的 HiddenClass 節(jié)點(diǎn)組成了一個(gè)的鏈表(其實(shí)準(zhǔn)確的說(shuō)應(yīng)該是 tree ),然后對(duì)象中的 hiddenClass 指針指向最新的節(jié)點(diǎn)。
上面說(shuō)的所有的 HiddenClass 節(jié)點(diǎn)其實(shí)組成了一棵樹,在 v8 里面叫做 transition tree。例如:假設(shè)這個(gè)時(shí)候,新創(chuàng)建一個(gè)對(duì)象,并且添加了一個(gè)新的屬性:

這樣看是不是就知道為什么是一棵樹了。
那這些跟 delete 操作有什么關(guān)系呢?
delete 操作導(dǎo)致 v8 的對(duì)象存儲(chǔ)模式退化為字典模式
當(dāng)對(duì)屬性進(jìn)行 delete 操作的時(shí)候,會(huì)將上面所說(shuō)的鏈表結(jié)構(gòu)打破,并且對(duì)象在 v8 內(nèi)的存儲(chǔ)方式也會(huì)發(fā)生變化。變成如下所示的結(jié)構(gòu):

或者直接看 v8 官方網(wǎng)站提供的圖:

可以看到,如果你對(duì)一個(gè)對(duì)象的屬性進(jìn)行 delete 操作,就會(huì)導(dǎo)致對(duì)象的存儲(chǔ)方式退化到字典模式(慢屬性模式)。相對(duì)于之前的快屬性模式,這種存儲(chǔ)方式更加消耗內(nèi)存。所以這也是為什么 delete 操作會(huì)導(dǎo)致對(duì)象內(nèi)存占用增加的根本原因。
驗(yàn)證:
const obj = {a:1,b: 2,}%DebugPrint(obj);delete obj.a;%DebugPrint(obj);

刪除屬性 a 之后,退化到字典模式:

再看一下這個(gè)圖,我們發(fā)現(xiàn)在 HiddenClass 節(jié)點(diǎn)也有一個(gè) back pointer ,指向上一個(gè)節(jié)點(diǎn)。

所以,如果你是按照對(duì)象添加屬性的反方向刪除屬性的話,對(duì)象并不會(huì)退化到慢屬性模式,或者對(duì)象的內(nèi)存占用并不會(huì)增加。這里更多詳細(xì)的描述可以參考 v8 的官方文檔或者 superzheng 大佬在知乎的專欄。
如何替換掉 delete 操作?
賦值給 undefined
假設(shè)有如下代碼:
const data = {a: '1',b: 'c',}// do something...delete data.c;
如果沒(méi)有特殊的要求,建議直接修改為:
const data = {a: '1',b: 'c',}// do something...data.c = undefined;
使用如下代碼測(cè)試:
const startTime = +new Date();for (let i = 0; i < 1000000; i++) {const a = {a: '1',b: '2',c: '3',};// a.b = undefined;delete a.b;}const endTime = +new Date();console.log(endTime - startTime);
賦值給 undefined 比直接 delete 刪除,要快幾十倍,甚至上百倍。
使用 Map 替代
假設(shè)你的對(duì)象必須就是要?jiǎng)h除,那么還可以使用 Map 來(lái)替代對(duì)象。
const data = {a: '1',b: 'c',}// do something...delete data.c;
可以修改為如下的形式:
const data = new Map<string, string>();data.set('a', '1');data.set('b', 'c');// do something...// 使用 Map 對(duì)象提供的 delete 方法刪除元素data.delete('a');
性能監(jiān)控(保護(hù)優(yōu)化成果)
內(nèi)存監(jiān)控
內(nèi)存優(yōu)化的手段固然重要,但是如果在優(yōu)化之后一段時(shí)間,隨著需求的增加,由于一些新代碼的引入,或者寫代碼時(shí)候不注意,就有可能導(dǎo)致內(nèi)存暴增。這個(gè)時(shí)候,就在想是否能夠在發(fā)布之前,就可以自動(dòng)化的檢測(cè)到騰訊文檔的特征表格的內(nèi)存占用,然后跟現(xiàn)網(wǎng)環(huán)境的內(nèi)存占用對(duì)比。如果發(fā)現(xiàn)內(nèi)存占用比現(xiàn)網(wǎng)的要高很多,則可以在發(fā)布階段終止發(fā)布,接著進(jìn)入代碼 review 階段,分析哪里導(dǎo)致的內(nèi)存占用增加。解決了問(wèn)題之后,再重新進(jìn)行發(fā)布流程。
針對(duì)這個(gè)自動(dòng)化測(cè)試,騰訊文檔這邊目前也實(shí)現(xiàn)了一個(gè)可以自動(dòng)化獲取頁(yè)面內(nèi)存快照和大小的工具,可以接入到流水線當(dāng)中,作為一個(gè)性能檢測(cè)的方式集成到發(fā)布流水線當(dāng)中,保護(hù)內(nèi)存優(yōu)化成功。
參考資料
https://flaviocopes.com/how-to-remove-object-property-javascript/ https://v8.dev/blog/fast-properties https://zhuanlan.zhihu.com/p/28872382 https://v8.dev/docs/build
內(nèi)推社群
我組建了一個(gè)氛圍特別好的騰訊內(nèi)推社群,如果你對(duì)加入騰訊感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時(shí)候隨時(shí)幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
