Node.js的performance鉤子和測量 API
當你完成了編寫和部署了項目,下一步就是去改進、消除瓶頸、提高執(zhí)行速度和優(yōu)化性能,得先了解項目現(xiàn)有的性能瓶頸和邏輯慢的地方。但是,沒有人喜歡猜測哪些部分可能更慢的試錯過程。
Node.js 提供了各種內置性能鉤子函數(shù)來衡量執(zhí)行速度,找出代碼的哪些部分值得優(yōu)化,并收集應用程序代碼執(zhí)行的精細視圖。
在本文中,您將學習如何使用 Node.js 性能鉤子函數(shù)和測量 API 來識別瓶頸并增強應用程序的性能,從而加快響應時間并提高資源效率。
Node.js的Performance API 概述
首先要了解為什么以及何時應該使用 Node 提供的 Performance API以及它提供的各種選項,考慮這樣一種情況:您想要測量特定代碼塊的執(zhí)行時間。
為此,您可能已經使用了 Date 對象,如下所示:
let start = Date.now();
for (let i = 0; i < 10000; i++) { } // stand-in for some complex calculation
let end = Date.now();
console.log(end - start);
但是,如果您運行上述操作并觀察,您會注意到這還不夠精確。
例如,像上面這樣的空循環(huán)會將 0 或 1 記錄為差值,并且不會給我們足夠的粒度。Date 類只能提供毫秒級的粒度,如果代碼以 100 納秒的順序運行,這不會給我們正確的測量結果。
為此,我們可以改用 Performance API 來獲得更好的測量結果:
const {performance} = require('node:perf_hooks');
let start = performance.now()
for (let i = 0; i < 10000; i++) {}
let end = performance.now()
console.log(end - start);
這樣,我們就可以得到一個更精細的值,在我的系統(tǒng)上,該值在 0.18 到 0.21 毫秒的范圍內,精度高達 15-16 位小數(shù)。這是我們可以使用 Node Performance API 更好地測量執(zhí)行時間的一種簡單方法。
該 API 還提供了一種在程序運行期間精確標記時間點的方法。我們可以使用performance.mark方法獲取高精度事件的時間戳,例如循環(huán)迭代的開始時間。
運行下面代碼:
let start_mark = performance.mark("loop_start",
{detail:"starting loop of 1000 iterations"}
);
for (let i = 0; i < 10000; i++) {}
let end_mark = performance.mark("loop_end",
{detail:"ending loop of 1000 iterations"}
);
console.log( start_mark, end_mark );
輸出:
PerformanceMark {
name: 'loop_start',
entryType: 'mark',
startTime: 27.891528000007384,
duration: 0,
detail: 'starting loop of 1000 iterations'
}
PerformanceMark {
name: 'loop_end',
entryType: 'mark',
startTime: 28.996093000052497,
duration: 0,
detail: 'ending loop of 1000 iterations'
}
mark 函數(shù)將標記的名稱作為第一個參數(shù)。第二個參數(shù)對象中的detail允許提供有關該標記的額外詳細信息,例如運行的迭代次數(shù)、數(shù)據(jù)庫查詢參數(shù)等。
然后,可以使用 mark 函數(shù)返回的對象通過 Prometheus exporter sdk 將計時數(shù)據(jù)導出到 Prometheus 之類的東西。這允許我們在應用程序外部查詢和可視化耗時信息。由于 mark 是一個瞬時時間點,因此返回對象中的 duration 字段始終為零。
而不是手動調用 performance.now 并計算兩個事件之間的差異,我們可以使用 marks 和 measure 函數(shù)執(zhí)行相同的操作。我們可以使用上面標記的名稱來測量兩個標記之間的持續(xù)時間:
performance.mark("loop_start",
{detail:"starting loop of 1000 iterations"}
);
for (let i = 0; i < 10000; i++) {}
performance.mark("loop_end",
{detail:"ending loop of 1000 iterations"}
);
console.log(performance.measure("loop_time","loop_start","loop_end"));
measure 的第一個參數(shù)是我們要為測量指定的名稱。然后,接下來的兩個參數(shù)分別指定要開始和結束測量的標記的名稱。
這兩個參數(shù)都是可選的 — 如果兩者都沒有給出,則為 performance.measure 將返回應用程序啟動和測度調用之間經過的時間。如果我們只提供第一個參數(shù),該函數(shù)將返回性能之間經過的performance.mark替換為該名稱和 measure 調用。
如果兩者都提供,該函數(shù)將返回它們之間的高精度時間差。對于上面的示例,我們將得到如下輸出:
PerformanceMeasure {
name: 'loop_time',
entryType: 'measure',
startTime: 27.991639000130817,
duration: 1.019368999870494
}
這可以再次與 Prometheus exporter 一起使用,以便導出自定義測量指標。如果您的設置執(zhí)行藍綠或 Canary 部署,則可以比較舊版本和新版本的性能,以查看您的優(yōu)化是否按預期工作。
最后,需要注意的一點是,Performance API 在內部使用固定大小的緩沖區(qū)來存儲標記和度量,因此我們需要在使用完它們后對其進行清理。這可以使用以下方法完成:
performance.clearMarks("mark_name");
或者:
performance.clearMeasures("measure_name");
這些函數(shù)將從相應的緩沖區(qū)中刪除具有給定名稱的標記/度量。如果在不提供任何參數(shù)的情況下調用這些函數(shù),它們將清除緩沖區(qū)中存在的所有標記/度量,因此在沒有任何參數(shù)的情況下調用這些函數(shù)時要小心。
使用 Performance鉤子優(yōu)化您的應用
現(xiàn)在讓我們看看如何使用這個 API 來優(yōu)化我們的應用程序。在我們的示例中,我們將考慮從數(shù)據(jù)庫中獲取一些數(shù)據(jù),然后手動排序并將其返回給用戶的情況。
我們想了解每個操作需要多少時間,以及首先優(yōu)化的最佳位置是什么。為此,我們將首先測量發(fā)生的各種事件:
async function main(){
const querySize = 10; // ideally this will come from user's request
performance.mark("db_query_start",{detail:`query size ${querySize}`});
const data = fetchData(querySize);
performance.mark("db_query_end",{detail:`query size ${querySize}`});
performance.mark("sort_start",{detail:`sort size ${querySize}`});
const sorted = sortData(data);
performance.mark("sort_end",{detail:`sort size ${querySize}`});
console.log(performance.measure("db_time","db_query_start","db_query_end"));
console.log(performance.measure("sort_time","sort_start","sort_end"));
// clear the marks...
}
我們首先聲明查詢大小,在實際應用程序中,它可能來自用戶的請求。
然后我們使用performance.mark 函數(shù)來標記數(shù)據(jù)庫獲取和排序操作的開始和結束。最后,我們使用 performance 輸出這些事件之間的持續(xù)時間。量功能。我們得到這樣的輸出:
PerformanceMeasure {
name: 'db_time',
entryType: 'measure',
startTime: 27.811830999795347,
duration: 1.482880000025034
}
PerformanceMeasure {
name: 'sort_time',
entryType: 'measure',
startTime: 29.31366699980572,
duration: 0.09800400026142597
}
要查看這兩個操作在查詢大小增加時的表現(xiàn),我們將更改查詢大小值并記下度量值。在我的系統(tǒng)上,我得到以下內容:
正如我們在這里看到的,隨著查詢大小的增加,排序時間會迅速增加,首先優(yōu)化它可能更有益。通過使用一些不同的排序算法,我們得到以下內容:
雖然對于非常小的查詢大小,排序時間略短,但與原始測量值相比,時間增長緩慢。因此,如果我們期望經常處理大型查詢,那么在此處更改排序算法將是有益的。
同樣,我們可以測量在查詢字段上創(chuàng)建索引之前和之后數(shù)據(jù)庫獲取時間的差異。然后我們可以決定索引創(chuàng)建是否有用,或者哪些字段在用于索引時提供更多好處。
使用后臺工作程序卸載任務
在創(chuàng)建基于 UI 的應用程序時,我們需要 UI 能夠響應,即使正在進行一些繁重的處理任務也是如此。如果在處理大數(shù)據(jù)時 UI 凍結,則處理起來將是一種糟糕的用戶體驗。在網站上,這可以使用 Web Worker 來完成。
對于直接使用 Node 運行的應用程序,我們可以使用 Node 的 worker_threads 模塊將計算密集型任務卸載到后臺線程。
請注意,僅當任務是 CPU 密集型任務(例如排序或解析數(shù)據(jù))時,這才有用。如果任務依賴于 I/O,例如讀取文件或獲取網絡資源,則使用 Node 的 async-await 比使用 worker 更有效。
我們可以按如下方式創(chuàng)建和使用 worker:
const { Worker, isMainThread, parentPort, workerData, } =
require("node:worker_threads");
async function main() {
const data = await fetchData(10);
let sorted = await new Promise((resolve, reject) => {
const worker = new Worker(__filename, {
workerData: data,
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0)
reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
function worker() {
const data = workerData;
sortData(data);
parentPort.postMessage(data);
}
if (isMainThread) {
// we are in the main thread of our application
main().then(() => {
console.log("done");
});
} else {
// we are in the background thread spawned by the main thread
worker();
}
我們首先從 worker_threads 模塊導入所需的函數(shù)和變量聲明。然后我們定義兩個函數(shù) —main(將在主線程中運行)和 worker (將在 worker 線程中運行)。
然后,我們檢查腳本是作為主線程還是作為 worker 線程執(zhí)行,并相應地調用 main/worker 函數(shù)。為了簡單起見,我們在一個文件中定義了所有這些函數(shù),但我們也可以在它們自己的文件中分離出 main 和 worker 函數(shù)。
在 main 函數(shù)中,我們像以前一樣獲取數(shù)據(jù)。然后我們創(chuàng)建一個 Promise,并在其中創(chuàng)建一個新的 worker。Worker 構造函數(shù)需要一個文件路徑,該路徑將作為Worker線程運行。
這里我們使用 __filename builtin 給它相同的文件。在第二個參數(shù)中,我們將要排序的數(shù)據(jù)作為 workerData 傳遞。此 workerData 將由 Node 運行時提供給 worker 線程。
最后,我們監(jiān)聽來自 worker 的事件 — 收到消息時,我們解決 promise,如果出現(xiàn)錯誤或非零退出代碼,我們拒絕 promise。
在 worker 線程中,我們從變量 workerData 中獲取從主線程傳遞的數(shù)據(jù),該變量是從 worker_threads 模塊導入的。在這里,我們對它進行排序,并將一條消息發(fā)布到包含排序數(shù)據(jù)的主線程。
在主線程中,我們可以將其保存在隊列中或定期檢查它,而不是立即等待 promise。這樣,當worker線程進行排序計算時,我們可以保持主線程的響應性。我們還可以從 worker 線程發(fā)送中間消息,指示排序進度。
優(yōu)化 Node 應用程序的常見提示
雖然每個應用程序都有自己的性能優(yōu)化方法,但Node.js應用程序有一些常見的起點。
優(yōu)化前觀察
在開始優(yōu)化應用程序之前,您必須檢測和測量應用程序的性能,以便您可以準確了解哪些函數(shù)或 API/DB 調用需要優(yōu)化。
嘗試進行盲目優(yōu)化可能會降低性能,這就是為什么使用 Node 提供的性能鉤子和 API 進行測量是一個很好的起點。
有一種簡單的方法來重復測量
要確定您的優(yōu)化是否有效,您應該有一種方便的方法來衡量之前和之后的性能。
這可以通過擁有兩個構建來完成 —--- 一個有更改,一個沒有更改,有一個運行測試和測量的腳本,以及可以為您提供比較的東西。為更改提供明確的前后性能值可以幫助您確定這些更改是否值得。
嘗試為數(shù)據(jù)庫編制索引并緩存請求/響應
如果您的應用程序使用數(shù)據(jù)庫并頻繁查詢,則應考慮在查詢的參數(shù)上創(chuàng)建索引,以提高檢索性能。
這將以可能增加存儲大小和可能增加插入/更新查詢時間為代價,因此您應該仔細衡量使用案例中的前后,并確定權衡是否良好。
提高性能的另一種方法是使用一些緩存方案,以便快速響應數(shù)據(jù)庫或 API 查詢。如果您可以使用查詢參數(shù)緩存 API 響應,然后使用此緩存響應以后的請求,則可以有效地使用它。
請注意,緩存是一把雙刃劍。您需要仔細評估保留緩存條目的時間、逐出條目的依據(jù)以及何時使緩存失效。錯誤地執(zhí)行此操作不僅會降低您的性能,而且還有可能在用戶之間發(fā)送不正確的數(shù)據(jù)或泄露的數(shù)據(jù)。
減少依賴性
如果您曾經查看 node_modules 或檢查過node_modules 所占用的磁盤大小,您就會知道 Node 項目中的依賴關系有多嚴重。
添加新的依賴項時需要小心,因為它們可能會添加更多的傳遞依賴項,而解析所有這些依賴項可能會影響應用程序的啟動性能。您可以嘗試通過以下方法緩解此問題:
- 刪除未使用的軟件包 — 有時軟件包中有多個軟件包。JSON 格式這些 ID 不再在應用程序中使用,可以刪除。這對于縮小依賴項的數(shù)量和軟件包的構建大小非常有用
- 使用打包器對
tree-shaking
并從最終構建中刪除未使用的模塊 — 在捆綁和打包應用程序時,您可以使用捆綁器提供的功能從依賴項中刪除未使用的模塊。您只保留代碼使用的依賴項部分,而不將其余部分包含在最終構建中 - 從依賴項中提供所需的特定代碼 — 當您只需要代碼的一小部分時,而不是將整個包添加為依賴項,而是提供代碼的特定部分。執(zhí)行此操作時,請務必檢查并遵守原始代碼的許可證
- 延遲加載依賴項 — 您可以延遲加載依賴項以提高啟動性能,并在不需要該依賴項的情況下減少內存使用量
結論
Node 提供的Performance API 不僅可以幫助確定哪些部分速度較慢,還可以幫助確定它們需要多少時間。您可以通過將這些數(shù)據(jù)作為跟蹤或指標導出到Jaeger或 Prometheus 之類的內容來進一步探索這些數(shù)據(jù)。
請記住 — 擁有大量數(shù)據(jù)只會使其更難探索,因此一個好的策略是首先只測量粗略事件的時間,例如函數(shù)調用甚至請求的端到端處理,然后為花費最多時間的函數(shù)添加越來越多的細粒度測量。
原文地址:https://blog.logrocket.com/node-js-performance-hooks-measurement-apis-optimize-applications/
原文作者:Yashodhan Joshi
本文譯者:一川