保持 Node.js 快速:製作高性能 Node.js 服務器的工具、技術和技巧

已發表: 2022-03-10
快速總結↬ Node 是一個非常通用的平台,但主要的應用程序之一是創建網絡進程。 在本文中,我們將重點分析其中最常見的:HTTP Web 服務器。

如果您使用 Node.js 構建任何東西的時間已經足夠長,那麼您無疑已經經歷了意外速度問題的痛苦。 JavaScript 是一種事件的異步語言。 這可能會使關於性能的推理變得棘手,這一點將變得顯而易見。 Node.js 的迅速流行暴露了對適合服務器端 JavaScript 約束的工具、技術和思維的需求。

在性能方面,在瀏覽器中工作的東西不一定適合 Node.js。 那麼,我們如何確保 Node.js 實現快速且適合目的? 讓我們來看一個動手示例。

工具

Node 是一個非常通用的平台,但主要的應用程序之一是創建網絡進程。 我們將專注於分析最常見的這些:HTTP Web 服務器。

我們需要一個工具,可以在測量性能的同時用大量請求攻擊服務器。 例如,我們可以使用 AutoCannon:

 npm install -g autocannon

其他優秀的 HTTP 基準測試工具包括 Apache Bench (ab) 和 wrk2,但 AutoCannon 是用 Node 編寫的,提供類似(或有時更大)的負載壓力,並且非常容易安裝在 Windows、Linux 和 Mac OS X 上。

跳躍後更多! 繼續往下看↓

在我們建立了基線性能測量之後,如果我們決定我們的流程可以更快,我們將需要一些方法來診斷流程的問題。 診斷各種性能問題的一個很好的工具是 Node Clinic,它也可以使用 npm 安裝:

 npm install -g clinic

這實際上安裝了一套工具。 我們將使用 Clinic Doctor 和 Clinic Flame(圍繞 0x 的包裝)。

注意對於這個動手示例,我們需要 Node 8.11.2 或更高版本。

代碼

我們的示例案例是一個具有單一資源的簡單 REST 服務器:在/seed/v1處公開為 GET 路由的大型 JSON 有效負載。 服務器是一個app文件夾,它由一個package.json文件(取決於restify 7.1.0 )、一個index.js文件和一個util.js文件組成。

我們服務器的index.js文件如下所示:

 'use strict' const restify = require('restify') const { etagger, timestamp, fetchContent } = require('./util')() const server = restify.createServer() server.use(etagger().bind(server)) server.get('/seed/v1', function (req, res, next) { fetchContent(req.url, (err, content) => { if (err) return next(err) res.send({data: content, url: req.url, ts: timestamp()}) next() }) }) server.listen(3000)

該服務器代表了提供客戶端緩存的動態內容的常見情況。 這是通過etagger中間件實現的,該中間件為內容的最新狀態計算ETag標頭。

util.js文件提供了在這種情況下通常使用的實現部分、從後端獲取相關內容的函數、etag 中間件和按分鐘提供時間戳的時間戳函數:

 'use strict' require('events').defaultMaxListeners = Infinity const crypto = require('crypto') module.exports = () => { const content = crypto.rng(5000).toString('hex') const ONE_MINUTE = 60000 var last = Date.now() function timestamp () { var now = Date.now() if (now — last >= ONE_MINUTE) last = now return last } function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag }) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } } function fetchContent (url, cb) { setImmediate(() => { if (url !== '/seed/v1') cb(Object.assign(Error('Not Found'), {statusCode: 404})) else cb(null, content) }) } return { timestamp, etagger, fetchContent } }

絕不將此代碼作為最佳實踐的示例! 此文件中有多種代碼異味,但我們會在測量和分析應用程序時找到它們。

要獲得我們起點的完整源代碼,可以在此處找到慢速服務器。

剖析

為了進行分析,我們需要兩個終端,一個用於啟動應用程序,另一個用於對其進行負載測試。

在一個終端的app文件夾中,我們可以運行:

 node index.js

在另一個終端中,我們可以像這樣分析它:

 autocannon -c100 localhost:3000/seed/v1

這將打開 100 個並發連接並用請求轟炸服務器十秒鐘。

結果應該類似於以下內容(Running 10s test @ https://localhost:3000/seed/v1 — 100 個連接):

統計平均標準差最大限度
延遲(毫秒) 3086.81 1725.2 5554
請求/秒23.1 19.18 65
字節/秒237.98 KB 197.7 KB 688.13 KB
10 秒內 231 個請求,讀取 2.4 MB

結果會因機器而異。 然而,考慮到“Hello World”Node.js 服務器很容易在產生這些結果的機器上每秒處理 30000 個請求,因此平均延遲超過 3 秒的每秒 23 個請求是令人沮喪的。

診斷

發現問題區域

借助 Clinic Doctor 的 –on-port 命令,我們可以使用單個命令診斷應用程序。 在我們運行的app文件夾中:

 clinic doctor --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

這將創建一個 HTML 文件,該文件將在分析完成後在我們的瀏覽器中自動打開。

結果應如下所示:

Clinic Doctor 檢測到事件循環問題
診所醫生結果

Doctor 告訴我們,我們可能遇到了 Event Loop 問題。

除了 UI 頂部附近的消息,我們還可以看到 Event Loop 圖表是紅色的,並且顯示出不斷增加的延遲。 在深入研究這意味著什麼之前,讓我們首先了解診斷出的問題對其他指標的影響。

我們可以看到 CPU 始終處於或高於 100%,因為該進程努力處理排隊的請求。 Node 的 JavaScript 引擎(V8)在這種情況下實際上使用了兩個 CPU 內核,因為機器是多核的,而 V8 使用了兩個線程。 一個用於事件循環,另一個用於垃圾收集。 當我們看到在某些情況下 CPU 飆升至 120% 時,該進程正在收集與已處理請求相關的對象。

我們在內存圖中看到了這種相關性。 Memory 圖表中的實線是 Heap Used 指標。 每當 CPU 出現峰值時,我們都會看到 Heap Used 線下降,表明內存正在被釋放。

活動句柄不受事件循環延遲的影響。 活動句柄是表示 I/O(如套接字或文件句柄)或計時器(如setInterval )的對象。 我們指示 AutoCannon 打開 100 個連接( -c100 )。 活動句柄保持一致的計數為 103。其他三個是 STDOUT、STDERR 句柄和服務器本身的句柄。

如果我們點擊屏幕底部的 Recommendations 面板,我們應該會看到如下內容:

診所醫生推薦小組開放
查看特定問題的建議

短期緩解

對嚴重性能問題的根本原因分析可能需要時間。 對於實時部署的項目,值得為服務器或服務添加過載保護。 過載保護的想法是監視事件循環延遲(除其他外),如果超過閾值,則響應“503 Service Unavailable”。 這允許負載均衡器故障轉移到其他實例,或者在最壞的情況下意味著用戶將不得不刷新。 過載保護模塊可以為 Express、Koa 和 Restify 提供最小的開銷。 Hapi 框架具有提供相同保護的負載配置設置。

了解問題區域

正如 Clinic Doctor 中的簡短解釋所解釋的,如果事件循環被延遲到我們觀察到的水平,則很可能一個或多個函數正在“阻塞”事件循環。

識別這個主要的 JavaScript 特性對於 Node.js 尤為重要:在當前執行的代碼完成之前,不會發生異步事件。

這就是為什麼setTimeout不能精確的原因。

例如,嘗試在瀏覽器的 DevTools 或 Node REPL 中運行以下命令:

 console.time('timeout') setTimeout(console.timeEnd, 100, 'timeout') let n = 1e7 while (n--) Math.random()

由此產生的時間測量永遠不會是 100 毫秒。 它可能在 150 毫秒到 250 毫秒的範圍內。 setTimeout調度了一個異步操作( console.timeEnd ),但是當前執行的代碼還沒有完成; 還有兩行。 當前執行的代碼稱為當前的“tick”。 要完成滴答, Math.random必須被調用一千萬次。 如果這需要 100 毫秒,那麼超時解決之前的總時間將是 200 毫秒(加上setTimeout函數實際預先排隊超時所需的時間,通常是幾毫秒)。

在服務器端上下文中,如果當前tick 中的操作需要很長時間才能完成請求,則無法處理數據獲取,因為異步代碼在當前tick 完成之前不會執行。 這意味著計算量大的代碼會減慢與服務器的所有交互。 因此建議將資源密集型工作拆分為單獨的進程並從主服務器調用它們,這樣可以避免在很少使用但昂貴的路由上降低其他常用但便宜的路由的性能的情況。

示例服務器有一些阻塞事件循環的代碼,所以下一步是找到該代碼。

分析

快速識別性能不佳的代碼的一種方法是創建和分析火焰圖。 火焰圖將函數調用表示為彼此重疊的塊——不是隨著時間的推移,而是在聚合中。 之所以將其稱為“火焰圖”,是因為它通常使用橙色到紅色的配色方案,其中塊越紅,函數就越“熱”,這意味著它越可能阻塞事件循環。 捕獲火焰圖的數據是通過對 CPU 進行採樣來進行的——這意味著獲取當前正在執行的函數及其堆棧的快照。 熱量由分析期間給定函數位於每個樣本的堆棧頂部(例如當前正在執行的函數)的時間百分比確定。 如果它不是在該堆棧中被調用的最後一個函數,那麼它可能會阻塞事件循環。

讓我們使用clinic flame來生成示例應用程序的火焰圖:

 clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

結果應該在我們的瀏覽器中打開,如下所示:

Clinic 的火焰圖顯示 server.on 是瓶頸
診所的火焰圖可視化

塊的寬度表示它在 CPU 上花費的總時間。 可以觀察到三個主要堆棧佔用了最多的時間,它們都將server.on突出顯示為最熱的函數。 事實上,所有三個堆棧都是相同的。 它們之所以不同,是因為在分析期間優化和未優化的函數被視為單獨的調用幀。 以*為前綴的函數由 JavaScript 引擎優化,以~為前綴的函數未優化。 如果優化狀態對我們不重要,我們可以通過按下 Merge 按鈕進一步簡化圖表。 這應該會導致類似於以下內容的視圖:

合併火焰圖
合併火焰圖

從一開始,我們就可以推斷出違規代碼在應用程序代碼的util.js文件中。

slow 函數也是一個事件處理程序:導致該函數的函數是核心events模塊的一部分, server.on是作為事件處理函數提供的匿名函數的後備名稱。 我們還可以看到,此代碼與實際處理請求的代碼不在同一個滴答中。 如果是這樣,來自核心httpnetstream模塊的函數將在堆棧中。

這些核心功能可以通過擴展火焰圖的其他小得多的部分來找到。 例如,嘗試使用 UI 右上角的搜索輸入來搜索sendrestifyhttp內部方法的名稱)。 它應該在圖表的右側(函數按字母順序排序):

火焰圖有兩個突出顯示的小塊,代表 HTTP 處理功能
在火焰圖中搜索 HTTP 處理函數

請注意所有實際的 HTTP 處理塊相對較小。

我們可以單擊以青色突出顯示的塊之一,它將展開以顯示諸如writeHead之類的函數並write http_outgoing.js文件(Node 核心http庫的一部分):

火焰圖已放大到顯示 HTTP 相關堆棧的不同視圖
將火焰圖擴展為 HTTP 相關堆棧

我們可以單擊所有堆棧返回主視圖。

這裡的關鍵點是,即使server.on函數與實際的請求處理代碼不在同一個滴答中,它仍然會通過延遲其他性能代碼的執行來影響整體服務器性能。

調試

我們從火焰圖中知道,有問題的函數是util.js文件中傳遞給server.on的事件處理程序。

讓我們來看看:

 server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag })

眾所周知,密碼學往往很昂貴,序列化( JSON.stringify )也是如此,但為什麼它們不出現在火焰圖中呢? 這些操作在捕獲的樣本中,但它們隱藏在cpp過濾器後面。 如果我們按下cpp按鈕,我們應該會看到如下內容:

火焰圖(主視圖)中顯示了與 C++ 相關的其他塊
揭示序列化和加密 C++ 幀

與序列化和加密相關的內部 V8 指令現在顯示為最熱門的堆棧並且佔用了大部分時間。 JSON.stringify方法直接調用 C++ 代碼; 這就是為什麼我們看不到 JavaScript 函數的原因。 在密碼學案例中, createHashupdate等函數在數據中,但它們要么是內聯的(這意味著它們在合併視圖中消失),要么太小而無法呈現。

一旦我們開始對etagger函數中的代碼進行推理,很快就會發現它的設計很糟糕。 為什麼我們要從函數上下文中獲取server實例? 有很多散列正在進行,所有這些都是必要的嗎? 實現中也沒有If-None-Match標頭支持,這將減輕某些實際場景中的一些負載,因為客戶端只會發出頭部請求來確定新鮮度。

讓我們暫時忽略所有這些點,並驗證在server.on中執行的實際工作確實是瓶頸的發現。 這可以通過將server.on代碼設置為空函數並生成新的火焰圖來實現。

etagger函數更改為以下內容:

 function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => {}) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } }

傳遞給server.on的事件偵聽器函數現在是空操作。

讓我們再次運行clinic flame

 clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

這應該會產生類似於以下內容的火焰圖:

火焰圖顯示 Node.js 事件系統堆棧仍然是瓶頸
server.on 為空函數時服務器的火焰圖

這看起來更好,我們應該注意到每秒請求的增加。 但為什麼事件發射代碼如此火爆? 我們預計此時 HTTP 處理代碼會佔用大部分 CPU 時間,在server.on事件中根本沒有執行任何操作。

這種類型的瓶頸是由於執行的功能超出了應有的程度。

util.js頂部的以下可疑代碼可能是一個線索:

 require('events').defaultMaxListeners = Infinity

讓我們刪除這一行並使用--trace-warnings標誌開始我們的進程:

 node --trace-warnings index.js

如果我們在另一個終端中使用 AutoCannon 進行分析,如下所示:

 autocannon -c100 localhost:3000/seed/v1

我們的過程將輸出類似於:

 (node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit at _addListener (events.js:280:19) at Server.addListener (events.js:297:10) at attachAfterEvent (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14) at Server. (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7) at call (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9) at next (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9) at Chain.run (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5) at Server._runUse (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19) at Server._runRoute (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10) at Server._afterPre (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10) (node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit at _addListener (events.js:280:19) at Server.addListener (events.js:297:10) at attachAfterEvent (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14) at Server. (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7) at call (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9) at next (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9) at Chain.run (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5) at Server._runUse (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19) at Server._runRoute (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10) at Server._afterPre (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)

Node 告訴我們有很多事件被附加到服務器對像上。 這很奇怪,因為有一個布爾值檢查事件是否已附加,然後提前返回,基本上使attachAfterEvent在附加第一個事件後成為無操作。

我們看一下attachAfterEvent函數:

 var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => {}) }

條件檢查錯誤! 它檢查attachAfterEvent是否為 true 而不是afterEventAttached 。 這意味著在每個請求上都會將一個新事件附加到server實例,然後在每個請求之後觸發所有先前附加的事件。 哎呀!

優化

現在我們已經發現了問題區域,讓我們看看我們是否可以讓服務器更快。

低垂的果實

讓我們放回server.on監聽器代碼(而不是一個空函數)並在條件檢查中使用正確的布爾名稱。 我們的etagger函數如下所示:

 function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (afterEventAttached === true) return afterEventAttached = true server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag }) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } }

現在我們通過再次分析來檢查我們的修復。 在一個終端啟動服務器:

 node index.js

然後使用 AutoCannon 配置文件:

 autocannon -c100 localhost:3000/seed/v1

我們應該會在 200 倍的改進範圍內看到結果(運行 10 秒測試 @ https://localhost:3000/seed/v1 — 100 個連接):

統計平均標準差最大限度
延遲(毫秒) 19.47 4.29 103
請求/秒5011.11 506.2 5487
字節/秒51.8 MB 5.45 MB 58.72 MB
10 秒內 50k 個請求,讀取 519.64 MB

平衡潛在的服務器成本降低和開發成本很重要。 我們需要在自己的情境中定義優化項目需要走多遠。 否則,將 80% 的努力投入到 20% 的速度提升上可能太容易了。 項目的限制是否證明了這一點?

在某些情況下,實現 200 倍的改進可能是合適的,而且很容易實現。 在其他情況下,我們可能希望盡可能快地實現我們的實現。 這實際上取決於項目的優先級。

控制資源消耗的一種方法是設定目標。 例如,10 倍的改進,或每秒 4000 個請求。 基於業務需求是最有意義的。 例如,如果服務器成本超出預算 100%,我們可以設定 2 倍改進的目標。

更進一步

如果我們生成服務器的新火焰圖,我們應該會看到類似於以下內容:

火焰圖仍將 server.on 顯示為瓶頸,但瓶頸較小
修復性能錯誤後的火焰圖

事件監聽器仍然是瓶頸,它在分析期間仍然佔用了三分之一的 CPU 時間(寬度大約是整個圖的三分之一)。

可以取得哪些額外收益,這些變化(以及相關的破壞)是否值得做出?

通過優化的實現,但仍然稍微受到更多限制,可以實現以下性能特徵(運行 10 秒測試 @ https://localhost:3000/seed/v1 — 10 個連接):

統計平均標準差最大限度
延遲(毫秒) 0.64 0.86 17
請求/秒8330.91 757.63 8991
字節/秒84.17 MB 7.64 MB 92.27 MB
11 秒內 92k 個請求,讀取 937.22 MB

雖然 1.6 倍的改進是顯著的,但有爭議的是,創造這種改進所需的努力、更改和代碼中斷是否合理取決於具體情況。 尤其是與通過單個錯誤修復對原始實現的 200 倍改進相比時。

為了實現這一改進,使用了相同的配置文件、生成火焰圖、分析、調試和優化的迭代技術來到達最終優化的服務器,其代碼可以在這裡找到。

達到 8000 req/s 的最終更改是:

  • 不要構建對象然後序列化,直接構建一串JSON;
  • 使用內容的獨特之處來定義它的 Etag,而不是創建一個哈希;
  • 不要對 URL 進行哈希處理,直接將其用作密鑰。

這些更改涉及更多,對代碼庫的破壞性更大,並且使etagger中間件不太靈活,因為它將負擔放在提供Etag值的路由上。 但它在分析機器上實現了每秒額外 3000 個請求。

讓我們看一下這些最終改進的火焰圖:

火焰圖顯示與 net 模塊相關的內部代碼現在是瓶頸
所有性能改進後的健康火焰圖

火焰圖最熱的部分是 Node 核心的一部分,在net模塊中。 這是理想的。

防止性能問題

最後,這裡有一些關於在部署之前防止性能問題的方法的建議。

在開發過程中使用性能工具作為非正式檢查點可以在性能錯誤進入生產之前過濾掉它們。 建議將 AutoCannon 和 Clinic(或等價物)作為日常開發工具的一部分。

購買框架時,請了解它的性能政策是什麼。 如果框架沒有優先考慮性能,那麼檢查它是否與基礎設施實踐和業務目標一致很重要。 例如,Restify 顯然(自第 7 版發布以來)投資於提高庫的性能。 然而,如果低成本和高速度是絕對優先考慮的,請考慮 Fastify,它被 Restify 貢獻者測量為快 17%。

注意其他影響廣泛的庫選擇——尤其是考慮日誌記錄。 隨著開發人員修復問題,他們可能會決定添加額外的日誌輸出以幫助將來調試相關問題。 如果使用性能不佳的記錄器,這可能會像沸騰的青蛙寓言一樣隨著時間的推移扼殺性能。 pino 記錄器是可用於 Node.js 的最快的換行分隔 JSON 記錄器。

最後,永遠記住事件循環是一個共享資源。 Node.js 服務器最終受限於最熱路徑中最慢的邏輯。