顧客按下結帳,畫面轉了幾秒,顯示逾時。他再按一次,後台卻出現兩張訂單。第一次可能已經提交,只是回覆沒有送到手機;第二次若換一個操作識別值,後端很難知道這是重試,還是顧客確實想再買一份。
解法是讓同一次購買一直使用同一把鍵,再把這把鍵的收據、訂單與庫存預留放進同一筆 MySQL 交易。兩個請求撞上同一鍵時,先由唯一約束決定誰能繼續;後到的請求讀回原結果,不能再走一次扣庫存。
這是「系統設計每日實戰」第 4 篇,沿用需求篇的虛構案例:單倉、單幣別、每張訂單一種商品,尚未付款;201 前必須共同提交訂單、成交快照與庫存預留。上一篇已決定由下單用例協調共同連線,這次補上其中的 findOrClaimOperation。
本文所有欄位、容量與時限都是教學假設。流程是待實作設計,沒有執行真實 MySQL 併發測試或壓測。
同一鍵代表同一次購買意圖
收據可以叫 order_operations,最低限度包含:
customer_id與idempotency_key:皆非空,建立具名複合唯一約束,例如uq_order_operation。本文這張表只服務下單;若未來共用到其他操作,鍵的範圍也要納入操作類型。request_fingerprint與正規化版本:由已驗證的商品、正整數數量等業務欄位產生,不直接雜湊原始 JSON 字串。欄位排序、預設值或不同表示法不該誤判為新內容;也不納入稍後重讀的目前售價,以免改價讓原鍵重試變成衝突。- 終局結果、訂單 ID 與必要的回覆資料:成功或已決定的缺貨都保存。認證失敗、格式錯誤則在進入這個流程之前拒絕。
使用者識別值必須來自已驗證身分;每次查收據或重放結果仍需授權。知道別人的 key 不代表能看別人的訂單。鍵值比較也應明確區分大小寫,例如採 binary 語意,不讓資料庫預設定序意外合併兩個鍵。
同鍵、同內容,重放原決定;同鍵、不同內容,回覆明確衝突。顧客真的要再買一份,才建立新鍵。AWS Builders’ Library 的冪等 API 設計討論 caller-provided identifier、原子記錄操作,以及同一識別值攜帶不同參數的問題。這支持上述設計方向,具體欄位是本案例的選擇。
唯一收據和訂單一起提交
最小方案使用單一 MySQL InnoDB 寫入主庫。以下是流程規格,不是可直接執行的程式碼:
開始交易,固定使用同一條連線
INSERT order_operations 的唯一鍵與請求摘要
讀取伺服器價格,請庫存模組嘗試預留
若缺貨且沒有庫存副作用:保存缺貨終局結果
否則:建立訂單與快照,保存成功收據及訂單 ID
COMMIT 確認成功後,才送出終局 HTTP 回覆
交易內可以有尚未填完的收據,但不能在中途把它提交為長期 processing 狀態。若程式在扣庫存後失敗,整筆交易回滾,收據也消失;若交易提交成功,收據與業務結果必須同時存在。不能留下「收據說成功,訂單沒建立」的半套事實。
缺貨走正常提交,保存已作出的拒絕決定,並保證沒有庫存或預留變更。補貨之後重送原鍵,仍回覆原本的缺貨;重新購買是新意圖,需要新鍵。這延續第一篇契約,避免同一次重試因庫存變化而突然成立。
代價是操作收據也參與鎖競爭。案例若每天 10 萬個新操作,每份收據連同索引假設平均 512 bytes,一天約 100,000 × 512 = 51.2 MB,365 天約 18.7 GB,均為十進位估算,不含回覆快取、複本與備份。這不是量測結果;實際欄位與索引大小必須抽樣驗證。
A 還沒提交時,B 卡在哪一行?
假設同一位顧客的兩個請求 A、B,都帶著 checkout-123 和相同商品、數量。兩條獨立連線先 SELECT,都可能查不到收據。所以「查過沒有」不能當成領到這次操作的資格。
本方案讓每個新請求先 INSERT 收據,成功後才往下讀價、預留與建單。在沒有其他競爭者、也沒有逾時或死結的簡化順序裡:
- A 開始交易,成功插入
(customer_id, checkout-123),但還沒 COMMIT。 - B 開始另一筆交易,嘗試 INSERT 同一鍵。B 可能在這一行等 A 的鎖,不能因為 HTTP 還沒回應就先去扣庫存。
- 若 A 建好訂單並提交,B 的 INSERT 得到指定唯一鍵的 duplicate-key。B 回滾自己的交易,再從主庫的新查詢取得 A 的終局收據,核對摘要後回原訂單。
- 若 A 在建立訂單前失敗並回滾,A 的收據與預留一起消失。B 才有機會插入該鍵,接著走自己的下單交易。
這裡的「等」有實際代價:B 的連線仍被借用,請求時限也繼續流逝。不能讓同鍵重送無限占著 pool。超過等待預算就回結果待確認、保留原鍵,後續再恢復;不要把等待逾時保存成缺貨。
真實執行還有其他交錯。MySQL 的鎖定行為文件說明,重複鍵檢查涉及 shared lock,多個等待者可能形成 deadlock。因此「A 回滾,B 就一定立刻成功」不是所有併發下的保證,B 仍須能整筆重試。
捕捉錯誤時也要辨認是哪個索引。只有 INSERT order_operations 時,撞上 uq_order_operation,才有資格查原操作。若撞的是訂單號、另一張表或其他唯一索引,那是另一種錯誤,不能一律回「這張單已經處理過」。
重放分支先 rollback,再用主庫的新 autocommit 查詢核對 request_fingerprint 與正規化版本;相同則重放,相異則回衝突。沒有終局收據時仍保留未決,不能略過唯一約束另建訂單。
為什麼不在原交易裡再 SELECT 一次就好?InnoDB 預設的 REPEATABLE READ 會讓一致性讀沿用第一次讀取的快照。如果 B 在 A 提交前已建立快照,普通 SELECT 可能仍看不到 A 剛提交的收據;INSERT 的唯一檢查與這份快照並不是同一種讀取。MySQL 一致性非鎖定讀說明這項差異。本例結束舊交易後重讀,也不去延遲複本判斷操作不存在。
deadlock 與 lock timeout 的清理同樣要明確。MySQL 的錯誤處理文件指出,前者回滾整筆交易,後者預設只回滾等待中的敘述。本例在應用邊界確認整筆交易已結束,無法確認狀態的連線不放回 pool,再以原鍵有限次重試。不能 catch 完就 COMMIT,讓本次其他變更意外留下。
COMMIT 沒收到回覆,不能自行判成失敗
再把斷線點移到 COMMIT:應用已經把命令送出去,卻沒收到確認。此時連線錯誤只描述「應用沒拿到答案」,不能證明資料庫已回滾。若資料庫已提交,換新鍵重做就會真的多建一張訂單。
提交結果不明時,保留原鍵,不建立新購買意圖。 重新取得健康連線,查主庫的終局收據。查到就核對摘要並重放;查不到也不能只憑那一次讀取宣告第一次失敗,因為原交易可能仍在完成。
後續仍以同一鍵重新進入唯一約束保護的流程。若原交易最後提交,新的 INSERT 會被唯一約束擋住;若原交易回滾,新的交易才有機會取得該鍵。超過有界等待與重試預算,就保留「結果待確認」,提供查詢與後續確認入口。這條恢復路徑和首次請求共用同一個唯一約束,沒有另外開一個「補單時跳過去重」的入口。
此論證依賴同一個受唯一約束保護的權威寫入資料庫。若故障切換會遺失已確認的提交,或允許兩個主庫各自接受同鍵,仍可能破壞承諾;複寫、故障切換與災難恢復需要另外驗收。
Redis 鎖與持久 processing 各自多帶來一份責任
Redis 的短效鎖可以降低同鍵流量同時打進資料庫,但鎖到期、程序停頓或快取遺失後,請求仍可能再次進入。若只靠它去重,資料庫已提交而 Redis 尚未保存結果的時間差也沒有消失。本案例保留 MySQL 唯一收據作為裁決依據;尚無量測證明需要額外 Redis 層。
另一個方案是先獨立提交 processing 收據,再非同步執行。它適合長時間工作,卻需要處理程序死掉後誰接手、租約到期與舊執行者是否仍能寫入、如何證明完成等問題。此時 API 通常要重新定義受理與完成的差別,不能沿用「201 就全部完成」而不改契約。
這條同步下單路徑只涉及本地資料。未來加入遠端付款、長時間供應商 API 或明顯鎖等待,才有理由重新評估非同步流程。遠端副作用不能靠 MySQL rollback 撤銷;站內金流到對帳閱讀路徑處理的是那個較大的邊界,本篇的交易無法涵蓋它。
暫停 A,再確認 B 還沒走到庫存更新
只把同一支 API 連續呼叫兩次,測到的可能只是「已存在的收據能重放」。要驗併發,必須在 A 尚未提交時讓 B 進場。下面是待執行的 MySQL 整合測試,本文沒有宣稱已跑過。
第一輪用兩條獨立連線與同步屏障控制時序:
- 讓 A 插入收據後暫停,交易保持未提交。
- 啟動 B,以同一使用者、同一鍵及同一內容進入。觀測 B 卡在 INSERT 的鎖等待,並斷言 B 尚未進入庫存
reserve。使用資料庫鎖等待資訊與程式執行點確認,不靠任意 sleep 猜測順序。 - 恢復 A,讓它完成下單並提交。B 應經指定唯一鍵衝突、rollback、新主庫查詢取得相同訂單 ID。
- 兩邊完成後,用新查詢檢查:該操作只有一張終局收據、一張訂單與一次有效預留,庫存只扣一次。不能只看兩次 HTTP 都成功。
第二輪清理測資後重跑,但在 A 已預留庫存、尚未提交時注入例外。A 整筆 rollback,B 才繼續競爭該鍵;最後仍只能留下 B 的一份完整結果。若再加第三個等待者,就要允許 deadlock/有界重試,最終不變量不變。
第三輪專門抓舊快照:B 先在 REPEATABLE READ 交易中查詢尚不存在的收據,A 隨後寫入並提交,B 再用普通 SELECT。確認舊交易可能仍回空值;結束 B 的交易,改成新的主庫讀取後,才應看到終局收據。這輪和前面的阻塞測試分開,不讓測試本身混入未預期的鎖交錯。
最後分開測兩種「沒有收到回覆」:
- 資料庫已確認提交,但 HTTP 回覆被測試代理丟棄。顧客以原鍵重送,應回同一訂單,不能再扣庫存。
- 資料庫 COMMIT 的確認在回到應用前被截斷。應用先進入未決,不回報已取消或庫存已還原;由測試端確認資料庫結果,再驗原鍵恢復能找到原結果或安全重新取得資格。
另補同鍵改數量、缺貨後補貨、清掉完整回覆快取等測試,確認各自仍是衝突或原結果。刻意觸發其他唯一索引錯誤時,重放分支不得被誤用。所有等待、deadlock 與 timeout 路徑結束後,都要檢查 pool 沒有留下未結束的交易。
沿用第一篇假設,完整回覆快取保留 7 天,自動重試限 24 小時,去重索引首版不自動刪除。超過快取期時,保留的終局資料與訂單快照應足以重建契約回覆;若日後要刪索引,必須先重新定義晚到重試的行為。
練習:第八天的舊鍵,該不該再扣庫存?
一張訂單第 1 天已提交,回覆遺失。第 8 天顧客重送同一鍵、相同商品與數量;完整回覆快取已清除,但去重收據與原訂單仍在。商品現在漲價,庫存也補過貨。
請決定回覆哪個訂單與價格、要不要再預留,以及客戶端若想重新購買應做什麼。再寫一個能抓到錯誤實作的測試斷言。
參考思路
驗證身分與摘要後,從收據和原訂單成交快照重建原結果,不重新計價或預留。新的購買意圖才用新鍵。測試不只檢查回覆成功,還要確認訂單 ID 與原值相同、庫存未再減少,且價格仍是原成交快照。
檢查現有實作時,可以先找 duplicate-key 的 catch:它有沒有確認索引名稱、結束舊交易,再用新讀取核對摘要?接著把 A 暫停在 INSERT 後,看看 B 是在資料庫前等待,還是已經繞去扣第二次庫存。
續篇把這個交易邊界延伸到取消訂單與一次性釋放預留,處理不同取消請求競爭同一份庫存的情況。
