iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

Spring Boot + Kotlin 協程高併發,後端開發新選擇系列 第 28 篇

Day 27:壓力測試,驗證系統在高併發下真的撐得住

  • 分享至 

  • xImage
  •  

Day 26:Metrics 與監控,量化你的協程效能表現 結尾留下一句話,講得直接:這些監控機制目前為止還只是安靜地掛在那裡,等著哪天真正被高併發流量考驗一次。Semaphore 的等候時間曲線會不會如預期般在流量攀升時墊高,連線池的使用中連線數量會不會真的在尖峰時段貼著上限,樂觀鎖的版本衝突計數器會不會如實反映衝突頻率,這些問題到今天都還沒有答案。今天要做的事情很單純,拿一場真正的高併發流量,把前面五天陸續裝進訂單服務實戰整合版的所有防護,一次考驗過一輪,而不是繼續停留在推測階段。

同一套方法論,換一個更完整的考驗對象

這場測試不需要重新設計。Day 19:幫協程模型量身打造一場效能比較 已經把公平比較的規則、量測指標的選擇、k6 這套工具的定案理由都講得很完整,本篇的測試腳本延續 Day 19 建立的邏輯,只是這次測試對象從單純的協程與 Thread-per-Request 對照,換成完整裝上所有控制手段的訂單服務實戰整合版。回應時間、每秒可處理請求數、錯誤率,這三個 Day 19 定案的量測指標名字不會變,漸進式流量爬升的測試階段設計也照搬過來,唯一真正不同的地方在於這次打的對象。它已經不是一支乾淨的對照組 API,身上掛著 Semaphore、逾時控制、樂觀鎖、監控埋點,是一支完整武裝過的服務。

Day 19、Day 20 那場測試回答的問題是「協程模型本身值不值得選」,是選型層次的驗證。今天這場測試回答的問題是「選完之後,實際裝上去的每一道防護,真的有發揮作用嗎」,是實作層次的驗證。兩場測試用的是同一套尺,量的卻是不同階段的東西。

這場測試要回答三個具體問題

跑測試之前,先把要驗證的東西列清楚,否則測試很容易變成單純看一堆數字跑出來好不好看,卻答不出任何具體問題。這場測試鎖定三個問題,分別回扣 Day 23、Day 24、Day 26 建立的三項機制。

第一個問題,回扣 Day 23:把併發控制手段實際裝進訂單服務。stockCheckSemaphore 的 permits 設定為 20,這個數字從 Day 15 定案以來就只是一個假設值,從沒被實際流量驗證過。當併發流量超過這個上限,排隊等候的行為是否符合預期,這件事需要親眼看到協程真的在排隊,而非無限制地一擁而上打向下游服務。

第二個問題,回扣 Day 24:庫存扣減的併發正確性,用資料庫機制取代分散式鎖。Day 24 那個經典情境,某筆商品庫存剩下 1 件、兩個請求幾乎同時搶購,當時只用時間序列圖推演過邏輯上會出錯與不會出錯的差異,從沒有真的拿大量併發請求去驗證樂觀鎖是否真的守住了這條底線。今天要驗證的是,就算把搶購的併發規模拉高到遠超過一件商品能承受的程度,資料庫裡最終的庫存數字是不是依然正確,不多不少。

第三個問題,回扣 Day 26:Metrics 與監控,量化你的協程效能表現。stock_check.semaphore.wait 這個 Timer、stock.deduct.version_conflict 這個 Counter,能不能在測試過程中如實反映當下發生的排隊與衝突狀況,還是掛在那裡卻量不出東西,或者量出來的數字跟實際發生的狀況對不上。監控機制本身,今天也要接受一次驗證。

測試情境設計,把搶購場景直接搬進腳本

流量爬升的整體節奏不重新設計,延用 Day 19 已經建立的漸進式階段:低併發模擬平常流量,接著逐步攀升,最後維持在促銷尖峰水位一段時間再回落。這裡不重複展開設計邏輯,讀者可以直接回頭參考 Day 19 的完整說明。

今天真正要新增的,是一個 Day 19 當時沒有處理過的測試情境:多個請求同時搶購同一筆庫存極低商品。Day 19 那場測試打的是一般訂單查詢端點,請求之間彼此獨立,不會互相競爭同一筆資料。但今天要驗證的是 Day 24 建立的機制,測試腳本必須刻意製造高衝突機率,讓大量虛擬使用者集中鎖定同一筆商品去搶購,才有機會真正把樂觀鎖逼到會發生版本衝突的程度。這裡要先說明一件事,Day 24 當時只定案到 StockService.deductStockOptimistic 這個 Service 層方法,系列還沒有定案任何對外開放的扣庫存 HTTP 端點,今天為了讓 k6 能真正打到這個方法,補上一個最小限度的 Controller 入口,單純轉呼叫 deductStockOptimistic,不涉及其他新邏輯,只是壓測需要的入口,不是新的骨架設計。

把這個情境拆成兩條獨立的測試路徑會比較清楚。第一條路徑延用 Day 19 的一般訂單查詢流量,打散在多筆不同的訂單編號上,用來觀察 Semaphore 排隊與系統整體吞吐量。第二條路徑刻意集中火力,讓所有虛擬使用者在尖峰階段同時對準同一筆庫存極低的商品發出扣庫存請求,用來驗證樂觀鎖的正確性。k6 允許用 tags 替不同情境的請求標上名稱,讓結果統計與 thresholds 可以分開檢視,不需要為了區分兩種情境另外跑兩次測試:

export const options = {
  scenarios: {
    order_query_ramp: {
      executor: 'ramping-vus',
      exec: 'queryOrders',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 20 },
        { duration: '2m', target: 20 },
        { duration: '1m', target: 200 },
        { duration: '3m', target: 200 },
        { duration: '1m', target: 0 },
      ],
    },
    hot_item_rush: {
      executor: 'ramping-vus',
      exec: 'rushLimitedStock',
      startVUs: 0,
      startTime: '2m',
      stages: [
        { duration: '1m', target: 300 },
        { duration: '2m', target: 300 },
        { duration: '30s', target: 0 },
      ],
    },
  },
  thresholds: {
    'http_req_duration{scenario:order_query_ramp}': ['p(95)<1000'],
    'http_req_failed{scenario:order_query_ramp}': ['rate<0.05'],
  },
};

export function queryOrders() {
  http.get(`${BASE_URL}/orders/${randomOrderId()}`);
}

export function rushLimitedStock() {
  http.post(`${BASE_URL}/stocks/${LIMITED_STOCK_ID}/deduct`, null, {
    tags: { name: 'rush_limited_stock' },
  });
}

hot_item_rush 這個情境的 startTime 設在第二分鐘,刻意讓它在一般查詢流量已經進入攀升階段之後才加進來,模擬促銷倒數結束那一刻,搶購請求疊加在既有流量之上,而不是從一開始就單獨測試。rushLimitedStock 這支函式固定打向同一個 LIMITED_STOCK_ID,目標就是製造 Day 24 那個「庫存剩 1 件、多個請求同時搶購」情境,只是這次把兩個請求的規模放大到 300 個虛擬使用者同時搶購。thresholds 這裡只針對一般查詢情境設了門檻,搶購情境的重點不在回應時間是否夠快,而在最終資料正不正確,這件事沒辦法用 k6 的 thresholds 直接驗證,得測試結束後回頭查資料庫才能確認,等一下會回到這一點。

k6 兩個測試情境的虛擬使用者數量曲線,order_query_ramp 從 20 爬升到 200 VUs,hot_item_rush 在第 2 分鐘疊加進來並爬升到 300 VUs,兩個情境在第 2 分鐘到第 5.5 分鐘同時存在

執行測試,逐一對照三個問題看結果

在往下看任何數字之前,必須先把話說在前頭。以下呈現的所有數字,都是示意性質的假設結果,用來說明解讀這類測試結果時該關注的方向,並非某一次實際執行後量測到的真實數字。如果你打算真正驗證自己專案裡的這些機制,務必依照這裡的腳本邏輯,在自己的環境裡實際跑一次,實際數字會因為機器規格、資料庫版本、permits 設定值而有落差。

先看第一個問題,Semaphore 排隊行為。測試進入促銷尖峰階段後,stock_check.semaphore.wait 這個 Timer 記錄到的等候時間分布,示意走勢是這樣:低併發階段,p95 等候時間幾乎貼著零,代表 20 個名額綽綽有餘;流量攀升到 200 個虛擬使用者之後,p95 等候時間開始明顯墊高,假設從幾毫秒拉長到數百毫秒的量級。這個走勢符合預期,代表 Semaphore 確實把超額的併發請求擋在外面排隊,而不是放任它們一股腦全部打向下游的確認庫存服務。排隊本身不是壞事,這正是 Day 15 設計 Semaphore 節流時想要的效果,用可控的排隊延遲,換取下游服務不被瞬間打爆。

stock_check.semaphore.wait 的 p95 等候時間示意走勢,低併發階段幾乎貼著零,攀升與尖峰階段墊高到數百毫秒,回落後降回,尖峰階段標示一條使用者可接受等候上限的參考線

再看第二個問題,樂觀鎖在高衝突情境下的正確性,這是今天最關鍵的驗證。hot_item_rush 這個情境結束後,先看 stock.deduct.version_conflict 這個 Counter 的示意數字:假設 300 個虛擬使用者對同一筆庫存為 1 的商品發出搶購請求,測試過程中這個計數器累加到接近 300 次左右的版本衝突。這個數字本身不是重點,衝突次數多寡只反映當下的併發激烈程度,真正決定這次驗證成不成立的,是測試結束後回頭查一次資料庫,這筆商品的 quantity 欄位最終停在什麼數字。邏輯正確的話,不管衝突發生了幾次,最終結果應該只有一筆請求扣減成功,庫存從 1 變成 0,其餘所有請求都應該在 affectedRows > 0 這個判斷上得到 false,收到「搶購失敗」的回應,不會出現庫存被扣成負數,也不會出現兩筆以上的請求都收到購買成功的回應。這件事沒辦法只看 k6 的統計摘要就下結論,必須額外寫一段查詢,測試跑完之後手動核對資料庫裡的實際庫存數字,這一步不能省略。

第三個問題,監控指標本身是否如實反映。把測試過程中觀察到的三件事對照起來看:stock_check.semaphore.wait 的等候時間曲線墊高的時間點,是否正好對應 k6 回報的虛擬使用者數量開始超過 20 這個門檻的時間點;stock.deduct.version_conflict 開始累加的時間點,是否正好對應 hot_item_rush 這個情境的 startTime;R2DBC 連線池的使用中連線數量,是否也在同一段尖峰期間跟著升高。如果這三條時間軸能對得起來,代表 Day 26 建立的監控機制不只是形式上掛著,而是真的在正確的時間點、以正確的幅度反映了系統內部實際發生的狀況。監控機制本身的驗證方式,看的不是它記了多少數字,是它記錄的時間點與幅度是否與實際發生的事件吻合。

誠實檢視,哪些符合預期,哪些沒有

如果測試結果全部完美符合預期,這篇文章寫到上一節就可以收尾了,但這種事很少發生,也不該是讀者對壓力測試該有的期待。壓力測試存在的意義,本來就是拿來發現問題,而不是拿來印證一切都很完美。

第一件值得誠實記錄的事,permits = 20 這個數字,很可能在測試中會暴露出設得偏保守的跡象。如果 p95 等候時間在流量攀升初期,虛擬使用者數量還沒真正逼近促銷尖峰水位,就已經開始出現不必要的排隊,這代表 20 這個上限可能低估了下游確認庫存服務實際能承受的併發量,正常流量下也讓部分請求白白多等了一段時間。這不代表 Semaphore 節流這個手段本身有問題,而是當初 Day 15 評估時使用的假設數字,需要依照這次測試的實際結果重新校準,調高到一個更貼近下游服務真實承載能力的數字,再重新測一次驗證。

第二件事跟樂觀鎖的重試體驗有關。如果 stock.deduct.version_conflict 在搶購情境下的計數,如同預期般幾乎逼近虛擬使用者的總數,從資料正確性的角度看是好事,代表機制確實擋下了幾乎所有的重複扣減。但從使用者體驗的角度看,這代表絕大多數送出請求的使用者,第一次嘗試就會收到「搶購失敗」的回應。如果 Service 層目前的做法是直接把 affectedRows > 0 為否這個結果原封不動回給使用者,沒有搭配任何自動重試邏輯,體驗上會顯得生硬。Day 24 當時已經留了伏筆,提到重試策略留給讀者依照自己系統的容錯需求決定,今天這場測試把這個懸而未決的問題,變成一個有具體數據支撐、值得認真考慮的待辦事項。

第三件事跟測試腳本本身的極限有關。今天這場測試把 300 個虛擬使用者集中打向同一筆商品,已經算是相當激烈的併發情境,但真實世界裡的搶購瞬間,尤其是知名商品開賣的那一刻,實際併發規模有可能遠遠超過這個數字。如果測試環境的資料庫或連線池在到達某個更高的併發水位之後,開始出現連線逾時或交易排隊過久的狀況,而今天的測試規模還沒有觸及那個臨界點,今天驗證出來的「機制運作正常」這個結論,是有規模上限的,不能無限上綱到宣稱這套機制在任何規模的搶購情境下都撐得住。誠實面對這件事,比得出一個看起來漂亮卻經不起放大檢驗的結論更有價值。

系統經得起考驗了,但上線前還有一些細節

這場測試驗證了三件事,大致符合預期的方向:Semaphore 節流確實讓超額流量排隊等候而不是一擁而上,樂觀鎖確實在高衝突的搶購情境下守住了庫存數字的正確性,Day 26 建立的監控指標也確實如實反映了測試過程中發生的排隊與衝突狀況。訂單服務實戰整合版,從 Day 22 開始收斂的這套骨架,到今天已經具備了因應高併發流量的基本能力,而且這份能力不再只是紙上談兵,是被一場刻意設計、針對性驗證過的壓力測試親自確認過的。

但這句話得補上一個限定條件。今天驗證的,是這套系統在一個受控的測試環境下的表現,機器規格、資料庫版本、網路狀況,都是為了這場測試而準備的環境,不必然等於真正上線之後會面對的正式環境。真正部署上線時,還有一些跟協程與非阻塞架構相關、容易被忽略的環境設定細節,執行緒池大小該怎麼抓、逾時參數在正式環境是否還適用今天測試出來的假設值、R2DBC 連線池大小在正式環境的資料庫規格下夠不夠用,這些數值在測試環境跟正式環境之間,往往不能直接照搬。

這些疑問今天先不回答。下一篇會整理一份部署上線前的協程專屬檢查清單,把這些容易被忽略的設定細節一項一項攤開來看。

《Day 28:部署上線的協程專屬檢查清單》 見。


上一篇
Day 26:Metrics 與監控,量化你的協程效能表現
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言