摘要:相關(guān)環(huán)境由于是一個(gè)幾年前的項(xiàng)目,所以使用的是這樣的。一些小提示本次優(yōu)化筆記,并不會有什么文件的展示。將異步改為了串行,喪失了作為異步事件流的優(yōu)勢。
這兩天針對一個(gè)Node項(xiàng)目進(jìn)行了一波代碼層面的優(yōu)化,從響應(yīng)時(shí)間上看,是一次很顯著的提升。背景
一個(gè)純粹給客戶端提供接口的服務(wù),沒有涉及到頁面渲染相關(guān)。
首先這個(gè)項(xiàng)目是一個(gè)幾年前的項(xiàng)目了,期間一直在新增需求,導(dǎo)致代碼邏輯變得也比較復(fù)雜,接口響應(yīng)時(shí)長也在跟著上漲。
之前有過一次針對服務(wù)器環(huán)境方面的優(yōu)化(node版本升級),確實(shí)性能提升不少,但是本著“青春在于作死”的理念,這次就從代碼層面再進(jìn)行一次優(yōu)化。
由于是一個(gè)幾年前的項(xiàng)目,所以使用的是Express+co這樣的。
因?yàn)樵缒?b>Node.js版本為4.x,遂異步處理使用的是yield+generator這種方式進(jìn)行的。
確實(shí)相對于一些更早的async.waterfall來說,代碼可讀性已經(jīng)很高了。
關(guān)于數(shù)據(jù)存儲方面,因?yàn)槭且恍?shí)時(shí)性要求很高的數(shù)據(jù),所以數(shù)據(jù)均來自Redis。
Node.js版本由于前段時(shí)間的升級,現(xiàn)在為8.11.1,這讓我們可以合理的使用一些新的語法來簡化代碼。
因?yàn)樵L問量一直在上漲,一些早年沒有什么問題的代碼在請求達(dá)到一定量級以后也會成為拖慢程序的原因之一,這次優(yōu)化主要也是為了填這部分坑。
一些小提示本次優(yōu)化筆記,并不會有什么profile文件的展示。
我這次做優(yōu)化也沒有依賴于性能分析,只是簡單的添加了接口的響應(yīng)時(shí)長,匯總后進(jìn)行對比得到的結(jié)果。(異步的寫文件appendFile了開始結(jié)束的時(shí)間戳)
依據(jù)profile的優(yōu)化可能會作為三期來進(jìn)行。
profile主要會用于查找內(nèi)存泄漏、函數(shù)調(diào)用堆棧內(nèi)存大小之類的問題,所以本次優(yōu)化沒有考慮profile的使用
而且我個(gè)人覺得貼那么幾張內(nèi)存快照沒有任何意義(在本次優(yōu)化中),不如拿出些實(shí)際的優(yōu)化前后代碼對比來得實(shí)在。
這里列出了在本次優(yōu)化中涉及到的地方:
一些不太合理的數(shù)據(jù)結(jié)構(gòu)(用的姿勢有問題)
串行的異步代碼(類似callback地獄那種格式的)
數(shù)據(jù)結(jié)構(gòu)相關(guān)的優(yōu)化這里說的結(jié)構(gòu)都是與Redis相關(guān)的,基本上是指部分?jǐn)?shù)據(jù)過濾的實(shí)現(xiàn)
過濾相關(guān)的主要體現(xiàn)在一些列表數(shù)據(jù)接口中,因?yàn)橐鶕?jù)業(yè)務(wù)邏輯進(jìn)行一些過濾之類的操作:
過濾的參考來自于另一份生成好的數(shù)據(jù)集
過濾的參考來自于Redis
其實(shí)第一種數(shù)據(jù)也是通過Redis生成的。:)
過濾來自另一份數(shù)據(jù)源的優(yōu)化就像第一種情況,在代碼中可能是類似這樣的:
let data1 = getData1() // [{id: XXX, name: XXX}, ...] let data2 = getData2() // [{id: XXX, name: XXX}, ...] data2 = data2.filter(item => { for (let target of data1) { if (target.id === item.id) { return false } } return true })
有兩個(gè)列表,要保證第一個(gè)列表中的數(shù)據(jù)不會出現(xiàn)在第二個(gè)列表中
當(dāng)然,這個(gè)最優(yōu)的解決方案一定是服務(wù)端不進(jìn)行處理,由客戶端進(jìn)行過濾,但是這樣就失去了靈活性,而且很難去兼容舊版本
上面的代碼在遍歷data2中的每一個(gè)元素時(shí),都會嘗試遍歷data1,然后再進(jìn)行兩者的對比。
這樣做的缺點(diǎn)在于,每次都會重新生成一個(gè)迭代器,且因?yàn)榕袛嗟氖?b>id屬性,每次都會去查找對象屬性,所以我們對代碼進(jìn)行如下優(yōu)化:
// 在外層創(chuàng)建一個(gè)用于過濾的數(shù)組 let filterData = data1.map(item => item.id) data2 = data2.filter(item => filterData.includes(item.id) )
這樣我們在遍歷data2時(shí)只是對filterData對象進(jìn)行調(diào)用了includes進(jìn)行查找,而不是每次都去生成一個(gè)新的迭代器。
當(dāng)然,其實(shí)關(guān)于這一塊還是有可以再優(yōu)化的地方,因?yàn)槲覀兩线厔?chuàng)建的filterData其實(shí)是一個(gè)Array,這是一個(gè)List,使用includes,可以認(rèn)為其時(shí)間復(fù)雜度為O(N)了,N為length。
所以我們可以嘗試將上邊的Array切換為Object或者Map對象。
因?yàn)楹筮厓蓚€(gè)都是屬于hash結(jié)構(gòu)的,對于這種結(jié)構(gòu)的查找可以認(rèn)為時(shí)間復(fù)雜度為O(1)了,有或者沒有。
let filterData = new Map() data.forEach(item => filterData.set(item.id, null) // 填充null占位,我們并不需要它的實(shí)際值 ) data2 = data2.filter(item => filterData.has(item.id) )
P.S. 跟同事討論過這個(gè)問題,并做了一個(gè)測試腳本實(shí)驗(yàn),證明了在針對大量數(shù)據(jù)進(jìn)行判斷item是否存在的操作時(shí),Set和Array表現(xiàn)是最差的,而Map和Object基本持平。
關(guān)于來自Redis的過濾關(guān)于這個(gè)的過濾,需要考慮優(yōu)化的Redis數(shù)據(jù)結(jié)構(gòu)一般是Set、SortedSet。
比如Set調(diào)用sismember來進(jìn)行判斷某個(gè)item是否存在,
或者是SortedSet調(diào)用zscore來判斷某個(gè)item是否存在(是否有對應(yīng)的score值)
這里就是需要權(quán)衡一下的地方了,如果我們在循環(huán)中用到了上述的兩個(gè)方法。
是應(yīng)該在循環(huán)外層直接獲取所有的item,直接在內(nèi)存中判斷元素是否存在
還是在循環(huán)中依次調(diào)用Redis進(jìn)行獲取某個(gè)item是否存在呢?
如果是SortedSet,建議在循環(huán)中使用zscore進(jìn)行判斷(這個(gè)時(shí)間復(fù)雜度為O(1))
如果是Set,如果已知的Set基數(shù)基本都會大于循環(huán)的次數(shù),建議在循環(huán)中使用sismember進(jìn)行判斷
如果代碼會循環(huán)很多次,而Set基數(shù)并不大,可以取出來放到循環(huán)外部使用(smembers時(shí)間復(fù)雜度為O(N),N為集合的基數(shù))
而且,還有一點(diǎn)兒,網(wǎng)絡(luò)傳輸成本也需要包含在我們權(quán)衡的范圍內(nèi),因?yàn)橄?b>sismbers的返回值只是1|0,而smembers則會把整個(gè)集合都傳輸過來
如果現(xiàn)在有一個(gè)列表數(shù)據(jù),需要針對某些省份進(jìn)行過濾掉一些數(shù)據(jù)。我們可以選擇在循環(huán)外層取出集合中所有的值,然后在循環(huán)內(nèi)部直接通過內(nèi)存中的對象來判斷過濾。
如果這個(gè)列表數(shù)據(jù)是要針對用戶進(jìn)行黑名單過濾的,考慮到有些用戶可能會拉黑很多人,這個(gè)Set的基數(shù)就很難估,這時(shí)候就建議使用循環(huán)內(nèi)判斷的方式了。
降低網(wǎng)絡(luò)傳輸成本 杜絕Hash的濫用確實(shí),使用hgetall是一件非常省心的事情,不管Redis的這個(gè)Hash里邊有什么,我都會獲取到。
但是,這個(gè)確實(shí)會造成一些性能上的問題。
比如,我有一個(gè)Hash,數(shù)據(jù)結(jié)構(gòu)如下:
{ name: "Niko", age: 18, sex: 1, ... }
現(xiàn)在在一個(gè)列表接口中需要用到這個(gè)hash中的name和age字段。
最省心的方法就是:
let info = {} let results = await redisClient.hgetall("hash") return { ...info, name: results.name, age: results.age }
在hash很小的情況下,hgetall并不會對性能造成什么影響,
可是當(dāng)我們的hash數(shù)量很大時(shí),這樣的hgetall就會造成很大的影響。
hgetall時(shí)間復(fù)雜度為O(N),N為hash的大小
且不說上邊的時(shí)間復(fù)雜度,我們實(shí)際僅用到了name和age,而其他的值通過網(wǎng)絡(luò)傳輸過來其實(shí)是一種浪費(fèi)
所以我們需要對類似的代碼進(jìn)行修改:
let results = await redisClient.hgetall("hash") // == > let [name, age] = await redisClient.hmget("hash", "name", "age")
**P.S. 如果hash的item數(shù)量超過一定量以后會改變hash的存儲結(jié)構(gòu),
此時(shí)使用hgetall性能會優(yōu)于hmget,可以簡單的理解為,20個(gè)以下的hmget都是沒有問題的**
從co開始,到現(xiàn)在的async、await,在Node.js中的異步編程就變得很清晰,我們可以將異步函數(shù)寫成如下格式:
async function func () { let data1 = await getData1() let data2 = await getData2() return data1.concat(data2) } await func()
看起來是很舒服對吧?
你舒服了程序也舒服,程序只有在getData1獲取到返回值以后才會去執(zhí)行getData2的請求,然后又陷入了等待回調(diào)的過程中。
這個(gè)就是很常見的濫用異步函數(shù)的地方。將異步改為了串行,喪失了Node.js作為異步事件流的優(yōu)勢。
像這種類似的毫無相關(guān)的異步請求,一個(gè)建議:
能合并就合并,這個(gè)合并不是指讓你去修改數(shù)據(jù)提供方的邏輯,而是要更好的去利用異步事件流的優(yōu)勢,同時(shí)注冊多個(gè)異步事件。
async function func () { let [ data1, data2 ] = await Promise.all([ getData1(), getData2() ]) }
這樣的做法能夠讓getData1與getData2的請求同時(shí)發(fā)出去,并統(tǒng)一處理回調(diào)結(jié)果。
最理想的情況下,我們將所有的異步請求一并發(fā)出,然后等待返回結(jié)果。
然而一般來講不太可能實(shí)現(xiàn)這樣的,就像上邊的幾個(gè)例子,我們可能要在循環(huán)中調(diào)用sismember,亦或者我們的一個(gè)數(shù)據(jù)集依賴于另一個(gè)數(shù)據(jù)集的過濾。
這里就又是一個(gè)權(quán)衡取舍的地方了,就像本次優(yōu)化的一個(gè)例子,有兩份數(shù)據(jù)集,一個(gè)有固定長度的數(shù)據(jù)(個(gè)位數(shù)),第二個(gè)為不固定長度的數(shù)據(jù)。
第一個(gè)數(shù)據(jù)集在生成數(shù)據(jù)后會進(jìn)行裁剪,保證長度為固定的個(gè)數(shù)。
第二個(gè)數(shù)據(jù)集長度則不固定,且需要根據(jù)第一個(gè)集合的元素進(jìn)行過濾。
此時(shí)第一個(gè)集合的異步調(diào)用會占用很多的時(shí)間,而如果我們在第二個(gè)集合的數(shù)據(jù)獲取中不依據(jù)第一份數(shù)據(jù)進(jìn)行過濾的話,就會造成一些無效的請求(重復(fù)的數(shù)據(jù)獲?。?。
但是在對比了以后,還是覺得將兩者改為并發(fā)性價(jià)比更高。
因?yàn)樯线呉蔡岬搅耍谝粋€(gè)集合的數(shù)量大概是個(gè)位數(shù),也就是說,第二個(gè)集合即使重復(fù)了,也不會重復(fù)很多數(shù)據(jù),兩者相比較,果斷選擇了并發(fā)。
在獲取到兩個(gè)數(shù)據(jù)集以后,在拿第一個(gè)集合去過濾第二個(gè)集合的數(shù)據(jù)。
如果兩者異步執(zhí)行的時(shí)間差不太多的話,這樣的優(yōu)化基本可以節(jié)省40%的時(shí)間成本(當(dāng)然缺點(diǎn)就是數(shù)據(jù)提供方的壓力會增大一倍)。
如果串行執(zhí)行多次異步操作,任何一個(gè)操作的緩慢都會導(dǎo)致整體時(shí)間的拉長。
而如果選擇了并行多個(gè)異步代碼,其中的一個(gè)操作時(shí)間過長,但是它可能在整個(gè)隊(duì)列中不是最長的,所以說并不會影響到整體的時(shí)間。
總體來說,本次優(yōu)化在于以下幾點(diǎn):
合理利用數(shù)據(jù)結(jié)構(gòu)(善用hash結(jié)構(gòu)來代替某些list)
減少不必要的網(wǎng)絡(luò)請求(hgetall to hmget)
將串行改為并行(擁抱異步事件)
以及一個(gè)新鮮的剛出爐的接口響應(yīng)時(shí)長對比圖:
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/108081.html
摘要:手頭做的項(xiàng)目開發(fā)得差不多了,而打包配置是一開始粗略配置的,不大的項(xiàng)目打包出來得,所以現(xiàn)在必須進(jìn)行優(yōu)化。用于生產(chǎn)環(huán)境的打包,設(shè)置其為后,這些庫會提供最小體積的文件。這種情況打包后的體積要更小一些。最后打包結(jié)果的體積開銷主要就是以上幾項(xiàng)。 手頭做的項(xiàng)目開發(fā)得差不多了,而打包配置是一開始粗略配置的,不大的項(xiàng)目打包出來得6MB+,所以現(xiàn)在必須進(jìn)行優(yōu)化。 打包結(jié)果分析 執(zhí)行命令 webpack ...
摘要:寫在最前本次分享一下在作者上一次失利即拿到畢業(yè)證第二天突然收到阿里社招面試通知失敗之后,通過分析自己的定位與實(shí)際情況,做出的未來一到兩年的規(guī)劃。在博客有一定曝光度的積累中,陸續(xù)收到了一些面試邀請,基本上是阿里的但是我知道我菜。。 寫在最前 本次分享一下在作者上一次失利即拿到畢業(yè)證第二天突然收到阿里社招面試通知失敗之后,通過分析自己的定位與實(shí)際情況,做出的未來一到兩年的規(guī)劃。以及本次社招...
摘要:同時(shí)也要引入對應(yīng)版本的先引入引入組件庫因?yàn)橐蕾囀菑耐獠恳氲模孕枰嬷诖虬鼤r(shí),依賴的來源。然后在中加入一條命令執(zhí)行或者即可完成打包。因此將此次優(yōu)化記錄下來,并傳上了中。 本文原文 前言 公司有好幾個(gè)項(xiàng)目都有后臺管理系統(tǒng),為了方便開發(fā),所以選擇了 vue 中比較火的 后臺模板 作為基礎(chǔ)模板進(jìn)行開發(fā)。但是,開始用的時(shí)候,作者并沒有對此進(jìn)行優(yōu)化,到項(xiàng)目上線的時(shí)候,才發(fā)現(xiàn),打包出來的文件...
閱讀 982·2023-04-26 02:56
閱讀 9576·2021-11-23 09:51
閱讀 1887·2021-09-26 10:14
閱讀 2989·2019-08-29 13:09
閱讀 2161·2019-08-26 13:29
閱讀 578·2019-08-26 12:02
閱讀 3573·2019-08-26 10:42
閱讀 3011·2019-08-23 18:18