iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 24 篇

Day 24 - 壓力測試找出無頭渲染的排隊瓶頸,改成有上限的公平池

  • 分享至 

  • xImage
  •  

前言

上線之後我想知道一件事:這台機器可以同時服務多少人?我心裡有三個猜測。

怎麼壓才不會傷到別人

不要打第三方網站。測匯入功能需要網頁,但拿真的網站來做壓力測試等於對別人發動攻擊。我起一個本機的 fixture 伺服器,提供兩種頁面:一種是靜態文章,一種是內容靠 JavaScript 產生的單頁應用,用來強迫走無頭瀏覽器那條路。

不要打自己的正式站。正式站有真的使用者。我在本機跑正式建置,測完再想辦法折算。

不要用真的模型 API。測 agent 並行時,我做了一個假的模型端點,它會照腳本回覆固定的工具呼叫,每次回應前等固定的時間模擬思考。不花一毛錢,而且結果可重現。

用完就刪。測試會建立臨時帳號,腳本結束時全部刪掉。

三個猜測,三個都錯

猜測一:瀏覽跟搜尋會是瓶頸。

一個「動作」等於開一頁會發的請求(目錄樹、筆記內容、反向連結,加三成機率的搜尋),結果是:

同時連線 動作/秒 p95 延遲
10 2,317 2 ms
50 2,319 9 ms
100 2,304 16 ms
200 2,179 33 ms

十條連線就到每秒兩千三百次上下。圖上二十五條是 2,404,是這組裡最高的,之後不再上去,只剩延遲往上走,全程零錯誤、記憶體平坦。一般瀏覽不是瓶頸。

猜測二:一百個 agent 同時跑會炸掉。

也錯了。十個、五十個、一百個工作區同時編纂,每件工作分別是 5.1、5.5、5.9 秒(單獨跑是 4.9 秒),記憶體最高三百 MB。

事後對得上原因:agent 幾乎所有時間都在等模型回應。等待不吃 CPU。一百個等待跟一個等待的成本差不多。

猜測三:記憶體會是限制。

還是錯。無頭瀏覽器是最耗記憶體的部分,但它固定在四個行程、兩百七十 MB 左右,因為一次只渲染一頁。加上應用本身,離八 GB 還很遠。

真正的瓶頸

是匯入的排隊延遲,而且它是我自己造成的。

之前我們已經提過,無頭渲染排成一條佇列,一次一頁。每頁兩秒多,所以等待時間跟佇列長度成正比:

併發 整批耗時 最慢一筆
1 2.6 s 2.6 s
8 17 s 17 s
16 33 s 33 s

佇列是全域的,不分使用者,也沒有上限。匯入端點掛的是每 IP 的限流,沒有按人算(src/app.ts:75)。十六頁那一列整批 33 秒,大約每頁兩秒。五十個網址排在前面,後面的人要等一分四十秒上下,而那些請求就掛著,直到某處逾時。

修法:有上限的公平池

改成一個排程器,三個參數:

const MAX_CONCURRENT = Math.max(1, Number(process.env.HEADLESS_CONCURRENCY ?? 3));
const MAX_PER_KEY = Math.max(1, Number(process.env.HEADLESS_PER_KEY ?? 1));
const MAX_QUEUE = Math.max(1, Number(process.env.HEADLESS_QUEUE_MAX ?? 24));

出處:src/headless.ts:87-89

全機同時渲染幾頁、單一工作區最多佔幾個位置、佇列排到多深就直接拒絕。

第二個參數不是硬上限。真正的邏輯在挑下一件工作的時候:

function pump(): void {
  while (running < MAX_CONCURRENT && waiting.length > 0) {
    // Prefer a workspace that is still under its share. If every waiting page belongs to a workspace already at its
    // share, take the oldest anyway: an idle slot helps nobody, and a workspace that arrives later still gets picked
    // first as soon as the next slot frees up.
    const under = waiting.findIndex(w => (inFlight.get(w.key) ?? 0) < MAX_PER_KEY);
    const [w] = waiting.splice(under < 0 ? 0 : under, 1);
    running++; inFlight.set(w.key, (inFlight.get(w.key) ?? 0) + 1);
    void w.run().then(w.resolve as (v: unknown) => void, w.reject).finally(() => {
      running--;
      const n = (inFlight.get(w.key) ?? 1) - 1;
      if (n > 0) inFlight.set(w.key, n); else inFlight.delete(w.key);
      pump();
    });
  }
}

出處:src/headless.ts:101-116

under < 0 ? 0 : under 做的是這件事:先找一個還沒用滿配額的工作區;如果每個等待中的工作區都已經滿了,就取最舊的那一件,而不是讓位置空著。

這叫 work-conserving:空著的位置對誰都沒有好處。一個人單獨使用時,配額形同不存在,他可以佔滿整個池;只有別人也在等的時候,配額才把位置讓出來。

效果:

情境 修改前 修改後
一個工作區丟 12 頁 25.3 s 11.2 s
隔壁工作區的第一頁 26.8 s 4.3 s
40 頁同時湧入 全部排隊到逾時 27 筆完成、13 筆立刻回 429

第二列是這張表要看的數字。鄰居從等 26.8 秒變成等 4.3 秒,而且丟大量網址的那個人自己也變快了。四十頁那一列的 27,剛好等於預設的同時 3 頁加上佇列上限 24;多出來的 13 筆在進佇列之前就被拒絕。

拒絕發生在推進佇列之前:

function schedule<T>(key: string, run: () => Promise<T>): Promise<T> {
  if (waiting.length >= MAX_QUEUE) return Promise.reject(new RenderBusyError(waiting.length));
  return new Promise<T>((resolve, reject) => {
    waiting.push({ key, run: run as () => Promise<unknown>, resolve: resolve as (v: never) => void, reject });
    pump();
  });
}

出處:src/headless.ts:117-123

使用者看到的句子是:目前排隊等待轉譯的網頁太多,請稍後再試,或改用「貼上文字」匯入。匯入 API 把這個錯誤對成 429。掛到瀏覽器自己逾時的話,他只會覺得產品壞了。

出處:src/import.ts:242-243、src/import-api.ts:43

請求打到還沒關掉的舊行程

改完排程器跑測試,數字完全沒變。我以為是排程邏輯寫錯,加日誌、寫探針、懷疑鍵值沒傳對,查了很久。

前一次測試留下的舊伺服器行程還佔著那個埠。後啟動的新伺服器綁不上去,所有請求都打到跑著舊程式碼的那個行程。現在測試腳本啟動前會先確認埠沒被佔用,被佔用就中止並說明原因。

那一輪也真的找到一個 bug:公平性的鍵(工作區 ID)在中間一個函式漏傳了,所有請求都落到同一個匿名鍵上,等於還是排一條隊。這個 bug 只有在鄰居測試裡才看得出來,單一使用者的測試從頭到尾都正常。

壓力測試報告:瀏覽吞吐量、延遲、agent 耗時與匯入前後對照

這張就是營運面板上的壓力測試報告。開發機量到的吞吐量,下面折成 Railway Hobby 的估計。

小結

改完之後,丟十二頁的人從 25.3 秒變成 11.2 秒,隔壁工作區的第一頁從 26.8 秒變成 4.3 秒。同時渲染停在三頁;有別人在等,就先把位置讓給還沒用滿的工作區。一次進來四十頁時,十三筆沒有排進去,直接請使用者稍後再試。


上一篇
Day 23 - 備份交給 pg_dump,每週拿 pg_restore 演練還原
下一篇
# Day 25 - 用本機 HTTP 伺服器測模型來回
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言