Classcord 壓力測試報告¶
工具:Apache JMeter 5.6.3。測試計畫與資料檔存放在專案的 tools/jmeter-loadtest/ 底下,可重複執行。
注意:專案的
.gitignore目前整個排除了/tools資料夾(沿用既有 k6 壓測腳本的做法),所以這份測試計畫目前只存在本機,尚未進版本控制、也不在 GitHub 上。
測試環境¶
- 本機開發環境:Gateway(:8080)、Main Service(:8081)皆為單一實例,資料層(PostgreSQL / Redis / RabbitMQ)皆為 Docker Compose 起的單容器,未做任何正式環境等級的資源配置。
- 所有請求都從同一台機器發出,經 Gateway 轉發至 Main Service,並未經過 nginx / K3s / 多節點負載平衡等正式環境才有的層級。
- 測試帳號:借用專案既有的 k6 壓測種子腳本(
tools/k6-presence-load/seed-test-accounts.sql)產生的 200 組測試帳號(密碼統一LoadTest123!),這些帳號已預先加入同一個測試班級伺服器。
這裡的目的是驗證特定機制在併發下的實際行為(限流是否真的擋在對的地方、非同步設計是否真的降低了回應延遲),而不是測出一個代表正式環境承載能力的絕對數字——單機單容器的條件跟正式環境的兩台 Droplet + K3s 拓撲並不相同。
測試一:登入端點限流行為(POST /v1/auth/login)¶
設計¶
Main Service 的 RateLimitFilter 對 /v1/auth/** 的寫入型請求,依來源 IP 做限流:同一 IP 每 60 秒最多 30 次。測試計畫用 60 個執行緒、2 秒內全部發出登入請求,全部來自同一台機器(同一個來源 IP),預期會有一半的請求撞到這個限制。
結果¶
| 指標 | 數值 |
|---|---|
| 總請求數 | 60 |
| 200 OK | 30 |
| 429 Too Many Requests | 30 |
| 錯誤率 | 50.0% |
延遲依回應碼拆開來看,差異非常明顯:
| 回應碼 | 樣本數 | 平均延遲 | 最小 | 最大 |
|---|---|---|---|---|
| 200(登入成功) | 30 | 118.7ms | 109ms | 146ms |
| 429(被限流擋下) | 30 | 4.6ms | 2ms | 21ms |
解讀¶
擋下的請求(429)平均只花 4.6ms,成功的請求(200)平均卻要 118.7ms——差了超過 25 倍。原因對應到程式碼裡的過濾器順序:
RateLimitFilter 被安排在認證流程最前面,一旦判定超過限流門檻,會直接寫入 429 回應並 return,根本不會走到後面真正的登入邏輯——也就不會觸發密碼雜湊比對、更不會呼叫外部的 Cloudflare Turnstile 驗證 API(真正登入成功的請求,延遲有很大一部分就是花在這次外部 API 呼叫上)。這個測試結果驗證了一個設計原則:限流要擋在最便宜的地方,被拒絕的請求不應該先付出跟正常請求一樣的成本才被拒絕。
依 IP 限流的已知取捨¶
由於本次測試的所有請求皆來自同一台機器(同一個來源 IP),在同一 60 秒視窗內連續執行多組測試時,觀察到後續測試的登入請求會被前一組測試已累積的計數器(RATE_LIMIT:IP:127.0.0.1)連帶擋下,導致依賴登入取得 JWT 的後續請求一併失敗。
這反映了依 IP 限流的一個已知取捨:若多個真實使用者共用同一個對外 IP(例如同一企業網路、同一行動網路的 NAT 出口),會被系統視為同一來源,其中任一使用者被判定濫用時,會連帶影響其他使用者。這是採用「依 IP」而非「依帳號」限流時必須接受的代價,RateLimitFilter.java 原始碼中也留有相應的待辦事項:「可考慮改為依帳號(Email)+IP 做限制」。
測試二:訊息發送吞吐量(POST /v1/channels/{channelId}/messages)¶
設計¶
每個虛擬使用者先登入一次取得 JWT,再連續發送 20 則訊息,用來觀察 Main Service 的訊息發送設計(預先生成 UUIDv7、直接回應,實際寫入資料庫的工作丟進 RabbitMQ 非同步處理)在有意義的併發量下,回應延遲是否真的不受資料庫寫入速度拖累。25 個執行緒、10 秒內完成登入(刻意錯開、避免撞到測試一的限流門檻),每人發送 20 則訊息,共 500 則。
結果¶
| 指標 | 數值 |
|---|---|
| 訊息發送總數 | 500 |
| 成功(201 Created) | 500(100%) |
| 錯誤 | 0 |
| 整體吞吐量 | 約 52 req/s(含登入請求) |
延遲分佈:
| 分位 | 延遲 |
|---|---|
| 最小 | 4ms |
| 平均 | 7.6ms |
| 中位數 | 7.0ms |
| p90 | 10.0ms |
| p95 | 12.0ms |
| p99 | 15.1ms |
| 最大 | 31ms |
解讀¶
500 次訊息發送、0 個錯誤,p99 延遲僅 15.1ms,且延遲分佈非常集中(p50 到 p99 只差 8ms 左右,沒有長尾)。跟同一批測試裡的登入請求(平均 112.9ms,主要花在外部 Turnstile 呼叫)對照,訊息發送快了超過一個數量級——這與 07 · Main Service 提到的設計一致:sendMessage() 不等資料庫寫入完成就回應,真正的持久化工作交給背景消費者處理,使用者端感受到的延遲只取決於處理 HTTP 請求與廣播本身的速度。
在單機單容器、僅 500 筆訊息的規模下沒有觀察到明顯的佇列堆積或延遲上升;更高流量下 RabbitMQ 消費速度與資料庫寫入速度是否會成為瓶頸,需要更大規模、更長時間的測試才能驗證,這裡先不做超出測試範圍的推論。
測試三:訊息端點容量曲線(QPS)¶
前兩項測試驗證的是機制的正確性,本項測試則直接量測端點的吞吐量上限。做法是預先登入取得一批 JWT(跳過登入流程與其限流,避免干擾量測),對同一端點 POST /v1/channels/{channelId}/messages 以不同並發執行緒數、各持續 10 秒發送請求,觀察吞吐量與延遲隨並發數的變化。測試分別針對兩條路徑執行:經過 Gateway(:8080,Sentinel 流控生效)、直接請求 Main Service(:8081,未經 Sentinel)。
jmeter -n -t message-capacity-test.jmx -Jport=8080 -Jthreads=100 -Jramp=2 -Jduration=10 -l results/capacity/p8080_t100.jtl
jmeter -n -t message-capacity-test.jmx -Jport=8081 -Jthreads=100 -Jramp=2 -Jduration=10 -l results/capacity/p8081_t100.jtl
路徑一:經過 Gateway(:8080,Sentinel 流控生效)¶
| 並發執行緒 | 總請求數 | 成功數 | 錯誤率 | 成功 QPS | 平均延遲 |
|---|---|---|---|---|---|
| 5 | 58,582 | 1,494 | 97.4% | 150.4 | 10.7ms |
| 10 | 53,867 | 1,481 | 97.3% | 149.1 | 21.4ms |
| 25 | 59,065 | 1,468 | 97.5% | 147.8 | 52.0ms |
| 50 | 58,700 | 1,469 | 97.5% | 147.8 | 113.9ms |
| 100 | 69,137 | 1,475 | 97.9% | 148.5 | 220.8ms |
| 200 | 45,683 | 1,469 | 96.8% | 147.9 | 1180.3ms |
觀察:不管開多少執行緒(5 到 200),成功處理的請求數穩定卡在每秒約 148-150 次,多打的請求全部收到 429 被擋下。這是 Gateway 的 Sentinel 流控 在生效——main-service-route 這條路由設定的門檻是程式碼裡寫的 300 QPS,實測穩定值大約是這個數字的一半,確切原因(Sentinel 滑動視窗取樣方式、或是無 think-time 高併發下的統計效應)還需要搭配 Sentinel Dashboard 的即時指標才能進一步確認,這裡先如實記錄觀察到的數字,不做超出測試範圍的臆測。結論是:透過 Gateway 這條路徑,無論後端能撐多少,客戶端實際感受到的容量上限就是這個流控門檻——這正是 Sentinel 存在的目的:保護後端不被瞬間流量打垮,代價是超過門檻的請求會被直接拒絕而非排隊等待。
路徑二:直連 Main Service(:8081,無流控)¶
| 並發執行緒 | 總請求數 | 成功數 | 錯誤率 | 成功 QPS | 平均延遲 | p95 延遲 |
|---|---|---|---|---|---|---|
| 5 | 1,624 | 1,624 | 0% | 163.5 | 28.1ms | 45ms |
| 10 | 1,098 | 1,098 | 0% | 110.6 | 83.0ms | 182ms |
| 25 | 2,495 | 2,495 | 0% | 251.6 | 90.5ms | 114ms |
| 50 | 2,435 | 2,435 | 0% | 245.5 | 187.2ms | 230ms |
| 100 | 2,247 | 2,247 | 0% | 226.6 | 410.2ms | 525ms |
| 200 | 1,385 | 1,385 | 0% | 139.4 | 1429.5ms | 2044ms |
觀察:跳過 Sentinel 後,全程錯誤率為 0(沒有任何請求被拒絕),但吞吐量呈現典型的容量曲線:並發數 25-50 左右吞吐量達到高點(約 245-252 QPS),此後繼續增加並發數,吞吐量不再上升、延遲反而顯著攀升(100 個並發時 p95 已達 525ms,200 個並發時 p95 達 2 秒)。這是本機單一容器(單一 Postgres、單一 RabbitMQ、單一 Main Service 實例,且與本機其他常駐服務共用 CPU 資源)達到資源飽和的典型訊號,而非程式邏輯本身的缺陷。
路徑比較¶
| 經 Gateway(Sentinel 生效) | 直連 Main Service | |
|---|---|---|
| 穩定吞吐量 | 約 148-150 QPS | 約 226-252 QPS(並發 25-100 之間) |
| 錯誤率 | 高(超過門檻的請求被拒絕) | 0% |
| 延遲隨並發數增加的表現 | 被拒絕的請求延遲很低,成功的請求延遲平穩 | 延遲隨並發數增加而持續惡化,無上限保護 |
這組對照直接呈現了 Sentinel 流控的實際效益:未受流控保護的路徑,延遲會隨並發數上升而持續惡化,直至失控(200 個並發時 p95 達 2 秒);受流控保護的路徑,則是提早拒絕超額請求,換取通過請求維持穩定、可預期的延遲。 本次測試規模尚未使 Main Service 產生錯誤,但延遲已明顯劣化,這正是前置流量管制設計所要避免的情境。
測試工具與可重現性¶
login-loadtest.jmx、message-loadtest.jmx、message-capacity-test.jmx:JMeter 測試計畫,可用-J參數調整執行緒數、爬升時間、持續秒數、目標 port。目前存放於本機的tools/jmeter-loadtest/(尚未納入版本控制,見上方注意事項)。accounts.csv:對應現有 k6 測試帳號的登入憑證。tokens.csv:容量曲線測試專用,預先登入一批帳號取得的 JWT,避免容量測試被登入端點自身的限流干擾。- 執行後用
-e -o <目錄>參數可產生 JMeter 內建的 HTML 儀表板報告(本次報告內的統計數字即由此產生的results/*.jtl原始資料整理而來)。
結論¶
這次壓測驗證了文件裡描述的機制確實如預期運作:
- 登入端點的限流確實擋在成本最低的位置(429 平均 4.6ms vs 200 平均 118.7ms)。
- 訊息發送確實因為非同步落庫設計而維持在毫秒等級的低延遲(p99 15.1ms)。
- Sentinel 的流控確實在保護後端:經過 Gateway 的路徑穩定卡在一個吞吐量上限、多餘請求直接拒絕;跳過 Sentinel 直連後端則是零錯誤但延遲隨並發數持續惡化,兩相對照具體展示了「先擋在門口、寧可拒絕也不要讓後端過載」這個設計取捨的實際效果。
測試同時以實際數據呈現了依 IP 限流的已知取捨,此取捨在 RateLimitFilter.java 原始碼中已有相應的待辦註記。另外,測試也發現一項尚待釐清的落差:Gateway 對 main-service-route 設定的流控門檻為 300 QPS,實測穩定值僅約為此數值的一半(148-150 QPS)。此差異的成因需搭配 Sentinel Dashboard 的即時監控數據進一步排查,本報告如實記錄觀察結果,不做未經驗證的推論。