這兩天針對一個Node項目進行了一波代碼層面的優化,從響應時間上看,是一次很顯著的提高。
一個純粹給客戶端提供接口的服務,沒有涉及到頁面渲染相關。javascript
首先這個項目是一個幾年前的項目了,期間一直在新增需求,致使代碼邏輯變得也比較複雜,接口響應時長也在跟着上漲。
以前有過一次針對服務器環境方面的優化(node版本升級),確實性能提高很多,可是本着「青春在於做死」的理念,此次就從代碼層面再進行一次優化。java
因爲是一個幾年前的項目,因此使用的是Express
+co
這樣的。
由於早年Node.js
版本爲4.x
,遂異步處理使用的是yield
+generator
這種方式進行的。
確實相對於一些更早的async.waterfall
來講,代碼可讀性已經很高了。node
關於數據存儲方面,由於是一些實時性要求很高的數據,因此數據均來自Redis
。
Node.js
版本因爲前段時間的升級,如今爲8.11.1
,這讓咱們能夠合理的使用一些新的語法來簡化代碼。redis
由於訪問量一直在上漲,一些早年沒有什麼問題的代碼在請求達到必定量級之後也會成爲拖慢程序的緣由之一,此次優化主要也是爲了填這部分坑。編程
本次優化筆記,並不會有什麼profile
文件的展現。
我此次作優化也沒有依賴於性能分析,只是簡單的添加了接口的響應時長,彙總後進行對比獲得的結果。(異步的寫文件appendFile
了開始結束的時間戳)
依據profile
的優化可能會做爲三期來進行。
profile
主要會用於查找內存泄漏、函數調用堆棧內存大小之類的問題,因此本次優化沒有考慮profile
的使用
並且我我的以爲貼那麼幾張內存快照沒有任何意義(在本次優化中),不如拿出些實際的優化先後代碼對比來得實在。數組
這裏列出了在本次優化中涉及到的地方:服務器
callback
地獄那種格式的)這裏說的結構都是與Redis
相關的,基本上是指部分數據過濾的實現
過濾相關的主要體如今一些列表數據接口中,由於要根據業務邏輯進行一些過濾之類的操做:網絡
其實第一種數據也是經過Redis
生成的。:)數據結構
就像第一種狀況,在代碼中多是相似這樣的:併發
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
})
複製代碼
有兩個列表,要保證第一個列表中的數據不會出如今第二個列表中
固然,這個最優的解決方案必定是服務端不進行處理,由客戶端進行過濾,可是這樣就失去了靈活性,並且很難去兼容舊版本
上面的代碼在遍歷data2
中的每個元素時,都會嘗試遍歷data1
,而後再進行二者的對比。
這樣作的缺點在於,每次都會從新生成一個迭代器,且由於判斷的是id
屬性,每次都會去查找對象屬性,因此咱們對代碼進行以下優化:
// 在外層建立一個用於過濾的數組
let filterData = data1.map(item => item.id)
data2 = data2.filter(item =>
filterData.includes(item.id)
)
複製代碼
這樣咱們在遍歷data2
時只是對filterData
對象進行調用了includes
進行查找,而不是每次都去生成一個新的迭代器。
固然,其實關於這一塊仍是有能夠再優化的地方,由於咱們上邊建立的filterData
實際上是一個Array
,這是一個List
,使用includes
,能夠認爲其時間複雜度爲O(N)
了,N
爲length
。
因此咱們能夠嘗試將上邊的Array
切換爲Object
或者Map
對象。
由於後邊兩個都是屬於hash
結構的,對於這種結構的查找能夠認爲時間複雜度爲O(1)
了,有或者沒有。
let filterData = new Map()
data.forEach(item =>
filterData.set(item.id, null) // 填充null佔位,咱們並不須要它的實際值
)
data2 = data2.filter(item =>
filterData.has(item.id)
)
複製代碼
P.S. 跟同事討論過這個問題,並作了一個測試腳本實驗,證實了在針對大量數據進行判斷item是否存在的操做時,Set
和Array
表現是最差的,而Map
和Object
基本持平。
關於這個的過濾,須要考慮優化的Redis
數據結構通常是Set
、SortedSet
。
好比Set
調用sismember
來進行判斷某個item
是否存在,
或者是SortedSet
調用zscore
來判斷某個item
是否存在(是否有對應的score
值)
這裏就是須要權衡一下的地方了,若是咱們在循環中用到了上述的兩個方法。
是應該在循環外層直接獲取全部的item
,直接在內存中判斷元素是否存在
仍是在循環中依次調用Redis
進行獲取某個item
是否存在呢?
SortedSet
,建議在循環中使用zscore
進行判斷(這個時間複雜度爲O(1)
)Set
,若是已知的Set
基數基本都會大於循環的次數,建議在循環中使用sismember
進行判斷Set
基數並不大,能夠取出來放到循環外部使用(smembers
時間複雜度爲O(N)
,N
爲集合的基數) 並且,還有一點兒,網絡傳輸成本也須要包含在咱們權衡的範圍內,由於像sismbers
的返回值只是1|0
,而smembers
則會把整個集合都傳輸過來Set
的基數就很難估,這時候就建議使用循環內判斷的方式了。確實,使用hgetall
是一件很是省心的事情,無論Redis
的這個Hash
裏邊有什麼,我都會獲取到。
可是,這個確實會形成一些性能上的問題。
好比,我有一個Hash
,數據結構以下:
{
name: 'Niko',
age: 18,
sex: 1,
...
}
複製代碼
如今在一個列表接口中須要用到這個hash
中的name
和age
字段。
最省心的方法就是:
let info = {}
let results = await redisClient.hgetall('hash')
return {
...info,
name: results.name,
age: results.age
}
複製代碼
在hash
很小的狀況下,hgetall
並不會對性能形成什麼影響,
但是當咱們的hash
數量很大時,這樣的hgetall
就會形成很大的影響。
hgetall
時間複雜度爲O(N)
,N
爲hash
的大小name
和age
,而其餘的值經過網絡傳輸過來實際上是一種浪費因此咱們須要對相似的代碼進行修改:
let results = await redisClient.hgetall('hash')
// == >
let [name, age] = await redisClient.hmget('hash', 'name', 'age')
複製代碼
P.S. 若是hash
的item數量超過必定量之後會改變hash
的存儲結構,
此時使用hgetall
性能會優於hmget
,能夠簡單的理解爲,20個如下的hmget
都是沒有問題的
從co
開始,到如今的async
、await
,在Node.js
中的異步編程就變得很清晰,咱們能夠將異步函數寫成以下格式:
async function func () {
let data1 = await getData1()
let data2 = await getData2()
return data1.concat(data2)
}
await func()
複製代碼
看起來是很舒服對吧?
你舒服了程序也舒服,程序只有在getData1
獲取到返回值之後纔會去執行getData2
的請求,而後又陷入了等待回調的過程當中。
這個就是很常見的濫用異步函數的地方。將異步改成了串行,喪失了Node.js
做爲異步事件流的優點。
像這種相似的毫無相關的異步請求,一個建議:
能合併就合併,這個合併非指讓你去修改數據提供方的邏輯,而是要更好的去利用異步事件流的優點,同時註冊多個異步事件。
async function func () {
let [
data1,
data2
] = await Promise.all([
getData1(),
getData2()
])
}
複製代碼
這樣的作法可以讓getData1
與getData2
的請求同時發出去,並統一處理回調結果。
最理想的狀況下,咱們將全部的異步請求一併發出,而後等待返回結果。
然而通常來說不太可能實現這樣的,就像上邊的幾個例子,咱們可能要在循環中調用sismember
,亦或者咱們的一個數據集依賴於另外一個數據集的過濾。
這裏就又是一個權衡取捨的地方了,就像本次優化的一個例子,有兩份數據集,一個有固定長度的數據(個位數),第二個爲不固定長度的數據。
第一個數據集在生成數據後會進行裁剪,保證長度爲固定的個數。
第二個數據集長度則不固定,且須要根據第一個集合的元素進行過濾。
此時第一個集合的異步調用會佔用不少的時間,而若是咱們在第二個集合的數據獲取中不依據第一份數據進行過濾的話,就會形成一些無效的請求(重複的數據獲取)。
可是在對比了之後,仍是以爲將二者改成併發性價比更高。
由於上邊也提到了,第一個集合的數量大概是個位數,也就是說,第二個集合即便重複了,也不會重複不少數據,二者相比較,果斷選擇了併發。
在獲取到兩個數據集之後,在拿第一個集合去過濾第二個集合的數據。
若是二者異步執行的時間差不太多的話,這樣的優化基本能夠節省40%
的時間成本(固然缺點就是數據提供方的壓力會增大一倍)。
若是串行執行屢次異步操做,任何一個操做的緩慢都會致使總體時間的拉長。
而若是選擇了並行多個異步代碼,其中的一個操做時間過長,可是它可能在整個隊列中不是最長的,因此說並不會影響到總體的時間。
整體來講,本次優化在於如下幾點:
hash
結構來代替某些list
)hgetall
to hmget
)以及一個新鮮的剛出爐的接口響應時長對比圖: