Day 19:幫協程模型量身打造一場效能比較 把該準備的都準備好了:公平比較的規則講清楚了,k6 定案為全系列壓測工具,模擬促銷流量爬升的測試情境也設計完成。昨天結尾說得很白,萬事俱備,只剩下實際執行。今天就是把測試真正跑一次,看看數據會說什麼。
不重新解釋測試設計的邏輯,Day 19 已經講得很完整:一個維持 Thread-per-Request 搭配傳統阻塞式邏輯,一個改寫成 suspend function 搭配 WebFlux 與 R2DBC,其餘條件盡量對等,用 k6 模擬虛擬使用者數量從平常水準逐步爬升到促銷尖峰水準的流量情境。今天要依序看三組指標:回應時間、每秒可處理請求數與錯誤率,最後留一整節篇幅誠實面對協程模型的代價,不會只挑好看的數字講。
在進入數據之前,有一件事必須先說清楚。以下呈現的所有數字,都是示意性質的假設結果,用來說明解讀這類測試結果時該關注的方向與判斷邏輯,並非某一次實際執行後量測到的真實數字。如果你打算真正驗證協程模型在自己的服務上能帶來多少差異,務必依照 Day 19 建立的方法論,在自己的環境裡實際跑一次 k6 測試,實際數字會因為機器規格、資料庫版本、網路狀況而有落差,這裡示意的數字只用來示範解讀的角度,不能直接拿來當作你的系統會有的表現。
先看回應時間。這裡關注兩個指標,平均回應時間,以及 p95 分位數回應時間,也就是 95% 的請求都能在這個時間內得到回應,用來反映尾端延遲的狀況,比平均值更能呈現使用者實際感受到的最差情況。
下面這張表是示意用途,數字為假設情境,不是真實測得的結果,實際數字請以自行測試環境為準。
| 併發階段 | 模型 | 平均回應時間,示意 | p95 回應時間,示意 |
|---|---|---|---|
| 低併發,日常流量 | Thread-per-Request | 假設 45 毫秒 | 假設 90 毫秒 |
| 低併發,日常流量 | 協程 + WebFlux + R2DBC | 假設 48 毫秒 | 假設 95 毫秒 |
| 高併發,促銷尖峰 | Thread-per-Request | 假設 620 毫秒 | 假設 2100 毫秒 |
| 高併發,促銷尖峰 | 協程 + WebFlux + R2DBC | 假設 180 毫秒 | 假設 420 毫秒 |

再次提醒,上面這張表與圖表都是示意數據,用來說明解讀邏輯,不是真實測試結果。
假設你實際跑出來的走勢確實符合這個示意方向,可以這樣解讀:低併發階段,兩者的回應時間差異不明顯,甚至協程版本可能因為額外的協程排程與非阻塞鏈路開銷,數字略高於傳統阻塞式寫法。這一點不需要意外,Day 04:小結:把 Thread 模型與協程模型放在一起比一次 當時的質化對照就提醒過,表格呈現的差異只在特定情境下才會被放大,不是協程「全面優於」Thread-per-Request。日常流量下,執行緒池根本沒被打滿,阻塞等待也還沒累積成看得見的排隊延遲,協程能省下的等待成本自然也還沒有發揮的空間。
真正的分歧出現在流量爬升到促銷尖峰之後。示意數字裡,Thread-per-Request 的 p95 回應時間從幾十毫秒暴衝到超過兩秒,協程版本的增長幅度則平緩許多。這正是 Day 4 質化對照打算驗證的方向,量化數據如果站得住腳,代表當時建立的直覺是對的:執行緒池被大量阻塞等待的請求占滿後,排隊時間急遽拉長,而協程版本因為執行緒不再被白白占住,能撐住更高的併發量才開始出現明顯的回應時間惡化。這裡要強調一次,如果你實際測出來的走勢跟這裡的示意方向不一致,例如兩者在高併發階段差異依然不大,應該誠實記錄這個現象,回頭檢查是不是控制變因沒做好,或者這次測試的等待密集程度本來就不夠高,而不是硬把數據套進預期的結論裡。
回應時間只呈現了使用者感受到的延遲,另一個同樣重要的面向是系統整體的吞吐能力,以及在高壓下還能不能維持穩定,這裡看每秒可處理的請求數量,Requests Per Second,以及錯誤率或逾時比例的變化情形。
以下同樣是示意數據,不是真實測試結果。
| 併發階段 | 模型 | 每秒可處理請求數,示意 | 錯誤或逾時比例,示意 |
|---|---|---|---|
| 低併發,日常流量 | Thread-per-Request | 假設 480 | 假設 0% |
| 低併發,日常流量 | 協程 + WebFlux + R2DBC | 假設 470 | 假設 0% |
| 高併發,促銷尖峰 | Thread-per-Request | 假設 210,成長趨緩 | 假設 18% |
| 高併發,促銷尖峰 | 協程 + WebFlux + R2DBC | 假設 850,持續成長 | 假設 2% |
如果你的實測結果也呈現類似的走勢,這裡的解讀方向可以這樣理解。Thread-per-Request 版本在併發數量超過執行緒池上限之後,每秒可處理請求數不再隨著虛擬使用者數量增加而成長,甚至可能下滑,因為多出來的請求只能在容器層排隊,錯誤率或逾時比例也跟著明顯上升。這不是巧合,正是 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 當時定案的機制在數據上的具體展現:執行緒池的容量固定,一旦同時處理中的請求數量逼近上限,多出來的請求只能排隊,而排隊等候本身就會轉化成逾時。
協程版本因為不受限於一對一的執行緒配置,理論上能撐住更高的併發量才開始出現明顯的錯誤率上升,每秒可處理請求數的成長曲線也能維持得比較久。這裡刻意用「理論上」而不是斬釘截鐵地宣稱結果一定如此,因為這仍然要看實際測試環境的執行緒池設定、R2DBC 連線池大小、資料庫本身能承受的負載,任何一個環節設定不當,都可能讓實測結果偏離這個方向。量測方法論到這裡已經足夠清楚,足以讓 《Day 26:Metrics 與監控,量化你的協程效能表現》 導入持續監控時直接沿用同一套指標定義,也足以讓 《Day 27:壓力測試,驗證系統在高併發下真的撐得住》 延用同一套 k6 腳本邏輯做更完整的壓測,不需要重新設計方法論。
看到這裡,協程版本在回應時間與吞吐量上的示意數字都相對好看,很容易讓人得出「協程模型全面勝出」的結論。這正是這個系列從第一天就想避免的態度,效能數字好看不代表沒有代價,接下來三件事必須誠實攤開來講。
第一件事跟效能數字本身無關,但實務採用時是不能忽視的成本。協程模型的程式碼撰寫與除錯門檻,明顯高於傳統阻塞式寫法。Day 14:小結:三種技術選型的判斷依據,MVC、WebFlux 各用在哪 已經提過,WebFlux 搭配協程與 R2DBC 這套組合下的堆疊追蹤與例外處理,不像 Thread-per-Request 模型那樣直覺,團隊如果缺乏這方面的除錯經驗,半夜出狀況時能不能看懂發生了什麼事,是效能數字完全反映不出來的風險。這件事不會因為 p95 回應時間好看就自動消失,選型時仍然要把這項成本放進權衡。
第二件事回到低併發階段的示意數字。前面回應時間那節其實已經點出來了,日常流量下協程版本的表現不一定明顯優於傳統模型,甚至可能因為額外的排程與非阻塞鏈路開銷,數字略遜於簡單直接的阻塞式寫法。如果你的實測結果也呈現這個現象,不需要迴避,這正好印證 Day 14:小結:三種技術選型的判斷依據,MVC、WebFlux 各用在哪 已經建立的判斷依據:協程與非阻塞架構的優勢,在等待密集、高併發的情境下才會顯著放大,不是任何情境下都穩贏。如果你的服務本身併發規模不大,或者請求處理過程中大部分時間花在運算而非等待外部回應,導入協程模型付出的學習成本與除錯門檻,換來的效益可能相當有限,甚至不划算。
第三件事跟連線池有關。如果測試過程中觀察到協程版本在高併發階段,回應時間或錯誤率並沒有像示意數字那樣持續維持優勢,反而在某個併發水位之後同樣開始出現排隊或逾時的現象,這不代表協程模型失效,而很可能是 Day 18:協程遇上 R2DBC 連線池,小心連線被偷偷耗盡 已經提醒過的風險真正發生了。協程確實不受限於一對一的執行緒配置,但底層的 R2DBC 連線池大小依然是固定的,一旦同時湧入的請求數量遠超過連線池能提供的連線數,等待連線的協程一樣會排隊,等待行為雖然是暫停而非阻塞,但供需失衡依然會拉長回應時間。效能提升不代表可以忽視這些控制手段的必要性,這正是本階段從 Day 15 到 Day 18 反覆強調的立場,協程解決的是執行緒被白白浪費的問題,不是讓所有有限資源都憑空變得無限。
這場測試,無論你實際測出來的數字最終落在哪裡,印證的方向應該離示意的解讀邏輯不遠:協程模型在高併發、等待密集的情境下,確實能帶來實際可量測的效益,回應時間增長更平緩,能撐住的吞吐量也更高。但這場測試同樣誠實揭露了協程並非萬能,除錯門檻更高、在低併發情境下優勢不明顯、連線池這類有限資源依然可能成為瓶頸,這三件事都不會因為效能數字好看就自動解決。
從 Day 15:併發限制,用 Semaphore 保護下游別被打爆 到今天這六天,陸續累積了節流、逾時取消、背壓、連線池搭配,再加上今天這套效能量測方法論,五樣工具已經放進手邊的工具箱。這些工具各自處理不同層次的問題,也各自有各自的取捨,接下來該做的,是把它們整理成一份完整、能隨時查閱的工具箱,而不是讓每一種手段各自散落在不同的章節裡。
《Day 21:小結,高併發控制的工具箱總覽,準備進實戰》 會正式做這件事,把併發深化期七天累積的所有控制手段收攏進同一張地圖,準備帶著這套完整的工具箱,正式進入實戰整合期。