訂單 A 預留了兩件商品,庫存還能賣八件。顧客按取消,資料庫成功把兩件還回去,HTTP 回覆卻在途中遺失。顧客沒看到結果,又按一次。

就在兩次取消之間,另一位顧客成立了訂單 B,預留三件。現在可售七件、預留三件。如果 A 的重試再把兩件「還回去」,可售就變成九件、預留只剩一件。每個數字都大於零,API 也能回成功,但 B 明明還占著三件。

這個錯誤甚至能躲過 reserved >= quantity 的檢查:目前預留三件,足夠減掉 A 要歸還的兩件。問題是,那三件已經屬於 B。

本篇要解決的就是這件事:只有仍持有預留的訂單,才有資格歸還那份數量。 取消重試要回覆已取消,庫存不再動。

以下沿用需求篇的虛構商店:單倉、單幣別、每張訂單一種商品,尚未付款與出貨。這是系列第 5 篇;上一篇處理下單重試,這次把重試放到取消之後。數字與資料欄位都是教學假設,SQL 是未在真實 MySQL 執行的設計片段。

庫存總數看不出兩件商品屬於誰

只存 SKU 的可售數與預留總數,無法回答 A 是否已經歸還過。需要再保留一筆屬於訂單的預留,記下 order_id、sku_id、quantity 與狀態。成立時是 held,歸還後是 released,不再重新啟用。

於是,A 第一次取消要同時完成三件事:訂單 created → cancelled、預留 held → released,以及庫存可售加二、預留減二。第二次只要確認 A 已經是 cancelled + released,就能直接回覆已取消。B 的三件商品完全不參與這次判斷。

最小資料模型因此保留三份紀錄:

  • orders:訂單 ID、customer_id 與業務狀態,決定誰有權取消、目前是否允許取消。
  • reservations:唯一的 order_id 對應預留的 SKU、不可變數量與 held/released。數量從這裡取,不能相信客戶端要求「加回五件」。
  • stock:SKU 的 available 與 reserved。後者必須等於本系統該 SKU 所有 held 數量總和;若另有其他占用來源,對帳定義也要包含它們。

預留數量使用整數、NOT NULL 與 CHECK (quantity > 0)。三者各有用途:MySQL CHECK 允許判斷為 UNKNOWN,只有正數檢查仍可能放過 NULL。庫存兩個計數則必須非負。

但這些欄位限制仍抓不到開頭的錯誤。錯誤歸還後,可售九加預留一仍等於十,總量也沒變。要抓到它,必須把庫存的預留一件,拿去對照 B 仍為 held 的三件。這就是為什麼取消不能只測一個 HTTP 200,甚至不能只測庫存總和。

三份紀錄必須一起成功,也一起失敗

沿用寫入責任篇的模組化單體與單一 InnoDB 主庫。取消用例協調同一條連線;訂單模組管理狀態,庫存模組管理預留與計數,模組不能各自提交。

首版選擇明確鎖定後判斷,順序固定為既有訂單、其預留,再到 SKU 庫存。下面是流程規格,省略 SQL adapter、錯誤碼映射與授權細節:

BEGIN
  用主鍵讀訂單 FOR UPDATE,核對所有權與目前狀態
  讀此訂單唯一預留 FOR UPDATE
  若訂單 cancelled 且預留 released:不補庫存,結束交易後回已取消
  若不是 created + held:拒絕非法轉移;資料不一致則回滾並告警
  取得預留內不可變的 sku_id 與 quantity
  條件更新預留 held → released,必須改變一列
  庫存 available + quantity、reserved - quantity,必須改變一列
  條件更新訂單 created → cancelled,必須改變一列
COMMIT 確認後回已取消

FOR UPDATE 需放在明確交易內,鎖在提交或回滾後釋放。MySQL 8.4 鎖定讀說明它會讀取目前可用資料並設定排他鎖;本篇用它讓另一個取消請求等待後,依已提交狀態重新判斷。不要在交易外先做一般 SELECT,拿舊結果決定釋放。

取消與預留釋放共同提交,重送只讀終局請求 A 鎖定訂單與預留,確認 created 與 held,將預留改為 released、歸還數量並取消訂單,最後共同提交。請求 B 等待後讀到 cancelled 與 released,不再歸還。A 若回滾,B 才可能重新執行取消。請求 A:同一連線、同一交易鎖定並核對訂單 created + 預留 held確認所有權與不可變數量鎖順序:訂單 → 預留合法轉移才可歸還held → released,恰好一列可售 +q、預留 −qcreated → cancelled共同 COMMIT成功確認後,才回已取消中途失敗:整筆回滾提交未知:重新確認狀態預期一列的寫入得到零列:回滾並診斷,不能單獨提交釋放標記。請求 B:等 A 提交後,讀到目前終局cancelled + released同一張訂單已完成取消與歸還回已取消,不再補庫存其他訂單的預留不能當作本單的釋放資格若 A 整筆回滾,B 可能看到 created + held,再完成一次合法取消。取消只歸還一次的直向流程桌面圖的直向排列。請求 A 鎖定並核對訂單和預留,完成狀態轉移與數量歸還,一起提交。請求 B 等待後看到已取消與已釋放,不再增加庫存。A:同一筆交易鎖訂單,再鎖預留created + held,確認所有權取用已保存的 SKU 與數量合法轉移才可歸還held → released,恰好一列可售 +q、預留 −q訂單改成 cancelled共同 COMMIT確認成功才回已取消中途失敗:整筆回滾B:等 A 提交後cancelled + released回已取消,不再增加庫存別人的預留不能釋放A 回滾:B 才可能接手取消。提交未知:重新確認,不猜結果。
q 來自該訂單不可變的預留數量。鎖控制同時進入,持久終局狀態控制下一次重送;兩者都要保留。

預留轉移的條件可以寫成以下片段;:order_id 等符號代表需由 adapter 綁定的參數,不能把整段當可直接貼到 MySQL client 的完整實作:

UPDATE reservations
SET status = 'released'
WHERE order_id = :order_id AND status = 'held';

-- 只有上述轉移成功一列,才可執行這個數量歸還。
UPDATE stock
SET available = available + :quantity,
    reserved = reserved - :quantity
WHERE sku_id = :sku_id AND reserved >= :quantity;

UPDATE orders
SET status = 'cancelled'
WHERE id = :order_id AND status = 'created';

成功釋放時,三個 UPDATE 都應改變一列。quantity 必須是已驗證的正整數;零值可能把「更新成功」變成沒有實際變更,負值則會把歸還方向反轉。MySQL UPDATE 文件區分符合條件與實際變更的列;應確認使用中的 driver 回傳語意,別只憑屬性名稱推測。

任何預期一列的寫入得到零列,整筆回滾,不單獨提交釋放標記。 零列可能是庫存列缺失、預留計數不足或狀態已改變,不能一概當成「對方已經處理過」。本方案已持有訂單與預留的鎖,遇到不符預期應視為不變量或其他寫入入口違規,留下診斷資訊。

主鍵與唯一索引也屬於這份設計。InnoDB 會依查詢掃描與索引設定鎖,沒有適當索引可能擴大鎖定範圍;不能只看到 WHERE 裡有訂單 ID,就認定只影響一列。InnoDB 各種敘述設定的鎖

A 已經取消,重試就不再碰 B 的預留

回到開頭:A 第一次取消已提交,第二次鎖住 A 的訂單與預留後,讀到的是 cancelled + released。它到這裡便結束,不執行 SKU 數量更新,所以 B 新預留的三件不會被碰到。

即使兩個取消請求同時抵達,後到的請求也要等同一張訂單的鎖。先到的提交後,它讀到已取消;先到的若整筆回滾,它則讀到 created + held,可以接手完成。圖中的兩個請求都針對同一張訂單,與故事中的新訂單 B 不同。

只有互斥鎖、沒有持久狀態不夠。第一個請求放鎖後,第二個仍會進來;若程式無條件 available + quantity,每次只是依序犯同一個錯。

lock timeout 尤其容易留下半成品。MySQL 預設對鎖等待逾時只回滾當前敘述,deadlock 則回滾整筆交易。若預留已改成 released,接著補庫存逾時,應用不能捕捉例外後繼續 COMMIT。官方錯誤處理是這個差異的依據;本案例在失敗邊界明確整筆 ROLLBACK,再做有界重試。

COMMIT 回覆遺失仍屬未知。用健康連線再次進入同一訂單的鎖定與狀態判斷,確認已取消就回已取消,未取消則受相同約束重新嘗試。超過等待預算時保留待確認,不宣稱已還原。不要從延遲副本或 REPEATABLE READ 舊快照的普通 SELECT 判斷「還沒取消」。一致性讀文件說明一般快照讀的可見性。

在 POST /orders/{id}/cancel 的回應上,第一次成功與重試都可以回已取消,但都要先核對認證與訂單所有權。提交結果未知時,保留待確認,不能把逾時寫成取消成功。下單收據仍代表「曾經成立」的歷史結果,目前狀態則由訂單查詢提供。若日後加入退款金額或其他可變參數,還得另外定義同一次取消改參數時的衝突規則。

條件 UPDATE 也能做,但不會省掉狀態判斷

另一個方案是先以條件 UPDATE 將訂單從 created 改為 cancelled,成功一列才接續鎖定預留並歸還數量,最後一起提交。它省去前面的鎖定讀,代價是零列後仍須辨識不存在、已取消或其他狀態,且必須確保不把失敗交易的局部變更提交。判讀目前狀態時要使用合適的鎖定讀或新交易,不能依賴舊快照。

本案例採先鎖定讀,是因為需要核對所有權、訂單狀態與預留是否一致,流程比較容易審閱。兩種方案都靠 InnoDB 鎖定,條件 UPDATE 並不是無鎖。站內庫存扣減文章談的是購買競爭與其他扣減方案;這裡的差別在於取消必須消耗一份尚未釋放的資格。

加入 Redis 鎖目前沒有必要。即使 Redis 鎖過期或遺失,資料庫仍必須阻止重複歸還,增加一層鎖無法取代上述終局狀態。

什麼時候這個簡單方案不夠用?

用教學數字估算:每秒 20 次取消、平均持有資料庫連線 40 ms,穩定負載下平均約 20 次/秒 × 0.04 秒 = 0.8 條連線被占用。若同一熱門 SKU 造成平均持有連線時間(含等待)升至 500 ms,便約為 10 條。這是平均占用粗估,沒有包含尾端延遲與突發,也沒有證明應配置多少 pool。

量測應分開記錄訂單鎖、預留鎖、SKU 鎖等待與整筆交易時間。不同訂單取消可能最後競爭同一 SKU;先縮短持鎖區段,確保沒有遠端 API 卡在交易內,再評估排隊或分區。鎖順序要檢查所有入口與索引路徑;固定本篇順序可以降低風險,不能保證系統從此沒有死結。MySQL 死結處理建議

一旦加入付款或出貨,這篇的兩狀態模型就到邊界了。付款成功回呼與取消同時到達時,必須定義誰能從哪個狀態轉移;已扣款訂單的取消可能需要退款,已出貨商品更不能直接補成可售。資料庫回滾也撤不回遠端付款。

在付款與出貨政策尚未定義前,不把任何非 created 狀態放進這條直接釋放路徑。 新流程需要協調付款確認、退款與庫存責任,應另作設計回顧,而非在 UPDATE 裡多加一個允許值。

測試要故意讓另一張訂單插進來

實作後,以真實 MySQL、至少兩條獨立連線與同步屏障控制交錯,檢查訂單、預留、可售數和預留總量,不能只看 HTTP 200:

  • 兩個取消同時到達:兩邊最終可取得已取消,但數量只歸還一次;再串行重送一百次,數值不動。
  • 在預留改為 released 後、庫存歸還前注入例外:整筆回滾後仍是 created、held,兩個計數未變。
  • 刻意讓另一條連線占住 SKU,製造補庫存 timeout:確認沒有提交 released,也沒有將半開交易放回 pool。
  • 在 COMMIT 後丟棄 HTTP 回覆:再次取消同一訂單後,預留與庫存不重複變動。
  • 人工建立缺失庫存列或不足的 reserved 計數:取消必須回滾並回報異常,不能只把訂單標成已取消。
  • 不同訂單取消同一 SKU,並與新預留交錯:最終 reserved 等於仍為 held 的預留總和;未授權請求不得改動任何一份資料。

這是待執行的資料庫驗收清單,不是本文已通過的測試結果。

結論:歸還的是 A 的預留,不是 SKU 裡任意兩件

這個案例選擇單一 InnoDB 交易:鎖定訂單與其預留,確認 created + held 才能釋放,三份寫入共同提交。鎖負責讓同時抵達的請求依序判斷;保存下來的 released,負責讓明天再來的請求也不會歸還第二次。

實作的第一個整合測試,就用開頭那段順序:A 取消成功、回覆遺失、B 預留三件、A 再取消。最後應維持可售七件、預留三件,且三件全部對得上 B 的 held 紀錄。這個斷言能直接抓住「回覆沒錯,庫存卻錯了」的修補。

練習:重試時發現資料對不上,還能回已取消嗎?

沿用可售七件、預留三件的情境。假設 A 的訂單已標成 cancelled,但它的預留卻仍是 held。你的取消 API 應直接回成功、再歸還兩件,還是停止寫入?請說明應查哪幾份資料,以及如何避免動到 B 的預留。

參考思路

cancelled + held 不符合本篇允許的終局,也不符合可執行取消的起點。不能只因訂單已取消便當作一致,更不能拿 SKU 的三件預留補做歸還。這次交易應回滾、留下診斷資訊並交由修復流程處理;對照 A 的提交紀錄、A/B 預留與庫存計數,確認哪一段寫入破壞了共同提交的規則。資料不一致時,用另一筆猜測的更新補平數字,可能把 B 的商品一起補掉。