你不知道的Node.js效能優化,讀了之後水平直線上升
本文由雲+社群發表
“當我第一次知道要這篇文章的時候,其實我是拒絕的,因為我覺得,你不能叫我寫馬上就寫,我要有乾貨才行,寫一些老生常談的然後加上好多特技,那個 Node.js 效能啊好像 Duang~ 的一下就上去了,那讀者一定會罵我,Node.js 根本沒有這樣搞效能優化的,都是假的。” ------ 斯塔克·成龍·王
1、使用最新版本的 Node.js
僅僅是簡單的升級 Node.js 版本就可以輕鬆地獲得性能提升,因為幾乎任何新版本的 Node.js 都會比老版本效能更好,為什麼?
Node.js 每個版本的效能提升主要來自於兩個方面:
- V8 的版本更新;
- Node.js 內部程式碼的更新優化。
例如最新的 V8 7.1 中,就優化了某些情形下閉包的逃逸分析,讓 Array 的一些方法得到了效能提升:
Node.js 的內部程式碼,隨著版本的升級,也會有明顯的優化,比如下面這個圖就是 require
的效能隨著 Node.js 版本升級的變化:
每個提交到 Node.js 的 PR 都會在 review 的時候考慮會不會對當前效能造成衰退。同時也有專門的 benchmarking 團隊來監控效能變化,你可以在這裡看到 Node.js 的每個版本的效能變化:
https://benchmarking.nodejs.org/
所以,你可以完全對新版本 Node.js 的效能放心,如果發現了任何在新版本下的效能衰退,歡迎提交一個 issue。
如何選擇 Node.js 的版本?
這裡就要科普一下 Node.js 的版本策略:
- Node.js 的版本主要分為 Current 和 LTS;
- Current 就是當前最新的、依然處於開發中的 Node.js 版本;
- LTS 就是穩定的、會長期維護的版本;
- Node.js 每六個月(每年的四月和十月)會發布一次大版本升級,大版本會帶來一些不相容的升級;
- 每年四月釋出的版本(版本號為偶數,如 v10)是 LTS 版本,即長期支援的版本,社群會從釋出當年的十月開始,繼續維護 18 + 12 個月(Active LTS + Maintaince LTS);
- 每年十月釋出的版本(版本號為奇數,例如現在的 v11)只有 8 個月的維護期。
舉個例子,現在(2018年11月),Node.js Current 的版本是 v11,LTS 版本是 v10 和 v8。更老的 v6 處於 Maintenace LTS,從明年四月起就不再維護了。去年十月釋出的 v9 版本在今年六月結束了維護。
對於生產環境而言,Node.js 官方推薦使用最新的 LTS 版本,現在是 v10.13.0。
2、使用 fast-json-stringify 加速 JSON 序列化
在 JavaScript 中,生成 JSON 字串是非常方便的:
const json = JSON.stringify(obj)
但很少人會想到這裡竟然也存在效能優化的空間,那就是使用 JSON Schema 來加速序列化。
在 JSON 序列化時,我們需要識別大量的欄位型別,比如對於 string 型別,我們就需要在兩邊加上 "
,對於陣列型別,我們需要遍歷陣列,把每個物件序列化後,用 ,
隔開,然後在兩邊加上 [
和 ]
,諸如此類等等。
但如果已經提前通過 Schema 知道每個欄位的型別,那麼就不需要遍歷、識別字段型別,而可以直接用序列化對應的欄位,這就大大減少了計算開銷,這就是 fast-json-stringfy 的原理。
根據專案中的跑分,在某些情況下甚至可以比 JSON.stringify
快接近 10 倍!
一個簡單的示例:
const fastJson = require('fast-json-stringify')
const stringify = fastJson({
title: 'Example Schema',
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
books: {
type: 'array',
items: {
type: 'string',
uniqueItems: true
}
}
}
})
console.log(stringify({
name: 'Starkwang',
age: 23,
books: ['C++ Primier', '響け!ユーフォニアム~']
}))
//=> {"name":"Starkwang","age":23,"books":["C++ Primier","響け!ユーフォニアム~"]}
在 Node.js 的中介軟體業務中,通常會有很多資料使用 JSON 進行,並且這些 JSON 的結構是非常相似的(如果你使用了 TypeScript,更是這樣),這種場景就非常適合使用 JSON Schema 來優化。
3、提升 Promise 的效能
Promise 是解決回撥巢狀地獄的靈丹妙藥,特別是當自從 async/await 全面普及之後,它們的組合無疑成為了 JavaScript 非同步程式設計的終極解決方案,現在大量的專案都已經開始使用這種模式。
但是優雅的語法後面也隱藏著效能損耗,我們可以使用 github 上一個已有的跑分專案進行測試,以下是測試結果:
file time(ms) memory(MB)
callbacks-baseline.js 380 70.83
promises-bluebird.js 554 97.23
promises-bluebird-generator.js 585 97.05
async-bluebird.js 593 105.43
promises-es2015-util.promisify.js 1203 219.04
promises-es2015-native.js 1257 227.03
async-es2017-native.js 1312 231.08
async-es2017-util.promisify.js 1550 228.74
Platform info:
Darwin 18.0.0 x64
Node.JS 11.1.0
V8 7.0.276.32-node.7
Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz × 4
我們可以從結果中看到,原生 async/await + Promise 的效能比 callback 要差很多,並且記憶體佔用也高得多。對於大量非同步邏輯的中介軟體專案而言,這裡的效能開銷還是不能忽視的。
通過對比可以發現,效能損耗主要來自於 Promise 物件自身的實現,V8 原生實現的 Promise 比 bluebird 這樣第三方實現的 Promise 庫要慢很多。而 async/await 語法並不會帶來太多的效能損失。
所以對於大量非同步邏輯、輕量計算的中介軟體專案而言,可以在程式碼中把全域性的 Promise 換為 bluebird 的實現:
global.Promise = require('bluebird');
4、正確地編寫非同步程式碼
使用 async/await 之後,專案的非同步程式碼會非常好看:
const foo = await doSomethingAsync();
const bar = await doSomethingElseAsync();
但因此,有時我們也會忘記使用 Promise 給我們帶來的其它能力,比如 Promise.all()
的並行能力:
// bad
async function getUserInfo(id) {
const profile = await getUserProfile(id);
const repo = await getUserRepo(id)
return { profile, repo }
}
// good
async function getUserInfo(id) {
const [profile, repo] = await Promise.all([
getUserProfile(id),
getUserRepo(id)
])
return { profile, repo }
}
還有比如 Promise.any()
(此方法不在ES6 Promise標準中,也可以使用標準的 Promise.race()
代替),我們可以用它輕鬆實現更加可靠快速的呼叫:
async function getServiceIP(name) {
// 從 DNS 和 ZooKeeper 獲取服務 IP,哪個先成功返回用哪個
// 與 Promise.race 不同的是,這裡只有當兩個呼叫都 reject 時,才會丟擲錯誤
return await Promise.any([
getIPFromDNS(name),
getIPFromZooKeeper(name)
])
}
5、優化 V8 GC
關於 V8 的垃圾回收機制,已經有很多類似的文章了,這裡就不再重複介紹。推薦兩篇文章:
我們在日常開發程式碼的時候,比較容易踩到下面幾個坑:
坑一:使用大物件作為快取,導致老生代(Old Space)的垃圾回收變慢
示例:
const cache = {}
async function getUserInfo(id) {
if (!cache[id]) {
cache[id] = await getUserInfoFromDatabase(id)
}
return cache[id]
}
這裡我們使用了一個變數 cache
作為快取,加速使用者資訊的查詢,進行了很多次查詢後,cache
物件會進入老生代,並且會變得無比龐大,而老生代是使用三色標記 + DFS 的方式進行 GC 的,一個大物件會直接導致 GC 花費的時間增長(而且也有記憶體洩漏的風險)。
解決方法就是:
- 使用 Redis 這樣的外部快取,實際上像 Redis 這樣的記憶體型資料庫非常適合這種場景;
- 限制本地快取物件的大小,比如使用 FIFO、TTL 之類的機制來清理物件中的快取。
坑二:新生代空間不足,導致頻繁 GC
這個坑會比較隱蔽。
Node.js 預設給新生代分配的記憶體是 64MB(64位的機器,後同),但因為新生代 GC 使用的是 Scavenge 演算法,所以實際能使用的記憶體只有一半,即 32MB。
當業務程式碼頻繁地產生大量的小物件時,這個空間很容易就會被佔滿,從而觸發 GC。雖然新生代的 GC 比老生代要快得多,但頻繁的 GC 依然會很大地影響效能。極端的情況下,GC 甚至可以佔用全部計算時間的 30% 左右。
解決方法就是,在啟動 Node.js 時,修改新生代的記憶體上限,減少 GC 的次數:
node --max-semi-space-size=128 app.js
當然有人肯定會問,新生代的記憶體是不是越大越好呢?
隨著記憶體的增大,GC 的次數減少,但每次 GC 所需要的時間也會增加,所以並不是越大越好,具體數值需要對業務進行壓測 profile 才能確定分配多少新生代記憶體最好。
但一般根據經驗而言,分配 64MB 或者 128MB 是比較合理的。
6、正確地使用 Stream
Stream 是 Node.js 最基本的概念之一,Node.js 內部的大部分與 IO 相關的模組,比如 http、net、fs、repl,都是建立在各種 Stream 之上的。
下面這個經典的例子應該大部分人都知道,對於大檔案,我們不需要把它完全讀入記憶體,而是使用 Stream 流式地把它傳送出去:
const http = require('http');
const fs = require('fs');
// bad
http.createServer(function (req, res) {
fs.readFile(__dirname + '/data.txt', function (err, data) {
res.end(data);
});
});
// good
http.createServer(function (req, res) {
const stream = fs.createReadStream(__dirname + '/data.txt');
stream.pipe(res);
});
在業務程式碼中合理地使用 Stream 能很大程度地提升效能,當然是但實際的業務中我們很可能會忽略這一點,比如採用 React 伺服器端渲染的專案,我們就可以用 renderToNodeStream
:
const ReactDOMServer require('react-dom/server')
const http = require('http')
const fs = require('fs')
const app = require('./app')
// bad
const server = http.createServer((req, res) => {
const body = ReactDOMServer.renderToString(app)
res.end(body)
});
// good
const server = http.createServer(function (req, res) {
const stream = ReactDOMServer.renderToNodeStream(app)
stream.pipe(res)
})
server.listen(8000)
使用 pipeline 管理 stream
在過去的 Node.js 中,處理 stream 是非常麻煩的,舉個例子:
source.pipe(a).pipe(b).pipe(c).pipe(dest)
一旦其中 source、a、b、c、dest 中,有任何一個 stream 出錯或者關閉,會導致整個管道停止,此時我們需要手工銷燬所有的 stream,在程式碼層面這是非常麻煩的。
所以社群出現了 pump 這樣的庫來自動控制 stream 的銷燬。而 Node.js v10.0 加入了一個新的特性:stream.pipeline,可以替代 pump 幫助我們更好的管理 stream。
一個官方的例子:
const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');
pipeline(
fs.createReadStream('archive.tar'),
zlib.createGzip(),
fs.createWriteStream('archive.tar.gz'),
(err) => {
if (err) {
console.error('Pipeline failed', err);
} else {
console.log('Pipeline succeeded');
}
}
);
實現自己的高效能 Stream
在業務中你可能也會自己實現一個 Stream,可讀、可寫、或者雙向流,可以參考文件:
Stream 雖然很神奇,但自己實現 Stream 也可能會存在隱藏的效能問題,比如:
class MyReadable extends Readable {
_read(size) {
while (null !== (chunk = getNextChunk())) {
this.push(chunk);
}
}
}
當我們呼叫 new MyReadable().pipe(xxx)
時,會把 getNextChunk()
所得到的 chunk 都 push 出去,直到讀取結束。但如果此時管道的下一步處理速度較慢,就會導致資料堆積在記憶體中,導致記憶體佔用變大,GC 速度降低。
而正確的做法應該是,根據 this.push()
返回值選擇正確的行為,當返回值為 false
時,說明此時堆積的 chunk 已經滿了,應該停止讀入。
class MyReadable extends Readable {
_read(size) {
while (null !== (chunk = getNextChunk())) {
if (!this.push(chunk)) {
return false
}
}
}
}
這個問題在 Node.js 官方的一篇文章中有詳細的介紹:Backpressuring in Streams
7、C++ 擴充套件一定比 JavaScript 快嗎?
Node.js 非常適合 IO 密集型的應用,而對於計算密集的業務,很多人都會想到用編寫 C++ Addon 的方式來優化效能。但實際上 C++ 擴充套件並不是靈丹妙藥,V8 的效能也沒有想象的那麼差。
比如,我在今年九月份的時候把 Node.js 的 net.isIPv6()
從 C++ 遷移到了 JS 的實現,讓大多數的測試用例都獲得了 10%- 250% 不等的效能提升(具體PR可以看這裡)。
JavaScript 在 V8 上跑得比 C++ 擴充套件還快,這種情況多半發生在與字串、正則表示式相關的場景,因為 V8 內部使用的正則表示式引擎是 irregexp,這個正則表示式引擎比 boost 中自帶的引擎(boost::regex
)要快得多。
還有一處值得注意的就是,Node.js 的 C++ 擴充套件在進行型別轉換的時候,可能會消耗非常多的效能,如果不注意 C++ 程式碼的細節,效能會很大地下降。
這裡有一篇文章對比了相同演算法下 C++ 和 JS 的效能(需翻牆):How to get a performance boost using Node.js native addons。其中值得注意的結論就是,C++ 程式碼在對引數中的字串進行轉換後(String::Utf8Value
轉為std::string
),效能甚至不如 JS 實現的一半。只有在使用 NAN 提供的型別封裝後,才獲得了比 JS 更高的效能。
換句話說,C++ 是否比 JavaScript 更加高效需要具體問題具體分析,某些情況下,C++ 擴充套件不一定就會比原生 JavaScript 更高效。如果你對自己的 C++ 水平不是那麼有信心,其實還是建議用 JavaScript 來實現,因為 V8 的效能比你想象的要好得多。
8、使用 node-clinic 快速定位效能問題
說了這麼多,有沒有什麼可以開箱即用,五分鐘見效的呢?當然有。
node-clinic 是 NearForm 開源的一款 Node.js 效能診斷工具,可以非常快速地定位效能問題。
npm i -g clinic
npm i -g autocannon
使用的時候,先開啟服務程序:
clinic doctor -- node server.js
然後我們可以用任何壓測工具跑一次壓測,比如使用同一個作者的 autocannon(當然你也可以使用 ab、curl 這樣的工具來進行壓測。):
autocannon http://localhost:3000
壓測完畢後,我們 ctrl + c 關閉 clinic 開啟的程序,就會自動生成報告。比如下面就是我們一箇中間件服務的效能報告:
我們可以從 CPU 的使用曲線看出,這個中介軟體服務的效能瓶頸不在自身內部的計算,而在於 I/O 速度太慢。clinic 也在上面告訴我們檢測到了潛在的 I/O 問題。
下面我們使用 clinic bubbleprof
來檢測 I/O 問題:
clinic bubbleprof -- node server.js
再次進行壓測後,我們得到了新的報告:
這個報告中,我們可以看到,http.Server
在整個程式執行期間,96% 的時間都處於 pending 狀態,點開後,我們會發現呼叫棧中存在大量的 empty frame,也就是說,由於網路 I/O 的限制,CPU 存在大量的空轉,這在中介軟體業務中非常常見,也為我們指明瞭優化方向不在服務內部,而在伺服器的閘道器和依賴的服務相應速度上。
想知道如何讀懂 clinic bubbleprof
生成的報告,可以看這裡:https://clinicjs.org/bubbleprof/walkthrough/
同樣,clinic 也可以檢測到服務內部的計算效能問題,下面我們做一些“破壞”,讓這個服務的效能瓶頸出現在 CPU 計算上。
我們在某個中介軟體中加入了空轉一億次這樣非常消耗 CPU 的“破壞性”程式碼:
function sleep() {
let n = 0
while (n++ < 10e7) {
empty()
}
}
function empty() { }
module.exports = (ctx, next) => {
sleep()
// ......
return next()
}
然後使用 clinic doctor
,重複上面的步驟,生成效能報告:
這就是一個非常典型的同步計算阻塞了非同步佇列的“病例”,即主執行緒上進行了大量的計算,導致 JavaScript 的非同步回撥沒法及時觸發,Event Loop 的延遲極高。
對於這樣的應用,我們可以繼續使用 clinic flame
來確定到底是哪裡出現了密集計算:
clinic flame -- node app.js
壓測後,我們得到了火焰圖(這裡把空轉次數減少到了100萬次,讓火焰圖看起來不至於那麼極端):
從這張圖裡,我們可以明顯看到頂部的那個大白條,它代表了 sleep
函式空轉所消耗的 CPU 時間。根據這樣的火焰圖,我們可以非常輕鬆地看出 CPU 資源的消耗情況,從而定位程式碼中哪裡有密集的計算,找到效能瓶頸。
此文已由作者授權騰訊雲+社群釋出