這兩天針對一個Node項目進行了一波代碼層面的優化,從響應時間上看,是一次很顯著的提升。一個純粹給客戶端提供介面的服務,沒有涉及到頁面渲染相關。 背景 首先這個項目是一個幾年前的項目了,期間一直在新增需求,導致代碼邏輯變得也比較複雜,介面響應時長也在跟著上漲。之前有過一次針對伺服器環境方面的優化(n ...
這兩天針對一個Node項目進行了一波代碼層面的優化,從響應時間上看,是一次很顯著的提升。
一個純粹給客戶端提供介面的服務,沒有涉及到頁面渲染相關。
背景
首先這個項目是一個幾年前的項目了,期間一直在新增需求,導致代碼邏輯變得也比較複雜,介面響應時長也在跟著上漲。
之前有過一次針對伺服器環境方面的優化(node版本升級),確實性能提升不少,但是本著“青春在於作死”的理念,這次就從代碼層面再進行一次優化。
相關環境
由於是一個幾年前的項目,所以使用的是Express
+co
這樣的。
因為早年Node.js
版本為4.x
,遂非同步處理使用的是yield
+generator
這種方式進行的。
確實相對於一些更早的async.waterfall
來說,代碼可讀性已經很高了。
關於數據存儲方面,因為是一些實時性要求很高的數據,所以數據均來自Redis
。Node.js
版本由於前段時間的升級,現在為8.11.1
,這讓我們可以合理的使用一些新的語法來簡化代碼。
因為訪問量一直在上漲,一些早年沒有什麼問題的代碼在請求達到一定量級以後也會成為拖慢程式的原因之一,這次優化主要也是為了填這部分坑。
一些小提示
本次優化筆記,並不會有什麼profile
文件的展示。
我這次做優化也沒有依賴於性能分析,只是簡單的添加了介面的響應時長,彙總後進行對比得到的結果。(非同步的寫文件appendFile
了開始結束的時間戳)
依據profile
的優化可能會作為三期來進行。profile
主要會用於查找記憶體泄漏、函數調用堆棧記憶體大小之類的問題,所以本次優化沒有考慮profile
的使用
而且我個人覺得貼那麼幾張記憶體快照沒有任何意義(在本次優化中),不如拿出些實際的優化前後代碼對比來得實在。
幾個優化的地方
這裡列出了在本次優化中涉及到的地方:
- 一些不太合理的數據結構(用的姿勢有問題)
- 串列的非同步代碼(類似
callback
地獄那種格式的)
數據結構相關的優化
這裡說的結構都是與Redis
相關的,基本上是指部分數據過濾的實現
過濾相關的主要體現在一些列表數據介面中,因為要根據業務邏輯進行一些過濾之類的操作:
- 過濾的參考來自於另一份生成好的數據集
- 過濾的參考來自於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的過濾
關於這個的過濾,需要考慮優化的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兩種實際的場景
- 如果現在有一個列表數據,需要針對某些省份進行過濾掉一些數據。
我們可以選擇在迴圈外層取出集合中所有的值,然後在迴圈內部直接通過記憶體中的對象來判斷過濾。 - 如果這個列表數據是要針對用戶進行黑名單過濾的,考慮到有些用戶可能會拉黑很多人,這個
Set
的基數就很難估,這時候就建議使用迴圈內判斷的方式了。
降低網路傳輸成本
杜絕Hash的濫用
確實,使用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
tohmget
) - 將串列改為並行(擁抱非同步事件)