跳轉到

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),預期會有一半的請求撞到這個限制。

jmeter -n -t login-loadtest.jmx -Jthreads=60 -Jramp=2 -Jloops=1 -l results/login.jtl

結果

指標 數值
總請求數 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 倍。原因對應到程式碼裡的過濾器順序:

http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);

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 則。

jmeter -n -t message-loadtest.jmx -Jthreads=25 -Jramp=10 -Jmessages=20 -l results/message.jtl

結果

指標 數值
訊息發送總數 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.jmxmessage-loadtest.jmxmessage-capacity-test.jmx:JMeter 測試計畫,可用 -J 參數調整執行緒數、爬升時間、持續秒數、目標 port。目前存放於本機的 tools/jmeter-loadtest/(尚未納入版本控制,見上方注意事項)。
  • accounts.csv:對應現有 k6 測試帳號的登入憑證。
  • tokens.csv:容量曲線測試專用,預先登入一批帳號取得的 JWT,避免容量測試被登入端點自身的限流干擾。
  • 執行後用 -e -o <目錄> 參數可產生 JMeter 內建的 HTML 儀表板報告(本次報告內的統計數字即由此產生的 results/*.jtl 原始資料整理而來)。

結論

這次壓測驗證了文件裡描述的機制確實如預期運作:

  1. 登入端點的限流確實擋在成本最低的位置(429 平均 4.6ms vs 200 平均 118.7ms)。
  2. 訊息發送確實因為非同步落庫設計而維持在毫秒等級的低延遲(p99 15.1ms)。
  3. Sentinel 的流控確實在保護後端:經過 Gateway 的路徑穩定卡在一個吞吐量上限、多餘請求直接拒絕;跳過 Sentinel 直連後端則是零錯誤但延遲隨並發數持續惡化,兩相對照具體展示了「先擋在門口、寧可拒絕也不要讓後端過載」這個設計取捨的實際效果。

測試同時以實際數據呈現了依 IP 限流的已知取捨,此取捨在 RateLimitFilter.java 原始碼中已有相應的待辦註記。另外,測試也發現一項尚待釐清的落差:Gateway 對 main-service-route 設定的流控門檻為 300 QPS,實測穩定值僅約為此數值的一半(148-150 QPS)。此差異的成因需搭配 Sentinel Dashboard 的即時監控數據進一步排查,本報告如實記錄觀察結果,不做未經驗證的推論。