記一次Node項目的優化

這兩天針對一個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的使用
並且我我的以爲貼那麼幾張內存快照沒有任何意義(在本次優化中),不如拿出些實際的優化先後代碼對比來得實在。數組

幾個優化的地方

這裏列出了在本次優化中涉及到的地方:服務器

  1. 一些不太合理的數據結構(用的姿式有問題)
  2. 串行的異步代碼(相似callback地獄那種格式的)

數據結構相關的優化

這裏說的結構都是與Redis相關的,基本上是指部分數據過濾的實現
過濾相關的主要體如今一些列表數據接口中,由於要根據業務邏輯進行一些過濾之類的操做:網絡

  1. 過濾的參考來自於另外一份生成好的數據集
  2. 過濾的參考來自於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)了,Nlength
因此咱們能夠嘗試將上邊的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是否存在的操做時,SetArray表現是最差的,而MapObject基本持平。

關於來自Redis的過濾

關於這個的過濾,須要考慮優化的Redis數據結構通常是SetSortedSet
好比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兩種實際的場景
  1. 若是如今有一個列表數據,須要針對某些省份進行過濾掉一些數據。
    咱們能夠選擇在循環外層取出集合中全部的值,而後在循環內部直接經過內存中的對象來判斷過濾。
  2. 若是這個列表數據是要針對用戶進行黑名單過濾的,考慮到有些用戶可能會拉黑不少人,這個Set的基數就很難估,這時候就建議使用循環內判斷的方式了。

下降網絡傳輸成本

杜絕Hash的濫用

確實,使用hgetall是一件很是省心的事情,無論Redis的這個Hash裏邊有什麼,我都會獲取到。
可是,這個確實會形成一些性能上的問題。
好比,我有一個Hash,數據結構以下:

{
  name: 'Niko',
  age: 18,
  sex: 1,
  ...
}
複製代碼

如今在一個列表接口中須要用到這個hash中的nameage字段。
最省心的方法就是:

let info = {}
let results = await redisClient.hgetall('hash')

return {
  ...info,
  name: results.name,
  age: results.age
}
複製代碼

hash很小的狀況下,hgetall並不會對性能形成什麼影響,
但是當咱們的hash數量很大時,這樣的hgetall就會形成很大的影響。

  1. hgetall時間複雜度爲O(N)Nhash的大小
  2. 且不說上邊的時間複雜度,咱們實際僅用到了nameage,而其餘的值經過網絡傳輸過來實際上是一種浪費

因此咱們須要對相似的代碼進行修改:

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開始,到如今的asyncawait,在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()
  ])
}
複製代碼

這樣的作法可以讓getData1getData2的請求同時發出去,並統一處理回調結果。

最理想的狀況下,咱們將全部的異步請求一併發出,而後等待返回結果。

然而通常來說不太可能實現這樣的,就像上邊的幾個例子,咱們可能要在循環中調用sismember,亦或者咱們的一個數據集依賴於另外一個數據集的過濾。
這裏就又是一個權衡取捨的地方了,就像本次優化的一個例子,有兩份數據集,一個有固定長度的數據(個位數),第二個爲不固定長度的數據。

第一個數據集在生成數據後會進行裁剪,保證長度爲固定的個數。
第二個數據集長度則不固定,且須要根據第一個集合的元素進行過濾。

此時第一個集合的異步調用會佔用不少的時間,而若是咱們在第二個集合的數據獲取中不依據第一份數據進行過濾的話,就會形成一些無效的請求(重複的數據獲取)。
可是在對比了之後,仍是以爲將二者改成併發性價比更高。
由於上邊也提到了,第一個集合的數量大概是個位數,也就是說,第二個集合即便重複了,也不會重複不少數據,二者相比較,果斷選擇了併發。
在獲取到兩個數據集之後,在拿第一個集合去過濾第二個集合的數據。
若是二者異步執行的時間差不太多的話,這樣的優化基本能夠節省40%的時間成本(固然缺點就是數據提供方的壓力會增大一倍)。

將串行改成並行帶來的額外好處

若是串行執行屢次異步操做,任何一個操做的緩慢都會致使總體時間的拉長。
而若是選擇了並行多個異步代碼,其中的一個操做時間過長,可是它可能在整個隊列中不是最長的,因此說並不會影響到總體的時間。

後記

整體來講,本次優化在於如下幾點:

  1. 合理利用數據結構(善用hash結構來代替某些list
  2. 減小沒必要要的網絡請求(hgetall to hmget
  3. 將串行改成並行(擁抱異步事件)

以及一個新鮮的剛出爐的接口響應時長對比圖:

相關文章
相關標籤/搜索