「做一個訂單系統」還不足以決定要不要 Kafka、Redis 或拆服務。如果消費者看到建立成功,重新整理卻找不到訂單;或者按兩次重試就扣了兩份庫存,即使 API 的 p99 很漂亮,系統仍沒有交付原本要的結果。

這篇的設計決策是:把「成功」寫成可驗收的業務契約,分開處理不可違反的資料規則與允許少量失敗的服務目標,再選能履行契約的最小架構。

這是「系統設計每日實戰」第一篇。案例、流量與目標數字全部是教學假設,沒有使用私人專案資料,也沒有代表任何正式服務的效能。讀者若已熟悉 Laravel、Node.js、MySQL 與 DDD,可以直接把下面的契約帶進需求討論。

一張訂單成立,要交付哪些可觀察結果?

假設我們替一間小型商店提供單倉、單幣別、單品項的下單功能。首版不處理付款、優惠券、預購與跨倉調撥;「成立」只表示價格快照與庫存預留已完成,尚未收款。假設尖峰每秒 20 次下單嘗試、一次促銷維持 10 分鐘。這些是待壓測的輸入條件,不能從數字直接推導機器規格。

可以交給產品與工程共同審閱的需求如下:

  • 消費者提交商品、數量與同一次操作的冪等鍵。價格由伺服器取得,訂單保存成交價快照與整數最小貨幣單位。
  • 回覆 201 時,訂單、品項快照與庫存預留必須已在同一筆資料庫交易中提交,回覆包含訂單 ID。隨後的訂單詳情讀取走主庫,不能被讀副本延遲誤導。
  • 同一使用者、同一冪等鍵、同一正規化內容重送,回覆原訂單 ID,不再預留庫存;同鍵卻換內容,回覆明確的衝突結果。缺貨等已作出的終局決定也保存並重放;補貨後要重新購買,必須明確發起新操作。
  • 缺貨回覆可辨識的業務拒絕,沒有新訂單與預留紀錄。缺貨不能用 500 混過去;系統也不能把資料庫故障偽裝成缺貨。
  • 逾時畫面顯示「結果待確認」,允許以原鍵重試或查詢。客戶端不得為了重試另產生一個鍵。

這裡的 201 對應 HTTP 的資源已建立語意;但「三份資料必須一起提交」是本案例額外訂出的契約,不是狀態碼替我們保證的事。RFC 9110 §15.3.2

冪等紀錄的保留期也屬於契約。此例假設完整回覆快取保留 7 天、自動重試限 24 小時;首版不自動刪除去重索引,仍保留使用者/鍵、請求摘要、終局結果與訂單 ID,過期回覆由主資料重建。索引容量與清理政策需另外設計,不能刪掉記錄後把舊鍵當成新意圖。AWS 的冪等 API 設計也討論 caller-provided identifier、參數變更與晚到重試。

有了這份範圍,原本抽象的「高可用」開始有具體成本:成功後讀主庫會增加主庫負擔,冪等紀錄需要儲存空間,結果待確認需要產品介面與客服查詢能力。這些選擇應該在畫部署圖之前被接受。

不變量不能拿錯誤預算抵銷

不變量是任何一次操作都必須維持的規則。 本例先訂四條:

  1. 同一使用者與冪等鍵最多對應一個訂單,且已記錄的請求摘要不可換掉。
  2. 可用庫存不得小於零。一次成立只能產生一次預留效果。
  3. 訂單金額等於伺服器價格快照乘以數量;拒絕零或負數數量,不接受客戶端總價作為權威。
  4. 不可回覆成立卻只寫入訂單、沒有寫入預留;也不可預留成功卻缺訂單。

MySQL InnoDB 的交易可讓同一資料庫內的變更一起提交或回滾,唯一索引可拒絕重複值,但「先查沒有,再新增」本身仍有併發空隙。設計上要靠資料庫唯一約束,加上同筆交易中的條件扣庫存與受影響列數檢查。InnoDB ACID、CREATE INDEX

實作時把 (customer_id, idempotency_key) 設為非空唯一鍵;以固定順序鎖定/更新庫存,更新條件包含 available >= quantity,成功一列才繼續建立訂單。碰到 deadlock 或 lock timeout,依錯誤類型與交易狀態處理,在應用邊界明確回滾整筆交易後,才有限次重試。唯一鍵衝突也不可直接吞掉後提交;先回滾本次交易,再讀取並核對原操作結果,以免保留本次不該發生的庫存變更。外部付款或寄信不能放進這筆本地交易並假設可回滾。InnoDB 錯誤處理說明 deadlock 與 lock timeout 的回滾範圍並不相同。

這是實作策略,還不是證明。兩個同鍵請求同時到達、一件庫存被兩個不同鍵爭搶、寫到一半程序中止,都必須由整合測試檢查最終資料,而非只檢查回應碼。

若在驗收中發現一次重複預留,先停止發布並找出競態。不能說「99.5% 達標,這一筆在預算內」;服務偶爾無法及時回覆,與服務回覆錯誤業務事實,是兩種需要分別管理的風險。

SLO 要能說出分母、時鐘與漏掉了誰

Google SRE Workbook 建議以使用者重視的服務行為選擇 SLI,常用「好的事件/有效事件」表示,並要求目標有關係人認同及後續改進流程。SLO 不會因為寫成 99.99% 就自然合理。Implementing SLOs

本例提出一個待業務確認的起始目標:

過去滾動 28 天,至少 99.5% 的合格下單 HTTP 嘗試,在可信任入口收完請求後 1,000 ms 內,送完符合契約的終局回覆。

這份 SLI 規格要附上以下量測約定。

分母:保留故障期間進來的嘗試

「合格」表示通過身分與靜態輸入檢查、屬於支援的商品/數量格式。缺貨是正常業務結果,仍算分母。5xx、容量不足造成的 429、上游逾時與程式未留下完成紀錄,也都不能因為沒有訂單 ID 就從分母消失。

在入口留下 attempt ID、操作鍵的安全摘要與起始時間,再與完成事件對接。下游故障導致資格無法判定的請求,保守留在分母、視為未成功,另外追蹤資格不明數量。明確的格式錯誤與未授權請求另記數量,不加入這個 SLI;排除比例突然升高要調查,避免把事故重新分類成「使用者問題」。

一次重試是另一個 HTTP 嘗試,因此會再次進入此分母。冪等鍵用於另外彙整「同一業務操作最後成立幾筆」與「多少操作仍待確認」,不能把 HTTP 嘗試與去重後的操作數混在同一比率。

分子:及時且正確的終局回覆

成立、原單重送、缺貨與正確的同鍵內容衝突,只有在回覆符合各自契約且耗時不超過 1,000 ms 時才算好事件。只回一個 200 或 202 不足以通過。「受理、尚未完成」若要成為產品行為,必須另訂完成目標,後面再比較。

入口看到的延遲不包含 DNS、TLS 建連與消費者網路,也無法證明畫面真的收到結果。上線時另用瀏覽器端遙測與外部合成探測補足這個盲區;不要把入口 SLO 命名成「所有使用者端到端成功率」。計時用單一觀測端的單調時鐘,避免拿不同主機的 wall clock 直接相減。

預算:計算事件,不偷換成停機分鐘

如果某個 28 天視窗有 100,000 次合格嘗試,99.5% 目標對應最多 500 次不良事件。這是請求型預算,不能直接宣稱「允許停機 201.6 分鐘」:促銷尖峰的一分鐘和凌晨的一分鐘有不同請求數。

這裡把過慢與失敗放在同一個好事件定義中;仍要分開記錄原因,才知道該處理鎖競爭、入口限流或應用錯誤。沒有流量時回報「資料不足」,而不是 100%。

本文建議的治理動作是:預算耗盡時暫停非必要功能發布,優先修復主要失敗原因;若資料不完整到無法判斷分母,先修量測。這是本案例的管理選擇,並非 Google 對所有團隊的通用門檻。

最小方案:讓訂單與庫存先共用一個交易邊界

依前面的範圍,首版採模組化單體:一個 Laravel 或 Node.js 應用內,訂單模組負責用例與冪等結果,庫存模組負責預留規則;兩者由同一個應用服務協調同一筆 MySQL 交易。模組邊界透過介面與依賴規則保持清楚,不先變成跨網路呼叫。

先定義承諾,再選擇最小交易邊界入口量測每次合格 HTTP 嘗試,模組化應用在同一筆 MySQL 交易中保存冪等結果、訂單與庫存預留,提交後才回覆成立。回覆遺失時,用原操作鍵查詢或重試。成功回覆的承諾:一張訂單,一次庫存預留首版同步方案;單一應用中的模組共用本地交易01 入口:留下每一次嘗試attempt ID、開始時間、操作鍵摘要;故障與逾時也進分母02 應用 + MySQL:一起提交冪等唯一約束 · 訂單與價格快照 · 有條件的庫存預留不變量由約束與整合驗收守住;不能用錯誤預算抵銷03 提交後回覆;詳情讀主庫回覆遺失:結果待確認,以同一操作鍵找回原單量測邊界:入口收完請求 → 送完符合契約的終局回覆訂單承諾與交易邊界:手機版桌面圖的直向排列:入口記錄、同一交易提交、回覆與原鍵查詢。每次嘗試都保留,不把逾時排除。成功回覆的承諾一張訂單,一次庫存預留01 入口留下嘗試ID、時間、操作鍵摘要故障與逾時也進分母02 同一筆交易冪等唯一約束、訂單快照有條件的庫存預留錯誤預算不能抵銷錯單03 提交後回覆詳情讀主庫;回覆遺失時用原操作鍵查詢或重試量測:入口收完請求到送完契約終局回覆
教學方案:模組邊界保留,首版把必須一起成立的資料放在同一個 MySQL 交易邊界。

Redis 可以晚一點用於非權威快取,Elasticsearch 可以晚一點用於搜尋投影。首版的成立判斷、冪等結果與庫存正確性全部由主庫負責。寄信若加入需求,應先把待通知工作與訂單一起持久化,再由背景工作者處理;不要讓郵件服務故障改寫已成立的事實。

提交耐久性還取決於 InnoDB、binlog 與儲存設定,備份/故障切換也需另外驗收。本文沒有承諾任何故障下的零資料損失;正式上線前需把可容忍資料損失與復原時間另寫成 RPO/RTO。InnoDB 提交刷盤設定

代價也要寫明:訂單與庫存共用故障域,熱門商品可能產生鎖競爭,主庫讀寫負擔集中。模組化單體不保證每秒 20 次一定通過目標,仍需以指定資料量、熱點分布與部署規格壓測。首版只是把要證明的跨系統性質減少到可控制的範圍。

有意義的替代:先持久受理,之後才決定成立

同步/非同步是在選擇回覆承諾,與單體/微服務是不同維度;以下兩種路徑都能留在模組化單體。另一個方案是入口先持久寫入操作紀錄,再回 202 與查詢位置,背景工作者完成預留與成立。HTTP 規格對 202 的定義是已接受處理、尚未完成,而且最終可能不執行;因此不能把 202 直接改名為「訂單成功」。RFC 9110 §15.3.3

這個方案可能有利於吸收尖峰,但需要產品接受 pending 畫面,也新增佇列積壓、重複投遞、失敗重試與工作者中止後恢復的驗收。若採用,除了入口受理 SLO,還要另訂「已持久受理的唯一操作,有多少在期限內到達可查詢終態」的完成 SLO;分母按操作去重,不能靠拒收更多流量美化完成率。

本案例暫不選它,因為目前需求要求在下單回覆中知道成立或缺貨,且還沒有量測證據顯示同步路徑必須讓步。當可重現的壓測顯示尖峰無法在合理成本下達成目標,而且業務同意延後告知結果時,才重新比較。改架構之前,先批准改變使用者承諾。

驗收從反例開始,不讓平均值掩蓋錯誤

下列是應用與資料庫整合驗收規格,本文尚未建置訂單服務,沒有聲稱這些情境已在 MySQL 執行。

  • 同鍵同內容的兩個併發請求:最終只有一個訂單 ID、一筆有效預留;回覆一致。重試途中回傳暫時錯誤時,仍不得有第二筆副作用。
  • 缺貨後補貨,再送出原鍵:仍重放原缺貨結果;使用者明確以新鍵購買才可重新判斷庫存。
  • 同鍵不同內容:可辨識的衝突結果,第一筆紀錄不可被覆寫。
  • 庫存只剩一件、兩個不同鍵同時購買:恰好一筆成立、一筆缺貨,庫存為零。
  • 交易提交前中止:沒有半套訂單;提交後、回覆前斷線:原鍵查詢/重試找回同一單,不能將 timeout 當作「肯定沒成立」。
  • 回覆成立後立刻讀取:主庫讀到對應訂單與正確快照;若後來導入讀副本,必須重驗 read-after-write 契約。
  • 在受控測試環境用教學流量壓測:記錄所有入口嘗試、錯誤與尾端延遲,再算 SLI;通過短期測試也不能推稱已達成 28 天 SLO。

下面這份可執行的 Node.js 合成驗收,只檢查「SLI 計算沒有漏掉逾時或把缺貨算成服務錯誤」。欄位 contractOK 在真系統中要由回應、主庫/對帳證據確認;不能讓服務隨手寫 true。這段程式沒有啟動 HTTP 或資料庫,也沒有產生實際延遲。

測資假設都是已結束或已滿 1,000 ms 觀察期的嘗試;未到期限且仍在處理的請求另列 pending,不提前算失敗。

儲存為 slo-contract.mjs,執行 node slo-contract.mjs:

import assert from "node:assert/strict";

function measure(attempts) {
  // eligibility 未知仍計入;只有已證明不合格才能排除。
  const eligible = attempts.filter((x) => x.eligible !== false);
  const good = eligible.filter(
    (x) =>
      x.eligible === true &&
      x.contractOK === true &&
      ["created", "replayed", "sold_out", "key_conflict"].includes(x.outcome) &&
      Number.isFinite(x.elapsedMs) &&
      x.elapsedMs >= 0 &&
      x.elapsedMs <= 1000,
  ).length;
  return {
    total: eligible.length,
    good,
    ratio: eligible.length ? good / eligible.length : null,
  };
}

const created = {
  eligible: true,
  outcome: "created",
  contractOK: true,
  elapsedMs: 250,
};
const soldOut = { ...created, outcome: "sold_out", elapsedMs: 80 };
const healthy = measure([created, soldOut]);
assert.deepEqual(healthy, { total: 2, good: 2, ratio: 1 });

const degraded = measure([
  created,
  soldOut,
  { eligible: true, outcome: "timeout" },
  { ...created, elapsedMs: 1400 },
  { eligible: false, outcome: "invalid_input" },
]);
assert.deepEqual(degraded, { total: 4, good: 2, ratio: 0.5 });
assert.equal(degraded.ratio >= 0.995, false);
assert.equal(measure([{ outcome: "gateway_error" }]).ratio, 0);
assert.equal(measure([{ ...created, eligible: undefined }]).good, 0);
assert.equal(measure([{ ...created, contractOK: false }]).good, 0);
assert.equal(measure([{ ...created, outcome: "accepted" }]).good, 0);
assert.equal(measure([{ ...created, outcome: "key_conflict" }]).good, 1);
assert.equal(measure([]).ratio, null);
console.log("PASS: SLI 分母、終局回覆、延遲與空視窗反例");

這份合成檢查於 2026-10-11 執行通過,驗證的是固定測資下的計算規則。它不驗證庫存併發安全、不驗證時鐘儀表、不證明短期或正式環境 SLO。下一個可交付成果,是把上方整合驗收接到真正的 adapter,再用獨立觀測資料取代 fixture。

今日練習:尖峰來了,要不要改成 202?

同一商店準備促銷,假設尖峰從每秒 20 次變成 200 次。產品提出:「先收到就好,30 秒內告訴我結果。」請用五行寫出你的決策:

  1. 202 承諾了哪個已持久化事實?
  2. 原本入口 SLO 哪裡需要修改?
  3. 完成 SLO 的分母、起點、終點與目標是什麼?
  4. 消費者在 30 秒後仍看到 pending 時,系統與客服要怎麼處理?
  5. 哪一個測試失敗會讓你拒絕切換?

參考思路:延後結果不能延後責任

可接受的答案是:202 只承諾操作紀錄已持久受理,回覆穩定的查詢 ID;入口仍計入所有合格嘗試,受理失敗不得消失。完成目標可以先提案為「28 天內 99.5% 的已受理唯一操作,在受理時間起算 30 秒內產生可查詢的成立或缺貨終態」,數字仍需業務批准與壓測。

報表時間為 T 時,取受理時間在 [T − 28 天 − 30 秒, T − 30 秒) 的操作,判斷各自第 30 秒時是否已有可查詢終態;後來補成功不能抹除當時逾期。統計時需以已滿 30 秒觀察期的受理批次計算,尚未到期者另列 pending,超期仍未完成者算失敗。查詢暫時不可用、工作者卡住與超過 30 秒都要留下不良事件,不能在重試後重設起點。pending 逾時進入待調查清單,前端保留原操作 ID 與狀態查詢;是否允許取消,必須另外定義與 worker 的競態處理。驗收至少包含「持久受理後 worker 中止,再啟動只完成一次」與「資料已提交但回應遺失,重送仍指向同一單」。

今日可帶走的產物是一頁需求契約:寫下成功回覆意味著什麼、哪些資料永遠不能錯,以及 SLI 究竟量到哪一段。下一次討論 Redis、佇列或微服務時,用這張契約檢查它改善了哪個結果、又新增了哪個要驗收的失敗狀況。

延伸閱讀:PEDALS 系統設計方法適合整理更完整的設計提問;容量估算可接續本例的待驗證流量假設;可觀測性與 SLO提供指標與追蹤的延伸脈絡。本篇聚焦決策前的契約,不以套用架構名詞作為完成條件。