你不知道的Node.js性能優化,讀了之後水平直線上升

来源:https://www.cnblogs.com/qcloud1001/archive/2018/12/07/10084223.html
-Advertisement-
Play Games

本文由雲+社區發表 “當我第一次知道要這篇文章的時候,其實我是拒絕的,因為我覺得,你不能叫我寫馬上就寫,我要有乾貨才行,寫一些老生常談的然後加上好多特技,那個 Node.js 性能啊好像 Duang~ 的一下就上去了,那讀者一定會罵我,Node.js 根本沒有這樣搞性能優化的,都是假的。” 斯塔克· ...


本文由雲+社區發表

“當我第一次知道要這篇文章的時候,其實我是拒絕的,因為我覺得,你不能叫我寫馬上就寫,我要有乾貨才行,寫一些老生常談的然後加上好多特技,那個 Node.js 性能啊好像 Duang~ 的一下就上去了,那讀者一定會罵我,Node.js 根本沒有這樣搞性能優化的,都是假的。” ------ 斯塔克·成龍·王


1、使用最新版本的 Node.js

僅僅是簡單的升級 Node.js 版本就可以輕鬆地獲得性能提升,因為幾乎任何新版本的 Node.js 都會比老版本性能更好,為什麼?

Node.js 每個版本的性能提升主要來自於兩個方面:

  • V8 的版本更新;
  • Node.js 內部代碼的更新優化。

例如最新的 V8 7.1 中,就優化了某些情形下閉包的逃逸分析,讓 Array 的一些方法得到了性能提升:

img

Node.js 的內部代碼,隨著版本的升級,也會有明顯的優化,比如下麵這個圖就是 require 的性能隨著 Node.js 版本升級的變化:

img

每個提交到 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 版本在今年六月結束了維護。

img

對於生產環境而言,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 倍!

img

一個簡單的示例:

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 的性能(需FQ):How to get a performance boost using Node.js native addons。其中值得註意的結論就是,C++ 代碼在對參數中的字元串進行轉換後(String::Utf8Value轉為std::string),性能甚至不如 JS 實現的一半。只有在使用 NAN 提供的類型封裝後,才獲得了比 JS 更高的性能。

img

換句話說,C++ 是否比 JavaScript 更加高效需要具體問題具體分析,某些情況下,C++ 擴展不一定就會比原生 JavaScript 更高效。如果你對自己的 C++ 水平不是那麼有信心,其實還是建議用 JavaScript 來實現,因為 V8 的性能比你想象的要好得多。


8、使用 node-clinic 快速定位性能問題

說了這麼多,有沒有什麼可以開箱即用,五分鐘見效的呢?當然有。

node-clinicNearForm 開源的一款 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 開啟的進程,就會自動生成報告。比如下麵就是我們一個中間件服務的性能報告:

img

我們可以從 CPU 的使用曲線看出,這個中間件服務的性能瓶頸不在自身內部的計算,而在於 I/O 速度太慢。clinic 也在上面告訴我們檢測到了潛在的 I/O 問題。

下麵我們使用 clinic bubbleprof 來檢測 I/O 問題:

clinic bubbleprof -- node server.js

再次進行壓測後,我們得到了新的報告:

img

這個報告中,我們可以看到,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,重覆上面的步驟,生成性能報告:

img

這就是一個非常典型的同步計算阻塞了非同步隊列的“病例”,即主線程上進行了大量的計算,導致 JavaScript 的非同步回調沒法及時觸發,Event Loop 的延遲極高。

對於這樣的應用,我們可以繼續使用 clinic flame 來確定到底是哪裡出現了密集計算:

clinic flame -- node app.js

壓測後,我們得到了火焰圖(這裡把空轉次數減少到了100萬次,讓火焰圖看起來不至於那麼極端):

img

從這張圖裡,我們可以明顯看到頂部的那個大白條,它代表了 sleep 函數空轉所消耗的 CPU 時間。根據這樣的火焰圖,我們可以非常輕鬆地看出 CPU 資源的消耗情況,從而定位代碼中哪裡有密集的計算,找到性能瓶頸。

此文已由作者授權騰訊雲+社區發佈



您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 0前言 陸續的用Node已經一年多了,已經用node寫了幾個的項目,也該是總結node學習的過程了 1.Node是啥? Node.js是一使用JavaScript作為開發語言,運行在伺服器端的Web伺服器,也就是說是 JavaScript的服務端運行環境,這麼說其實還不嚴謹。node除了實現了js的 ...
  • es6常用語法簡介 es是js的規範標準 let 特點: 1.有全局和函數作用域,以及塊級作用域(用{}表示塊級作用域範圍) 2.不會存在變數提升 3.變數不能重覆聲明 const 特點: 1.有塊級作用域 2.不會存在變數提升 3.不能重覆聲明變數, 4.只聲明常量,必須賦值,且定義之後不能更改 ...
  • 作為前端開發者的伙伴們,肯定對Promise,Generator,async/await非常熟悉不過了。Promise絕對是爛記於心,而async/await卻讓使大伙們感覺到爽(原來非同步可以這麼簡單)。可回頭來梳理他們的關聯時,你驚訝的發現,他們是如此的密切相關。 一、三者關係與發展史 我對他們三 ...
  • 場景: jsx 綁定方法 方法有3種 1: clickEye(a, b){ // do something..... } clickEye(a, b){ // do something..... } 2: 3: 總結: 我個人基本拋棄了第二種方法,經常使用的是第三種方法,如果遇到需要傳特殊參數,並且 ...
  • 這裡是修真院前端小課堂,每篇分享文從 【背景介紹】【知識剖析】【常見問題】【解決方案】【編碼實戰】【擴展思考】【更多討論】【參考文獻】 八個方面深度解析前端知識/技能,本篇分享的是: 【F12 console的用法,以及如何debug程式?】 1.背景介紹 Chrome中Console是用於顯示JS ...
  • 1:問題: 假如在做一個管理系統,面向老師學生的,學生提交申請,老師負責審核(或者還需要添加其他角色,功能許可權都不同)。 現在的問題是,每種角色登錄看到的界面應該都是不一樣的,那這個頁面的區分如何實現呢? 2:要不要給老師和學生各自設計一套頁面?這樣工作量是不是太大了,並且如果還要加入其它角色的話, ...
  • 清除浮動: 在非IE瀏覽器下,當容器的高度為auto,且容器的內容中有浮動(float為left或right)的元素,在這種情況下,容器的高度不能自動伸長以適應內容的高度,使得內容溢出到容器外面而影響(甚至破壞)佈局的現象。即父級對象盒子無法被撐開,這個現象叫浮動溢出,為了防止這個現象的出現而進行的 ...
  • var Arry=[ {name: "vehicleTravelLicenseCopyBack", id: "a1"}, {name: "vehicleTravelLicenseCopyFront", id: "a2"}, {name: "idCardBack", id: "a3"}, {name: ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...