
在幫 Uptime Kuma 擴充負載平衡(Load Balancing)與節點切換(Failover)功能的過程中,我意外踩到了一個Node.Js的效能瓶頸:
同一套程式,在家裡的機器可以穩穩跑 1000 個監控,但是放到一台 13 年前的測試機 上時,大概 單一節點跑到 800 個監控就開始頓、反應變慢。
這也逼得我回頭重新檢查,底是我寫壞,還是 Node.js 天生的限制?
Uptime Kuma 本身就是一個高頻輪詢(polling)的系統:每個監控 item 都要定期發出請求、判斷成功或失敗、更新狀態,現在又多了一層:
在新一點的 CPU 上,這些重計算還撐得住;但在那台 13 年前的測試機上,大約 800 個監控 時,以下現象開始出現:
JavaScript 本身是單執行緒,所有同步程式碼都必須依序在 call stack 中執行,一旦在主執行緒裡執行了耗時的同步工作(例如大量計算或死迴圈),整個執行緒就會被 block,其他任務完全無法插隊。
相對地,像 setTimeout、HTTP 請求這類需要等待的操作,並不會直接佔用 call stack。執行環境會先將它們交給底層 API(libuv)處理,等任務完成後,再把對應的 callback 放進佇列中,等待 event loop 在 call stack 空閒時依序取回並執行。
正因如此,event loop 讓 JavaScript 即使只有一條主執行緒,也能透過非同步機制同時安排大量 I/O 工作,而不會因為等待外部資源就讓整個程式停擺。
當監控數量很多、輪詢頻率又很高時**,就會出現一堆「需要重複計算」的邏輯一起擠在同一條 event loop 上。這些計算又不是在等 I/O,而是實打實吃 CPU,久了自然就變成效能瓶頸。
因此在不升級單核 CPU 的前提下,我這邊整理出幾種實際可以提昇效能的解法:
在高頻輪詢的架構裡,很多「重計算」其實是被輪詢驅動的。直接降低輪詢頻率(interval)可以讓 CPU bound 的邏輯觸發次數下降,進而讓 event loop 壓力減輕。
以 Uptime Kuma 為例,每個監控都是一條輪詢任務:例如有 1000 個監控,每 60 秒就要發一次請求、判斷成功/失敗、更新狀態與儀表板。這些動作本身就是吃 CPU 的邏輯,如果 interval 設太短,就會在同一段時間內把一大堆「重計算」一起塞進同一條 event loop。適度拉長輪詢頻率(例如從 15 秒調成 30 秒,或把不那麼關鍵的監控改成更長間隔),可以直接減少這些 CPU-bound 邏輯被觸發的次數,讓 event loop 壓力下降、整體卡頓感也會跟著改善。
當單機資源有限時,將監控分散到多台節點是更穩健的做法。每個節點只負責一部分監控,狀態透過共用儲存(DB/Redis)統一,前面可加反向代理或任務分配層。
參考:我對多節點/Cluster 的思路與做法整理在這篇文章,可作為延伸閱讀:Uptime Kuma Cluster 實作筆記
在和同事討論 Uptime Kuma 的時候,他建議我可以試試 Bun,所以我就順勢往下研究了一下。Bun 本質上是一個主打高效能的 JavaScript Runtime,特色大致有:
bun install)等等註:此段為我目前的研究紀錄,尚未在專案中完成實作;內容著重思路與示意,非落地方案。
我研究的做法是 針對「重計算」本身下手,把最吃 CPU 的那一塊抽出去,丟給 worker_threads 處理。
概念上就是:主執行緒負責:
而真正重的邏輯,搬去 worker 檔案裡去跑。
一個簡化版的程式骨架大概像這樣(示意,不是完整程式):
// main.js
const { Worker } = require("worker_threads");
function recalcLoadBalancing(monitors) {
return new Promise((resolve, reject) => {
const worker = new Worker("./recalc-worker.js", {
workerData: { monitors },
});
worker.on("message", (result) => resolve(result));
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited: ${code}`));
});
});
}
// recalc-worker.js
const { parentPort, workerData } = require("worker_threads");
function heavyRecalc(monitors) {
// 在這裡做複雜、吃 CPU 的演算法
// 回傳新的節點權重 / 排序結果等等
return { /* ... */ };
}
const result = heavyRecalc(workerData.monitors);
parentPort.postMessage(result);
這樣一來:
註:此段為研究方向與可行性分析,尚未實作於現有服務。
如果你的服務本身是 多使用者、多請求的 Web API,除了把重計算抽出去,其實也可以再用 Cluster / 多 process 來分散負載。
概念類似:
cluster 或 PM2 把同一個 Node.js 應用跑成多個 processconst cluster = require("cluster");
const os = require("os");
if (cluster.isMaster) {
const numCPUs = os.cpus().length;
console.log(`Master process ${process.pid} is running`);
console.log(`Forking ${numCPUs} workers...`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died, restarting...`);
cluster.fork();
});
} else {
// Worker process - 執行實際的應用邏輯
require("./app.js");
console.log(`Worker ${process.pid} started`);
}
| 方案 | 適用場景 | 優點 | 缺點 |
|---|---|---|---|
| 降低輪詢頻率(interval) | 高頻輪詢導致壓力 | 減少重計算次數、降低主迴圈負載 | 反應時間變慢、可能漏短暫故障 |
| 多節點(水平擴充) | 單機資源有限、需要擴展 | 分散負載、故障隔離、易於橫向擴張 | 跨節點同步與配置複雜度提高 |
| Bun | 新腳本、獨立服務 | 執行速度快、啟動快 | 相容性還不是 100% |
| Worker Threads | CPU bound 重計算 | 不阻塞主執行緒、可利用多核心 | 需要處理資料序列化 |
| Cluster | 多請求 Web 服務 | 多 process 分散負載、容錯性高 | 狀態需共用儲存 |
如果你現在也在幫其他 Node.js 處理效能問題,又剛好卡在老機器跑不動,可以試試以上方法。