原本一次只能買一種商品,現在產品要開放購物車。工程師把原來的 reserve(sku, quantity) 放進迴圈,測試買兩種都有庫存的商品,順利產生訂單。

接著試一張「筆記本兩本、充電器一個」的訂單:筆記本先扣成功,充電器卻缺貨。程式捕捉缺貨結果、保存拒絕收據,再提交交易。客人沒有買成,筆記本卻少了兩本。

把缺貨改成整筆 ROLLBACK,庫存會回來,但剛寫下的拒絕收據也一起消失。補貨後重送原鍵,這次可能突然買成,違反第 4 課「同一次購買重放原決定」的契約。

這一課選擇將預留分成兩個階段:在同一筆交易中,先鎖定並檢查所有品項;全部足夠,才開始扣減。缺貨分支因此能提交拒絕收據,又不必撤回前面已扣的商品。

多品項改變了失敗邊界,不能只把函式呼叫變成迴圈

這是「系統設計每日實戰」第 11 篇。前一課完成退款工作交接,現在回到下單入口,增加一項產品能力。沿用單倉、單幣別、MySQL 8.4 InnoDB 單一寫入主庫;每張訂單改為最多 20 個不同 SKU,不處理跨倉、套組拆解、折扣券或部分成立。

20 是這個案例的產品上限,不是 MySQL 的能力上限。每個 SKU 的 available 表示可售件數,以整數保存;需要幾件就預留幾件。每種上架 SKU 都已建立唯一的庫存列,下單期間不由其他流程刪除。顧客付款與金流 API 都不在這筆交易內。

一張訂單要嘛預留全部品項,要嘛一件也不預留。 缺貨時只保存此次購買的拒絕結果,不自動幫顧客成立剩餘品項。若產品要支援部分購買,必須另外決定運費、總價與顧客授權,不能由哪個 SQL 先成功替它決定。

進入交易前,先把請求整理成唯一的庫存需求:

  • 同一 SKU 出現兩行,數量合併。例如 101 × 1 加 101 × 2,必須檢查 101 × 3,不能各自看到庫存 2 就都通過。
  • 驗證數量是正整數,合併後仍在本店數量與資料型別範圍內;品項上限以合併後的 SKU 數計算。
  • 用 SKU 的固定數值順序排序,不用商品名稱、加入購物車時間或畫面排序。SKU 若是 64 位元識別值,應使用不會失真的整數表示與比較。
  • 冪等摘要保存正規化後的完整品項及數量,並帶正規化版本。品項顯示順序定義為不影響購買意圖;改變數量則算不同內容,原鍵應衝突。

伺服器價格仍依第 3 課的政策,保存這次成立時採用的成交快照;不把重試時重新讀到的目前售價混進請求摘要。這一課不重新定義報價有效期。

第一筆扣減之前,先取得所有品項的決定權

假設庫存主鍵為 (warehouse_id, sku_id)。例子裡只有倉庫 1,筆記本為 SKU 101,充電器為 SKU 202。下單用例沿用共用連線的交易邊界,依序做以下工作:

  1. 開始交易,插入 (customer_id, idempotency_key) 的唯一操作收據。若指定的冪等唯一鍵衝突,依第 4 課先結束本次交易,再查原收據;不能繼續預留。
  2. 讀取並準備成交快照。此時收據尚未提交,不留下獨立的長期 processing。
  3. 依完整庫存主鍵的升冪順序,逐筆發出 SELECT ... FOR UPDATE,等這筆取得結果後才送下一筆。所有操作仍使用同一條連線。
  4. 拿齊鎖定列後,比較每個 SKU 的可售數量與合併需求。任何一個不足,就把原操作收據填為 out_of_stock,不建立訂單、不扣庫存,直接提交拒絕結果。
  5. 全部足夠,才建立訂單、各品項成交快照及對應預留,扣減全部庫存,填好成功收據。共同提交確認成功後,才回覆 201。

第三步的兩個查詢可以長這樣。它們在應用迴圈內依序送出,不是兩個並行 Promise:

-- 同一條連線、同一筆明確交易;此處收據已插入但未提交。
SELECT available
FROM stock
WHERE warehouse_id = 1 AND sku_id = 101
FOR UPDATE;

SELECT available
FROM stock
WHERE warehouse_id = 1 AND sku_id = 202
FOR UPDATE;
-- 兩筆都取得後,由應用程式檢查 101 是否至少有 2、202 是否至少有 1。

這裡刻意不加 available >= quantity 的查詢條件,讓程式能分辨「確實有這列,但數量不夠」與「預期存在的庫存列消失」。後者是資料或目錄不一致,做法是整筆回滾並回報異常,不把查無列當成正常缺貨。

MySQL 8.4 的鎖定讀文件說明,FOR UPDATE 讀取最新可用資料並取得排他鎖,鎖到交易提交或回滾才釋放。因此讀完 101 再讀 202 的期間,其他交易不能先把我們鎖住的 101 改掉。普通 SELECT 沒有這項保護;「全部查過都足夠」後才去扣,中間仍可能被另一張訂單買走。

若全部足夠,更新仍可保留防禦條件:

UPDATE stock
SET available = available - :quantity
WHERE warehouse_id = :warehouse_id
  AND sku_id = :sku_id
  AND available >= :quantity;

每個正數需求都應更新一列。已持有全部鎖、剛驗過數量,結果卻是零列,應視為實作或資料不變量異常,整筆回滾;不能保存缺貨收據然後提交前面的扣減。各品項的預留須綁定訂單明細,取消時仍歸還那份預留,不按 SKU 猜測歸屬。

鎖定全部品項後,才選擇整單成立或拒絕同一筆交易先保存未提交的操作收據,依 SKU 順序鎖定所有庫存。缺貨時只提交拒絕收據,庫存不變;全部足夠才共同提交訂單、全部預留、扣減與成功收據。同一條 MySQL 連線・同一筆交易操作收據尚未提交;商品顯示順序不決定取鎖順序。依完整主鍵逐筆鎖定SKU 101 → SKU 202 → … 每筆取得後才繼續,這時尚未扣庫存。拿齊鎖定列後,檢查每種商品的合併需求101 要 2 件,202 要 1 件;任一不足就整單拒絕。有品項缺貨只提交缺貨終局收據。沒有訂單、沒有預留、沒有扣減。全部足夠建立訂單、全部預留與扣減。與成功收據共同提交,才回 201。

同一條連線、同一筆交易;以下是同一個檢查點的兩種結果。

依序鎖定,再檢查全部

收據尚未提交。依主鍵先鎖 101,再鎖 202,拿齊後檢查合併需求;尚未扣庫存。

有品項缺貨

只提交缺貨終局收據。沒有訂單、預留或扣減。

全部足夠

訂單、全部預留、扣減及成功收據共同提交,確認成功才回 201。

鎖等待失敗不屬於缺貨分支:它沒有提供判定缺貨的資料,應清理整筆交易,再依原鍵恢復。

圖中的缺貨分支沒有庫存寫入,只有拒絕收據。因此即使拒絕回覆在網路途中遺失,補貨後再送原鍵仍回原結果;顧客要重新購買時才建立新鍵。成功分支若在第二筆預留寫入時失敗,則連第一筆扣減、訂單與收據一起回滾。

兩張購物車的顯示順序,不該變成相反的取鎖順序

現在把庫存補足,讓兩個請求同時買 101 與 202,只是畫面順序不同。若直接照輸入順序呼叫:

  1. A 先鎖住 101。
  2. B 先鎖住 202。
  3. A 要鎖 202,等 B。
  4. B 要鎖 101,等 A。

這兩筆交易互相等對方放手,形成死結。改成兩邊都先鎖 101、再鎖 202,B 會先等在 101,尚未拿走 A 下一步要的 202。A 提交後,B 才拿到目前的數量並重新判斷是否足夠。

所以用固定順序消除這個交錯。MySQL 的死結處理建議也包含一致操作順序、縮短交易與適當索引。它不代表只排 SKU 就能消除整個應用的所有死結:唯一索引、外鍵,以及取消或後台調整的其他取鎖路徑仍可能參與競爭。

因此要把庫存模組的規則寫成「每次取得一組庫存列,都依完整主鍵順序」,並檢查下單、取消與批次調整都遵守。取消既有訂單時可以先鎖那張訂單與預留,再依序處理它的庫存;還必須確認其他入口不會先持有相同庫存、再反向等待那張既有訂單。新增訂單的列與既有訂單不是同一份鎖資源,不能只看表名就斷定安全或不安全。

用逐筆主鍵查詢,換取清楚的發送順序。若日後改為批次 IN (...) 查詢,不要把參數清單的排列當成取鎖順序保證;必須重新驗證執行計畫與實際競爭行為。

第 7 課的待辦佇列可以跳過忙碌工作,但庫存不能因此跳過顧客指定的商品。SKIP LOCKED 會略過已鎖列,官方文件明確指出它提供不一致的資料視圖。202 正被別人鎖住,不表示 202 缺貨;把略過的列保存成缺貨終局,會讓一個暫時忙碌變成永久的拒絕收據。

缺貨能提交,鎖等待失敗卻不能留下缺貨收據

這兩種失敗對客人的意義不同。

已鎖住全部品項,看到 202 的可售數量為 0,足以依本店政策作出缺貨決定。可以提交拒絕收據,之後重放。

等待 202 時逾時,則還沒有取得判斷所需的資料。不能把它翻譯成「充電器已售完」。應用邊界會結束並回滾整筆交易,再決定是否使用原鍵重試。

InnoDB 錯誤處理文件區分兩種行為:deadlock 回滾整筆交易;lock wait timeout 預設只回滾失敗敘述。後者發生後若直接 COMMIT,前面插入的未完成收據仍可能被提交。這裡額外採取「這兩種錯誤都清理整筆交易」的應用政策,不靠錯誤碼一出現就假定連線已乾淨。

自動重試最多三次,包含第一次,且每次都受原請求的剩餘時間預算限制;這是示範上限,實際值需量測。重新嘗試時從唯一收據開始,重新取得全部鎖、重新讀數量,不從上一輪記憶體裡的「101 足夠」繼續。退避等待在交易與連線釋放後才發生。無法確認清理完成的連線不放回 pool。額度耗盡就回可恢復的暫時失敗,保留原鍵,不冒充缺貨。

如果斷線發生在 COMMIT 附近,連本店是否已成立都不知道,則回到第 4 課的原收據核對流程。不知道交易結果時,不換鍵重建,也不自行宣告缺貨。

Savepoint 方案:先扣再退回,多一組必須驗證的規則

另一個可行方案是先插入唯一收據,建立 savepoint,再依序做條件扣減。碰到數量不足時 ROLLBACK TO SAVEPOINT,撤回那之後的所有扣減與預留,然後在保留下來的收據寫入缺貨結果並提交。

這可以減少先讀全部庫存的階段,但 savepoint 必須放在第一個業務副作用之前、收據插入之後;不能只撤回「失敗的那個 SKU」。MySQL 的 Savepoint 文件說明,回到 savepoint 不會結束交易,也不會一般性地釋放其後已存於記憶體的列鎖。它不能當作提早釋放其他商品的辦法。若發生整筆交易已回滾的 deadlock,也不能再嘗試回到原 savepoint 保存拒絕。

最多只有 20 個 SKU,選擇先鎖再驗,讓「正常缺貨時尚未扣任何庫存」直接從程式順序看得出來。代價是多次資料庫往返,也讓第一個 SKU 的鎖涵蓋後面所有讀取與寫入。

以 20 個逐筆查詢、每次網路來回假設 1 毫秒估算,光鎖定讀就約 20 毫秒;若來回是 5 毫秒,則約 100 毫秒。這還不含鎖等待、資料庫執行、寫入與提交,不能當成訂單延遲的量測值。增加應用副本也不會讓同一熱門 SKU 的排隊消失。

升級前要把交易時間拆成鎖等待、SQL 往返與其他工作,並依購物車品項數分組。若主要成本是往返,才比較可驗證的批次鎖定或 savepoint 方案;若主要成本是熱門 SKU 等待,改查詢包裝未必有幫助。若要跨資料庫分片,這套共同交易的前提已不存在,必須重新選擇預留、補償與回覆承諾,不能沿用同一個 reserveAll() 名稱就當成等價。

站內的 Shopify 庫存預留復盤討論單元池與連線成本;那種一件商品一個可用單元的模型,不等於這裡每個 SKU 一列計數的模型。這一課先把多品項共同成立與拒絕收據接對,沒有據此宣稱需要搬用大型平台的資料結構。

驗收要故意讓第二個品項失敗

先建一組隔離測資:101 有 5 件、202 有 0 件,請求要買 101 兩件、202 一件。執行後斷言:101 仍為 5、202 仍為 0,沒有訂單與預留,只有一份缺貨終局收據。補足 202 後用原鍵重送,這些結果不變;改用新鍵才可能成立。

第二組使用兩條真實 MySQL 連線與同步屏障。A 鎖住 101 後暫停;B 帶相反的畫面順序送出相同兩種商品。確認 B 仍先等 101,尚未送出對 202 的鎖定查詢;讓 A 繼續並提交後,B 才依 A 扣完後的數量作決定。這組只驗證實作採用了正規化順序。

第三組庫存足夠,在第一筆扣減與預留完成、第二筆寫入之前注入例外。整筆清理後,用新連線查兩個 SKU、訂單、預留與收據,全部應回到交易前;不能只檢查 HTTP 回了錯誤。另以測試連線持有 202 的鎖,讓下單在等待時逾時,驗證沒有被保存成缺貨,且重用 pool 時沒有殘留交易。

最後補上同 SKU 重複行、同鍵改數量、指定冪等唯一鍵競爭,以及提交後刻意丟棄回覆。後者用原鍵恢復,只能找到同一張含全部品項的訂單;每個明細恰有一份有效預留,各 SKU 的扣減只發生一次。死結路徑也要驗證整筆重試,不只重跑收到錯誤的 SQL。

下一次把單品項 API 改成購物車,就用「第二件缺貨」當第一個驗收案例。檢查拒絕收據提交前,是否已經有任何庫存副作用;若有,必須指出它在哪個交易邊界被完整撤回。

練習

一個請求買 101 兩件、202 一件。101 已被下單交易鎖住,但還沒扣減;202 被另一個交易鎖住,下單等待到逾時。工程師在 catch 裡寫入 out_of_stock 並 COMMIT,認為「反正還沒扣庫存,不會出事」。

這個處理違反了哪個承諾?應怎麼清理及恢復?如果 202 稍後釋放鎖後其實仍有 3 件,原鍵是否必須永遠缺貨?

參考思路

沒有漏扣庫存,不代表拒絕結果正確。這次只知道鎖等待失敗,沒有讀到足以判定缺貨的數量;不能把暫時的競爭保存成同鍵永久重放的缺貨。

依前面的策略,明確回滾整筆交易,包括尚未提交的收據;確認清理完成才釋放連線。仍有時間及重試額度時,以同一鍵從頭執行,重新鎖定並讀取兩個 SKU。若已確認原交易回滾、沒有終局收據,且新一輪兩種庫存都足夠,就可以共同成立;不必憑這次逾時永久缺貨。若提交結果不明,則先核對主庫收據,不能把「沒收到成功」當成「已回滾」。