假設顧客在結帳頁看到總價 500 元,按下送出前,後台把商品改成 550 元。下單 API 讀取目前價目,順利建立 550 元的訂單。資料庫沒有算錯,畫面與顧客同意的價格卻對不起來。
第 3 課只承諾保存下單時讀到的價格快照。現在產品多了一項要求:結帳頁展示一份有期限的伺服器報價,顧客在期限內接受,就沿用那份價格。這是「系統設計每日實戰」第 12 課,要決定的是期限在哪個動作生效,以及到期後怎麼恢復原操作。
選擇很具體:新操作取得報價列鎖後,另取一次主庫時間作為 accepted_at,以它判斷是否仍有效。已經提交的原操作則重放收據,不重新接受報價。
報價鎖住價格,不先占走庫存
沿用單倉、單幣別、最多 20 個 SKU、MySQL 8.4 InnoDB 單一寫入主庫與模組化單體。付款仍在訂單成立之後,報價期限和付款期限各自管理。
結帳頁先向伺服器取得 quote_id、品項、數量、單價、總價與 expires_at。每份報價自建立起有效 5 分鐘,時間以 UTC 保存到微秒。畫面可以倒數,最終判斷使用主庫時間;裝置時鐘不參與裁決。
報價主表記錄客戶、幣別、建立時間、到期時間與可空的 consumed_order_id,明細保存正規化 SKU、數量與整數最小貨幣單位金額。例如 500 元以 50,000 分表示。報價的品項、金額與期限建立後不可覆寫。 改數量或重新報價就建立新 ID,畫面重新呈現後由顧客確認。
伺服器產生報價時,要在同一個一致性讀取範圍內取得各品項價目,再保存完整結果,避免多次普通查詢各自取得不同時間的價格。InnoDB 的 REPEATABLE READ 在同一筆交易中,共享第一次一致性讀建立的快照;這是報價產生端可採用的機制,不表示價目更新後舊報價會自動變動。MySQL:Consistent Nonlocking Reads
首版不加優惠券、運費與稅額規則。若結帳總價日後包含它們,就要一起納入報價內容與適用條件,不能只凍結商品單價,送單時才突然加一筆費用。
這份承諾只保價。顧客拿到報價後,其他人仍能買走商品;下單時繼續使用第 11 課的多品項共同預留,不足就整單缺貨。每份報價最多成立一張訂單,成功後消耗報價,取消訂單也不把它恢復成可用。
期限判斷放在取得報價鎖之後
假設報價於台北時間 10:05:00 到期。請求在 10:04:59 到達入口,卻等到 10:05:01 才取得報價鎖,算不算有效?
這裡選擇到期。入口收到請求只代表開始排隊,還沒有取得接受這份報價的資格。對外說明應寫成「伺服器完成報價受理判斷前有效」,不能保證在倒數歸零前按按鈕就一定買成。
取得列鎖後才取時間,也有一個容易寫錯的 SQL 細節:MySQL 的 NOW()、UTC_TIMESTAMP() 等目前時間函式,於敘述開始時求值。同一條鎖定查詢如果等待兩秒,裡面的時間不會自動變成取得鎖那一刻。MySQL:Date and Time Functions
因此用兩條依序送出的敘述:
-- 已開始共同交易,並取得新操作的唯一收據資格。
SELECT customer_id, expires_at, consumed_order_id
FROM quotes
WHERE id = :quote_id AND customer_id = :authenticated_customer_id
FOR UPDATE;
-- 等上面的鎖定讀返回,應用程式才另外送出這條敘述。
SELECT UTC_TIMESTAMP(6) AS accepted_at;
應用比較 accepted_at < expires_at,相等也算到期。兩個值使用相同 UTC 與精度約定,不能把沒有時區的資料交給不同機器各自猜。FOR UPDATE 在明確交易內取得列鎖,直到提交或回滾才釋放,讓同一份報價的競爭依序判斷。MySQL:Locking Reads
若 10:04:59.900000 通過判斷,庫存寫入完成後於 10:05:00.100000 才提交,仍按舊報價成立。產品選的是受理時有效,沒有要求 COMMIT 必須早於期限。交易仍受既有請求時間預算約束,不能因為已取得報價鎖就無限等待。
這也表示主庫時鐘是業務輸入。部署時要校時並監控時鐘偏移,故障切換後也要核對。把來源換成資料庫,能統一裁決位置,沒有消除時鐘跳動的風險。
同一鍵重送時,先找原結果
報價 Q 在 10:04:59 已成立訂單 O,成功回覆卻遺失。10:05:02 顧客重送原鍵,如果 middleware 一進來就檢查 Q 到期,原成功便被改成拒絕。
期限只管新操作能否接受報價。恢復原操作時,沿用第 4 課的唯一收據,順序如下:
- 完成身分與靜態輸入驗證。購買意圖摘要包含正規化版本與
quote_id;品項、數量與幣別由伺服器報價取得,不接受客戶端總價作權威。 - 依
(customer_id, idempotency_key)識別操作。同鍵不同摘要回衝突,同鍵已有終局則重放原結果,這條路不再檢查現在是否到期。 - 新操作在共同交易插入唯一收據。指定冪等唯一鍵競爭時,先清理這次交易,再核對原操作,不能把事前 SELECT 查無資料當成取得資格。
- 鎖定屬於目前客戶的報價,確認尚未消耗,再取得
accepted_at。到期就提交quote_expired終局收據,不建立訂單、不改庫存。 - 有效時,依第 11 課鎖定與檢查全部庫存。缺貨只提交缺貨收據,報價仍未消耗;若顧客重新發起購買且報價仍有效,可用新鍵再試。
- 全部足夠,建立訂單與成交快照、共同預留,把報價標成由這張訂單消耗,保存
accepted_at與成功收據,一起提交。
另一個新鍵若競爭同一份 Q,必須等報價鎖。前者成功提交後,後者看到 consumed_order_id,保存 quote_used 拒絕結果。它不會把前一張訂單當成自己的成功,也不會因為換鍵就多成立一次。
報價承諾價格;下單交易才檢查並預留庫存。
原鍵同內容・已有終局收據
重放原訂單或原拒絕結果,不再用目前的報價期限重判。
新操作・未使用且未到期
取得報價鎖後,另取資料庫時間,以 accepted_at 小於 expires_at 判定。全部庫存足夠,才共同提交訂單、報價使用紀錄與成功收據。
新操作・報價已到期
只提交到期拒絕收據。顧客確認新報價後,以新鍵購買。
正常缺貨只提交拒絕收據,報價仍未使用。死結或鎖等待逾時,清理整筆交易。
AWS Builders’ Library 的 Malcolm Featonby 在〈Making retries safe with idempotent APIs〉說明操作識別、原子保存副作用與操作紀錄、晚到請求及同鍵參數改變。這裡的 5 分鐘、報價單次使用與拒絕結果政策是案例選擇,並非 AWS 替商店訂的規則。
報價期限也不決定收據保留多久。沿用既有的長期精簡去重紀錄,不能 5 分鐘後把操作鍵刪掉,讓晚到重試重新成立。報價資料的清理必須保留訂單成交快照、原操作摘要與結果之間的可核對關係。
交易回滾後,先前通過的期限判斷也失效
同一條連線依序取得收據、報價與庫存,鎖定順序由所有寫入入口共同遵守。商品改價只影響新報價,不回寫已存在的報價明細;下單期間沒有外部 HTTP 呼叫。
如果庫存寫入後發生例外,訂單、預留、報價消耗與未提交收據一起回滾。應用選擇在 deadlock 或 lock timeout 後清理整筆交易;MySQL 預設只有 deadlock 回滾整筆,lock timeout 通常只回滾當前敘述,兩者不能混為一談。MySQL:InnoDB Error Handling
重新嘗試是新的資料庫交易。前一輪 accepted_at 沒有提交,就沒有留下保價資格;必須重新取鎖、重新取時間。第一次在期限前,回滾後第二次已到期,最後可以是 quote_expired。若產品希望入口第一次收到就替顧客保住價格,需要另外設計可持久化的受理狀態與恢復流程,這套最小方案沒有提供。
若連 COMMIT 是否成功都不確定,先以原鍵核對主庫終局收據。不能拿「現在已到期」推論舊交易失敗,也不能替顧客自動換一份 550 元的新報價繼續買。
另一條路:下單時發現改價,請顧客重新確認
若產品不願承擔 5 分鐘內的價差,可以維持即時價目,讓客戶端帶上已確認的價目版本。下單發現版本不同就拒絕並提供新報價,顧客確認後發起新操作。即使價格下降,也照產品契約決定是否需要再次確認,不能讓各入口各自猜。
這條路少存一份可接受的價格承諾,但版本檢查仍要和成交快照取得同一個一致的決策邊界;「先比版本、稍後再讀目前價」依然有競爭。變價頻繁時,顧客也可能連續被要求確認。
選擇不可變報價,是因為這個結帳流程要展示一個可短暫接受的總價。成本是報價儲存、到期清理、單次使用競爭與商業價差。上線後分別量測到期拒絕率、報價鎖等待、從展示到送出的時間,以及按報價成立相對目前價目的價差,才能判斷 5 分鐘是否合適。
假設持續每秒產生 10 份報價,有效期 300 秒,穩態下約有 10 份/秒 × 300 秒 = 3,000 份 尚在有效時間內的報價。每份若都放滿 20 個 SKU,對應最多約 60,000 筆這些報價的明細。這只估有效時間窗內的資料量,不含到期歷史、索引與收據保留,也不代表同時連線數或已實測容量。
若顧客經常因排隊而到期,先區分報價鎖等待與庫存鎖等待。只有產品確實需要「入口已受理就保價」,才升級為持久化受理流程。若價格來自獨立遠端系統,無法放進目前交易,則要重訂報價發行與接受協定,不能在持有庫存鎖時等待遠端重算。
驗收用三個時間點,加一個競爭對手
先建立價格 500 元、期限 10:05:00 的 Q。讓第一個操作在期限前通過判斷、提交後丟棄 HTTP 回覆,再把目前價目改為 550 元。期限後用原鍵重送,必須回同一張 500 元訂單,庫存只扣一次;用原鍵換新 quote_id 必須衝突。
第二組以同步屏障卡住報價鎖,讓請求在期限前開始等待,期限後才取得鎖。另取的主庫時間應使新操作到期,沒有任何庫存副作用。補一個恰等於期限的測資,以及期限前接受、期限後提交的反例,確認實作沒有偷偷改成 COMMIT 期限。
第三組讓兩個不同鍵同時使用 Q,庫存足夠。放行後恰有一張訂單,另一個操作得到 quote_used。再注入庫存寫入後的例外,確認報價沒有被單獨消耗;等到期後重新嘗試,應重新判斷為到期。缺貨分支也要驗證原鍵永久重放缺貨,新鍵則在報價仍有效時才有再試機會。
最後用另一個客戶送入 Q,應在授權邊界被拒絕,不洩露他人的品項與價格。把前端總價改為 1 元也不能影響成交快照。這些測試和執行計畫、延遲量測分開記錄,站台 build 通過不代表訂單服務已經通過併發驗收。
練習
Q 於 10:05:00 到期。操作 K 在 10:04:59 通過報價判斷,接著遇到 deadlock,整筆交易已確認回滾。10:05:01 以 K 重試時,工程師想沿用記憶體裡的 accepted_at,理由是「顧客第一次按下時還沒到期」。應接受嗎?如果第一次其實已提交,只是回覆遺失,答案又有什麼不同?
參考思路
已確認回滾時不接受舊 accepted_at。這個方案的資格跟著共同交易提交,回滾後沒有持久化的接受結果。重試取得報價鎖後要重新取主庫時間,保存到期拒絕收據。
第一次已提交則重放 K 的成功收據,不進入新報價接受流程。若目前還不知道屬於哪一種,先核對原操作;無法確認時保持結果待確認,不換鍵、不改價重建。
實作時,把期限檢查前的分支標出來:那條路是在恢復已作出的決定,還是在允許一個新決定?用上面兩種相反結果驗證分支,避免一個共用的 isExpired() 把它們混在一起。
一手參考資料
查核日期:2026-10-22。時間點、報價期限及單次使用規則是前述設計選擇。
- MySQL 8.4 §17.7.2.3:Consistent Nonlocking Reads:同交易快照與隔離級別。
- MySQL 8.4 §17.7.2.4:Locking Reads:明確交易中的鎖定讀及鎖釋放。
- MySQL 8.4 §14.7:Date and Time Functions:目前時間在敘述開始求值、UTC_TIMESTAMP 與小數秒精度。
- MySQL 8.4 §17.20.5:InnoDB Error Handling:死結與鎖等待逾時的回滾範圍。
- Malcolm Featonby:Making retries safe with idempotent APIs:操作識別、原子紀錄、晚到請求及同鍵不同意圖。
