我對(duì)GitHub 8.3k Star項(xiàng)目貢獻(xiàn)了一次5倍性能提升的PR!
共 7237字,需瀏覽 15分鐘
·
2024-05-08 10:10
??目錄
1 qs 庫簡(jiǎn)介
2 優(yōu)化過程
3 開源貢獻(xiàn)
4 總結(jié)
本次調(diào)優(yōu)方案在他發(fā)起 pull request 后,僅耗時(shí) 34 小時(shí)便被開源庫作者合入主線并發(fā)布新版本,成為截至目前唯一的性能優(yōu)化更新。他是怎么做到的,一起來看看吧!
01
qs 是 JavaScript 領(lǐng)域最流行的解析和序列化 URL 查詢字符串開源庫。
GitHub 上依賴 qs 的代碼庫超過2760萬,npm 上每周下載量超過7000萬。
作者 Jordan Harband 自2014年以來一直是 TC39(JavaScript 標(biāo)準(zhǔn)委員會(huì))成員,并在18-21年擔(dān)任編輯。
02
實(shí)際業(yè)務(wù)場(chǎng)景中使用包含了 30M+ 的中文文本的數(shù)據(jù)在 Windows 上 node 進(jìn)程出現(xiàn)了 crash 的現(xiàn)象。
好在該問題有相關(guān)的測(cè)試數(shù)據(jù)可以在特定環(huán)境下穩(wěn)定復(fù)現(xiàn),debug 發(fā)現(xiàn)導(dǎo)致 crash 的原因是 JavaScript heap out of memory。
解決 OOM 問題的主要挑戰(zhàn)在于定位內(nèi)存泄漏的源頭。在龐大且復(fù)雜的項(xiàng)目中,追蹤內(nèi)存泄漏的線索尤其困難,但是一旦準(zhǔn)確地定位到位置,通常該問題就解決了99%。
基于以往處理 Node 內(nèi)存泄漏的經(jīng)驗(yàn),問題往往出現(xiàn)在某些變量的生命周期管理不當(dāng),導(dǎo)致它們持續(xù)占用了大量?jī)?nèi)存。因此,我最初嘗試使用 Chrome 的開發(fā)者工具來對(duì) Node.js 進(jìn)程進(jìn)行內(nèi)存快照分析,以便發(fā)現(xiàn)是否有堆棧占用了異常的內(nèi)存量。遺憾的是,這次嘗試并未成功,未能發(fā)現(xiàn)任何某些變量?jī)?nèi)存占用特別大的情況。
既然能穩(wěn)定復(fù)現(xiàn)該 OOM 的環(huán)境,那么就直接在相關(guān)業(yè)務(wù)流程的關(guān)鍵點(diǎn)打內(nèi)存變化日志,最終通過日志逐步排查定位到內(nèi)存激增的地方是 qs.stringify 附近。
qs 庫已經(jīng)持續(xù)十多年更新上百版本,所以一開始是不太敢確認(rèn)一定是 qs 庫處理大數(shù)據(jù)性能有問題,還是項(xiàng)目復(fù)雜的環(huán)境干擾到了。那就單獨(dú)起一個(gè)獨(dú)立干凈的測(cè)試 Demo,引入與項(xiàng)目相同的 qs 庫,構(gòu)造同級(jí)數(shù)據(jù)量測(cè)試內(nèi)存和耗時(shí),發(fā)現(xiàn)確實(shí)是 qs 的問題。
看到項(xiàng)目中 qs 不是最新版本,那么就升級(jí)下 qs 看看性能問題是否已經(jīng)優(yōu)化了,可惜最新版本測(cè)試效果一樣。既然這樣,那就自己動(dòng)手豐衣足食,去尋找 qs 性能瓶頸并嘗試優(yōu)化。
直接下載 qs 庫源代碼進(jìn)行 debug,在關(guān)鍵路徑上輸出耗時(shí)和內(nèi)存。
最終發(fā)現(xiàn) encode 函數(shù)性能不佳,encode 是 qs 庫核心功能的底層函數(shù)。
把這個(gè)函數(shù)單獨(dú)拿出來簡(jiǎn)化后使用 30M 中文測(cè)試耗時(shí)8092ms,內(nèi)存2.369G。
以下代碼是encode函數(shù)的性能不佳的部分,分析可知:
當(dāng) string 的長(zhǎng)度為 30M,那么 for 循環(huán)需要遍歷30 ?1024 ?1024,超過3000萬次。
JavaScript 中,字符串是不可變的,每次字符串拼接操作都會(huì)創(chuàng)建一個(gè)新的字符串,這會(huì)導(dǎo)致大量的內(nèi)存分配和垃圾回收,從而增加內(nèi)存占用和處理時(shí)間。
1、通過以上分析,優(yōu)化一個(gè)方向在于通過減少字符串拼接的次數(shù)而減少臨時(shí)變量的產(chǎn)生以降低內(nèi)存的消耗。
2、如何減少字符串的拼接呢?考慮換一種數(shù)據(jù)結(jié)構(gòu)來存放這些被 encode 后的字符,最終再把這些字符一次性轉(zhuǎn)成字符串。
3、首先嘗試把 encode 后的字符放在一個(gè)數(shù)組中,這樣就不會(huì)產(chǎn)生臨時(shí)的字符串變量了,等 string 的每個(gè)字符都處理完成,再把數(shù)組轉(zhuǎn)成最終結(jié)果的字符串。
// 簡(jiǎn)化代碼示意var out = [];out.push(c);out.join('');
然而,經(jīng)過測(cè)試發(fā)現(xiàn)該方法:
字符放入數(shù)組,耗時(shí)5168ms,內(nèi)存1.928G。
數(shù)組轉(zhuǎn)字符串,總耗時(shí)7040ms,內(nèi)存3.535G。
耗時(shí)略降,內(nèi)存暴增,負(fù)優(yōu)化!初步探索失敗告終!
4、直接把字符串改數(shù)組來存放臨時(shí)變量,雖然失敗了,但是會(huì)發(fā)現(xiàn),改成數(shù)組存放,耗時(shí)和內(nèi)存確實(shí)有所減少,只是大數(shù)組轉(zhuǎn)字符串這一步又大幅消耗了內(nèi)存。
5、那么是不是可以嘗試分片:限制一定數(shù)量的字符放入到數(shù)組中,然后把數(shù)組轉(zhuǎn)成字符串,再把這些片段字符串拼接成最終的結(jié)果,這樣可以減少字符串拼接過程產(chǎn)生的臨時(shí)變量,也會(huì)控制數(shù)組的大小和生命周期,避免內(nèi)存占用過高。
1、首先把string進(jìn)行分片,每片 string 遍歷進(jìn)行 encode,encode 后的字符放入到 array 中存儲(chǔ),當(dāng)一片 string encode 完成后,把 array 轉(zhuǎn)字符串拼接到最終結(jié)果中去,這樣這個(gè)臨時(shí)存儲(chǔ)的 array 就可以及時(shí)釋放掉。
// 簡(jiǎn)化代碼示意var limit = 1024;var out = ''for (var i = 0; i < string.length; i += limit) {var segment = string.slice(i, i + limit);var arr = [];for (var j = 0; j < segment.length; j++) {var c = segment.charCodeAt(j);arr.push(c)}out += arr.join('');}
2、根據(jù)以上方案進(jìn)行多項(xiàng)測(cè)試,最終對(duì)比之后發(fā)現(xiàn)分片大小為1024時(shí)性能最好。同樣 30M 的數(shù)據(jù)測(cè)試,耗時(shí)1701ms,內(nèi)存459M,性能提升約5倍!
3、為什么分片是1024呢?qs 的作者也問了這個(gè)問題。如果分片太小,那么字符串拼接的次數(shù)還是很多,效果不明顯。如果分片太大,臨時(shí)數(shù)組本身占用內(nèi)存不能及時(shí)釋放掉,并且大的數(shù)組轉(zhuǎn)字符串性能也不佳。1024是考慮到減少字符串拼接次數(shù)和能讓臨時(shí)數(shù)組及時(shí)釋放掉之間的平衡,綜合測(cè)試得到的最好結(jié)果。
03
本以為給開源庫提交代碼到進(jìn)入正式的版本會(huì)經(jīng)過較長(zhǎng)周期,但是本次貢獻(xiàn)在和作者15個(gè)小時(shí)時(shí)差的情況下,從 GitHub 上發(fā)起 pull request 到 npm 新版本發(fā)布全程僅34小時(shí)!尤其是代碼合入主線后一小時(shí)內(nèi)就發(fā)布了新版本!
04.11 20:53 提交 pull request。
04.11 22:49 作者第一次 review;
review & fix 耗時(shí)15小時(shí)。
04.12 13:39 作者 approved;
自動(dòng)化測(cè)試耗時(shí)13小時(shí),approved 后需要321項(xiàng)測(cè)試 checks passed 才能合入主線。
04.13 05:36 代碼合入主線。
04.13 06:24 npm 上 qs 新版發(fā)布。
看 qs 歷史發(fā)布記錄一個(gè)新版需要幾個(gè)月時(shí)間,如果是這樣那在業(yè)務(wù)中還需要自己先單獨(dú)維護(hù)一個(gè)包非常麻煩,好在作者的支持非常及時(shí)高效。
【前無古人】截至目前唯一的性能優(yōu)化更新 :
成為 qs 庫 GitHub 上的 contributors 之一。
通過變更日志 CHANGELOG.md 文件查詢可知截止目前是 qs 庫唯一的性能優(yōu)化更新。
【數(shù)據(jù)對(duì)比】qs 庫處理大數(shù)據(jù)性能提升約5倍:
qs 庫版本升級(jí)前后 30M 中文測(cè)試耗時(shí)和內(nèi)存對(duì)比。
// 測(cè)試腳本const qs = require('qs');const string = '好'.repeat(30 * 1024 * 1204);const start = Date.now();qs.stringify({ string });console.log(`cost: ${ Date.now() - start }ms, ${JSON.stringify(process.memoryUsage())}`);// 6.12.0版本測(cè)試結(jié)果cost: 7855ms, {"rss":2544050176,"heapTotal":2508939264,"heapUsed":2447279864,"external":231929,"arrayBuffers":18614}// 6.12.1版本測(cè)試結(jié)果cost: 2090ms, {"rss":482816000,"heapTotal":461070336,"heapUsed":421214168,"external":231929,"arrayBuffers":18614}
04
本次性能優(yōu)化雖然修改量不大范圍可控,但是收獲頗豐,性能提升約5倍
qs 庫在十多年的歷史中已持續(xù)不斷更新了數(shù)百個(gè)版本,在社區(qū)有著廣泛的影響力,但直到今天依然可以從實(shí)際業(yè)務(wù)中出發(fā),發(fā)現(xiàn)性能瓶頸并優(yōu)化改進(jìn)。日常開發(fā)中如果發(fā)現(xiàn)開源庫有哪些有待改進(jìn)的地方,可以積極參與,不僅解決實(shí)際業(yè)務(wù)問題還可以反哺開源社區(qū)。
本次優(yōu)化和版本發(fā)布與作者溝通過程中非常高效,并得到其積極支持,review 時(shí)其給出改進(jìn)意見,深受啟發(fā)受益匪淺。
往期推薦
最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...
