假設下單流量一直是每秒 20 次,服務卻開始排隊。有人打算再開一個應用副本;但查詢若都在等同一筆 SKU 的庫存鎖,多一台機器只會送來更多等待者。

同一個請求可能只用資料庫 20 ms,也可能等鎖等到 800 ms。「每秒 20 次」少了這段占用時間,就還算不出需要多少連線,更別說該買幾台機器。

這篇保留同步模組化單體,先把 HTTP 嘗試、唯一操作、在途請求與 DB 連線分開算,再用分散 SKU 和單一熱門 SKU 的對照負載,找出應該擴充的地方。候選規格只是第一輪測試環境,不能拿推算當成已通過的容量。

這是系列第 2 篇,沿用第一篇的訂單契約:單倉、單幣別、每張訂單一種商品,尚未付款。201 前要共同提交訂單、價格快照與庫存預留;原鍵重送不再扣庫存,成立後查詢讀主庫。入口目標仍是滾動 28 天內,至少 99.5% 的合格嘗試,自可信任入口收完請求起算,1,000 ms 內送完正確終局回覆。

本文的流量、耗時、資料大小與機器規格全部是教學假設,沒有建置或壓測這個訂單服務。以下算的是待驗證預算,不是事故實測結果。

先算清楚 12,000 次嘗試會留下幾張訂單

沿用尖峰 20 次 HTTP 嘗試/秒、持續 600 秒。新增一組方便推算的情境:20% 的嘗試是同鍵重送;其餘是新操作,每個新操作在這段觀察期都有一次首次嘗試。新操作的 90% 成立,其餘缺貨。這裡暫不混入跨觀察期重送、格式錯誤或同鍵不同內容,壓測時再各自加入。

  • 入口負載:20 × 600 = 12,000 次嘗試。
  • 新操作:12,000 × 80% = 9,600 個,平均 16 個/秒。
  • 新訂單:9,600 × 90% = 8,640 張。
  • 其餘 960 個新操作保存缺貨終局結果;2,400 次重送重放既有結果。

「20% 是重送」的分母是全部嘗試,不能改成每個新操作額外重試 20%。後一種假設會得到 12,000 ÷ 1.2 = 10,000 個新操作,兩者不同。

這份算式也能拿來驗測試器有沒有送對資料:測試結束後,入口應記到 12,000 次嘗試,按使用者與冪等鍵去重後是 9,600 個操作;每個鍵再核對終局結果,才知道是否得到假設中的 8,640 張訂單與 960 次缺貨。若只看到 12,000 個不同鍵,測試器根本沒有送出那 20% 的重試。

2,400 次重送雖然不新增訂單,還是要做驗證、查收據與寫日誌。HTTP 嘗試用來估入口及查詢壓力,唯一操作用來估去重紀錄與新訂單成長。第一篇的 SLI 也數 HTTP 嘗試,重送不能從報表消失。

平均在途 4 個,不等於只開 4 個 worker

Little’s Law 把穩定系統的長期平均量連起來:平均系統內數量 L = 平均進入率 λ × 平均停留時間 W。必須選定同一個觀測邊界,並滿足長期平均存在等條件;排隊時間若在邊界內,就要算進停留時間。Columbia 的 Little’s Law 講義

假設在 20 次/秒的穩定階段,從入口收完請求到送完回覆的平均耗時是 0.20 秒,則平均在途量為 20 × 0.20 = 4 個。這是用待驗證耗時建立的算例。實際量測若是 0.80 秒,同樣流量對應平均 16 個在途。

1,000 ms 是契約期限,不能直接當成平均耗時代入;λ × p99 也不是 Little’s Law。平均值更不會告訴你短暫尖峰需要多少 worker,或哪個上限能滿足尾端延遲。若輸入已超過可處理能力、佇列持續增長,就不能拿一個穩態平均數替它背書。

同一組入口嘗試,分開估算 HTTP 與 DB 占用 假設穩定負載為每秒 20 次 HTTP 嘗試,包含重試。HTTP 邊界內平均停留 0.20 秒,因此平均有 4 個在途嘗試。每次嘗試累計的 DB 借用量平均為 0.08 連線秒,包含未借用者的 0,因此平均占用 1.6 條連線。兩種量測邊界不同, 所有數字均為規劃假設,平均占用不能直接當作 worker 或連線池上限。入口嘗試每次 HTTP 嘗試都計入,包含重試20 次/秒HTTP 邊界20 次/秒 × 平均 0.20 秒平均 4 個在途DB 借用邊界20 次/秒 × 每次平均 0.08 連線秒平均 1.6 條占用HTTP 與 DB 平均占用:手機版桌面圖的直向排列:入口每秒 20 次嘗試,包含重試。HTTP 平均停留 0.20 秒,算得平均 4 個在途;每次嘗試的 DB 借用量平均為 0.08 連線秒,算得平均 1.6 條連線占用。這是兩個不同邊界的規劃算例,不是量測結果或容量上限。入口嘗試每次 HTTP 嘗試,包含重試20 次/秒HTTP 邊界平均 0.20 秒20 × 0.20 = 4平均 4 個在途DB 借用邊界每次平均 0.08 連線秒20 × 0.08 = 1.6平均 1.6 條占用
穩定負載的教學算例;平均數不能直接當作 worker 或連線池上限。 所有數字都是規劃假設,尚非量測結果。0.08 連線秒是每次 HTTP 嘗試的 DB 借用量:累計所有借用期間,未借用者計 0,再取平均。

DB 的邊界要另外量。假設每次 HTTP 嘗試合計借用 DB 連線的時間平均為 0.08 連線秒,包含不借連線的零值,以及同一次嘗試所有借用區間的加總,則平均被借用的連線數是 20 × 0.08 = 1.6 條。若一個請求同時借兩條,各自的占用時間都要累加。借用時間包含持有連線時的鎖等待,卻不包含還沒借到連線的 pool 排隊。

要驗這個 1.6,程式至少記下三個時間點:向 pool 申請連線、實際拿到連線,以及確實歸還連線。前兩者之差是 pool 等待,後兩者之差才是借用時間;同一請求借用多次就累加,完全沒借用則記零。時間差在同一程序以單調時鐘計算。

再把 SQL/交易時間與資料庫鎖等待接到同一個 request trace。pool 等待升高,表示當下拿不到連線;拿到後仍等 SKU 鎖,則表示連線已占用卻還不能往下做。兩種等待的處理方式不同,不能合併成一個「DB 慢」就把 pool 加倍。

平均 1.6 條也不包含開著但閒置的連線。它們仍計入 MySQL 連線上限,不能在規劃 max_connections 時當成不存在。MySQL 連線管理

每個副本 8 條,部署交接時會開到幾條?

首輪仍用一個應用部署單位,加一個獨立 MySQL InnoDB 實例;訂單與庫存保留程式內模組介面,共用本地交易。不加入 Redis、Elasticsearch 或訊息佇列,因為這次要確認的是同步成立路徑,還沒有證據顯示它們能解除瓶頸。

可先寫下這份候選測試規格,實際實例型號、CPU 架構、磁碟 IOPS 與版本在測試紀錄中固定:

  • 應用:2 vCPU、4 GiB RAM,一個副本;在途請求、工作程序數與 DB 連線總量都要量測。
  • 資料庫:2 vCPU、8 GiB RAM、100 GiB SSD,用 90 天規模的測試資料與實際索引,不只測空表。磁碟容量與 IOPS 是不同限制。
  • 應用連線預算:每個副本最多合計 8 條;若滾動部署最多同時存在兩個副本,就按 16 條計算。
  • DB 預算:假設一般客戶端總上限為 40 條,預留 8 條給維運、4 條給遷移與背景工作,應用可分配 28 條。16 ≤ 28 只是靜態配置檢查,還要靠連線監控與程序上限讓預留實際生效。

這組規格的用途是取得第一份可比較的結果。CPU、記憶體或磁碟任一飽和,都可能讓它需要調整。MySQL 官方說明每個連線有自己的記憶體需求;把 max_connections 加大並不免費,也不代表資料庫能同時有效執行更多工作。MySQL 記憶體使用

若 Node.js 每個程序各建立一個 pool,連線上限須跨程序加總;同一程序建立多個 pool,也要全部計入。Laravel/PHP 的預算要按實際 worker 與持久連線模式加總。不能在每個程序都填 8,再說整個容器最多 8。這裡的「每副本 8 條」是部署必須履行的總額,具體限制方式要依 driver 與執行模式設定、驗證。

驗部署預算時,讓新舊副本同時存在,檢查各程序的 pool 設定與 MySQL 實際連線數;再啟動預定的背景工作,確認沒有漏算它的 pool。設定檔寫了 8,不代表整個副本只開 8 條。

單一應用副本和單一資料庫仍有故障風險。這次先找容量瓶頸,高可用、RPO/RTO 與故障切換仍需另外驗收;10 分鐘壓測也無法證明 28 天 SLO。

加副本能分散 CPU,能不能分散同一列的鎖?

若觀測顯示應用 CPU 飽和、DB 等待低,兩個副本可能讓應用工作分散,也增加部署與路由成本;連線、日誌、健康檢查及故障切換都要按副本數重算。單副本加到 4 vCPU、8 GiB 則少一層協調,但單點風險不變,程式若受單執行緒或鎖限制,也未必用得到新增核心。

若瓶頸在同一 SKU 的庫存鎖,兩者都可能沒用。InnoDB 在 UPDATE 時會對掃描到的索引紀錄加鎖,選用的索引與掃描範圍會影響鎖定範圍;同一列的互斥更新仍會互相等待。InnoDB 各種敘述設定的鎖

用一個刻意簡化的反例檢查:所有新操作都爭同一列,若每筆把該互斥鎖持有 80 ms,理想串行上界約為 1 ÷ 0.08 = 12.5 次/秒,還未扣除其他開銷。前面假設的新操作有 16 個/秒,若每個都走這段鎖,就已超過上界。這裡的 80 ms 是另一個熱點反例假設,不能和上節「每 HTTP 嘗試平均 0.08 連線秒」混為同一項量測。

這個反例裡,一秒進來 16 個需要那把鎖的新操作,理想上卻最多通過 12.5 個。多開應用副本沒有縮短那 80 ms,也沒有讓同一列的互斥更新同時執行。排隊只會累積,直到某處逾時或拒絕請求。

實作時先找持鎖區段裡做了什麼:庫存 UPDATE 後是否還在等不相關查詢、外部 HTTP 或其他工作,索引是否讓掃描範圍變大。縮短這段,再以相同熱點負載重測完成率與鎖等待。注意整條 UPDATE 的耗時可能包含「取得鎖之前的等待」,不能把它直接當作已持鎖 80 ms 的證據。

Redis 快取不能替代權威庫存更新;若想改成非同步受理,還要回到第一篇修改回覆承諾。熱點等待仍持續增加時,不把「副本數已加倍」當成容量問題已解決。

儲存預算要把不刪除的去重紀錄算進去

10 分鐘尖峰不能外推成每天全天尖峰。另作每日 50,000 個新操作、其中 90% 成立的教學假設;尖峰包含在當日總量內。假設每張訂單連同品項與預留的邏輯資料為 2 KiB,每個新操作的精簡去重紀錄為 0.5 KiB,完整回覆快取額外為 1 KiB。

第一篇約定完整回覆快取保留 7 天,精簡去重索引首版不自動刪除。據此估算:

  • 長期邏輯資料每天增加:45,000 × 2 KiB + 50,000 × 0.5 KiB ≈ 112.3 MiB。
  • 90 天約 9.87 GiB;365 天約 40.03 GiB,沒有因為回覆快取到期而停止成長。
  • 7 天完整回覆快取上限按每操作一份估:50,000 × 7 × 1 KiB ≈ 341.8 MiB,前提是到期清理確實執行。

KiB、MiB、GiB 在這裡依序是 2¹⁰、2²⁰、2³⁰ bytes。這些 payload 數字尚未包含索引、頁面空間與資料庫額外開銷。為了選首輪磁碟,暫以「長期資料加回覆快取」乘 3 倍做占用假設;它不是 MySQL 的固定倍率,後續要用真實 schema、索引與資料分布測出來。

把算式展開,90 天的占用假設是 (9.87 GiB + 341.8 MiB ÷ 1,024) × 3 ≈ 30.6 GiB,計算時應使用未四捨五入的原值。再暫留 20 GiB給 binlog、redo、暫存與操作空間,合計約 50.6 GiB,可放入上述 100 GiB 候選磁碟。這筆預留同樣待實測,不能拿來保證索引重建或大量排序一定有空間。備份另存獨立位置,備份保留與還原空間另列預算。

若維持相同成長,365 天的資料占用假設已約 121.1 GiB,還沒加那 20 GiB。100 GiB 顯然不能原封不動撐一年。本文提案的操作門檻是:每週更新趨勢,當預估 30 天後使用量會超過配置的 70%,啟動擴容或保留政策審查。70% 與 30 天是本案例的保守工作門檻,需配合擴容交付時間調整;不得為了騰空間擅自刪掉仍承擔冪等契約的索引。

壓測要送出承諾的流量,不能等服務慢了就少送

固定 20 個虛擬使用者、每人等回覆後才送下一次,可能在系統變慢時自動降低到達率。k6 官方把這種回饋稱為 closed model 的 coordinated omission 問題;arrival-rate executor 則按預定速率啟動 iteration,使到達節奏不依賴前一個 iteration 是否完成。Open and closed models

第一輪測試可以按下面順序跑,每輪都保留程式版本、資料快照、環境與原始結果。重複測試前把庫存與收據恢復到同一基準,避免上一輪已消耗的庫存讓下一輪只剩快速缺貨,誤以為效能改善:

  1. 先暖機,再以固定到達率執行 20 次下單嘗試/秒、持續 10 分鐘。每個 iteration 恰好送一次下單 HTTP 請求;關閉腳本與 HTTP client 的隱式重試、redirect,另外安排已知原鍵的重送。否則 iteration/秒不能直接視為下單嘗試/秒。
  2. 基線跑分散 SKU、0% 重送;第二輪保持 0% 重送,將所有新操作集中同一 SKU;第三輪回到分散 SKU,加入 20% 同鍵重送。前兩輪都是 20 個新操作/秒,第三輪是 16 個/秒。重送必須指向同測試中可追蹤的原操作,不能拿隨機新鍵冒充。缺貨與成立比例以結果核對,不只相信輸入腳本。
  3. 訂單詳情查詢另以 10 次/秒作本輪新增假設,與下單同時打到主庫;監控、背景工作與必要健康檢查也記入資源預算。前面的 1.6 條只估了下單部分,不能當整台 DB 的平均占用。
  4. 收集入口嘗試與契約結果、延遲分布、應用 CPU/RSS、pool 等待、DB 連線、查詢耗時、鎖等待、磁碟延遲與實際資料成長。每次成立仍需對帳訂單與預留,不能以 HTTP 成功率代替正確性驗收。
  5. 另跑 40 次/秒的 60 秒突發,之後回到 20 次/秒並觀察是否恢復。這是探索餘裕的壓力情境,不是把產品需求悄悄加倍;記錄限流、錯誤與排隊,不期待靠無限佇列把超載藏起來。

比較結果時,先看第二輪集中 SKU 後,鎖等待與借用時間是否一起升高;如果第三輪加入重送後訂單成長下降,HTTP 查詢與驗證成本卻仍在,就能核對前面的兩種計數。若單改 SKU 分布就讓延遲大幅改變,不能把兩輪平均混在一起說「每秒 20 次沒問題」。

額定負載每輪暫用 99.5% 的及時正確終局回覆作短期 gate,並要求零重複預留、零負庫存與完整負載交付。不變量一旦失敗就停止;即使比例達標也不放行。這個 gate 不代替正式的 28 天觀測。

k6 constant-arrival-rate 的速率單位是 iteration;若沒有可用 VU,會出現 dropped_iterations。它們是測試器沒送出的工作,不能加進服務端「已收到 HTTP 嘗試」的分母,也不能當作服務成功。此時要報告目標流量未達,調查測試器資源或被拉長的請求;只看到低錯誤率,不能宣告通過。

AWS 在避免過載的負載卸除設計討論超過容量後的行為與測試。本例採用的驗收判斷是:額定負載先檢查既有契約與延遲,超載則確認排隊有界、拒絕可觀測且降回額定負載後可恢復。合格嘗試因容量不足收到 429,仍是第一篇 SLI 的不良事件;保護系統不能靠改分母變成達標。

練習:流量沒變,連線為什麼開始排隊?

假設後續真的量到以下結果:下單仍是 20 次/秒,但每次下單合計借用 DB 的時間由平均 0.08 增至 0.30 連線秒。另有 10 次/秒的詳情查詢,每次借用 0.05 秒。應用仍是一個副本,這個應用副本的 DB 連線池合計上限仍為 8。

請算出平均占用,並在「連線上限改成 16」與「先查借用時間增加原因」之間選一個第一步。再寫下一個足以推翻你選擇的觀測結果。不要只回答「看情況」。

參考思路:6.5 條是警訊,還不是加倍連線的理由

下單平均占用為 20 × 0.30 = 6 條,查詢為 10 × 0.05 = 0.5 條,合計 6.5 條;相對 8 條上限約 81.25%。這是假設穩態下的平均推算,無法單獨推出 pool 等待的 p99,也不能斷言上限 8 一定不足。

我的第一步會查借用時間增加的來源。如果熱門 SKU 的鎖等待升高,擴大 pool 很可能只是允許更多請求同時等待;先處理持鎖區段。如果 pool 等待升高,但借用後的 SQL、鎖等待與 DB 資源都保持穩定,且小範圍增加連線的對照測試改善延遲、沒有增加錯誤與資源飽和,就有理由修正原判斷。改成 16 前,也要重算滾動部署時 2 × 16 = 32 已超過原先分配的 28 條應用預算。

回到要不要加機器的問題,下一步可以先在 trace 裡拆出「等 pool」和「借到連線後等鎖」,再跑一次分散 SKU/單一 SKU 對照。把觀測值填回這篇的平均占用與連線總額算式;只有查清新增資源能縮短哪段等待,才有依據調整副本或 pool。