下單用例已經包在 transaction callback 裡,catch 也呼叫了 rollback。訂單建立失敗,資料庫裡沒有新訂單,庫存卻少了一件。
沿著呼叫往下找,會看到一種容易漏掉的寫法:下單用例借了連線 A,庫存 repository 又從同一個 pool 借了連線 B。B 扣完庫存就提交,A 稍後才失敗。兩條連線連向同一個 MySQL,A 的 rollback 仍然撤不回 B 已提交的變更。
這是虛構案例,沒有實際執行這套服務或資料庫測試。要修的是交易的傳遞方式:由下單用例協調交易,參與的模組都使用它提供的同一條連線;庫存 SQL 仍留在庫存模組,模組不能自行 commit。
這是系列第 3 篇,沿用需求契約的單倉、單幣別、單品項訂單,尚未付款。回覆 201 前,訂單、成交價快照與庫存預留必須一起提交;原鍵重送不能多扣庫存,成立後的明細讀主庫。下面只處理「誰寫哪些資料、哪些寫入一起成功」,不把模組拆成遠端服務。
共用一個 pool,為什麼還會漏回滾?
MySQL 的交易跟著 session 走。預設 autocommit 開啟;要把多條 SQL 包成一筆交易,必須在同一 session 上開始並結束它。MySQL 8.4:autocommit、commit 與 rollback
把開頭的錯誤攤開,執行順序是這樣:
- 下單用例在連線 A 執行
START TRANSACTION,準備保存訂單。 inventory.reserve()內部另借連線 B,更新stock並建立reservations,B 提交成功。- 程式回到 A,寫訂單時發生例外。A 執行
ROLLBACK。 - 用新連線查詢:訂單不存在,B 留下的預留與庫存扣減卻還在。
每個 repository 都可能通過自己的單元測試。庫存測試只確認「呼叫後少一件」,訂單測試只確認「例外後沒有訂單」,兩個測試一起綠燈,也沒有檢查兩份資料是否一起回復。
所以介面要傳遞的不能只有 sku 和 quantity。這次呼叫屬於哪筆交易,也要一路帶到 SQL adapter。可以明傳 transaction context,也可以由交易執行器建立綁定連線的模組介面;這裡選後者,讓呼叫端取得 scope.inventory,而非隨手使用全域 repository。
既然要一起提交,為什麼不把 SQL 全寫在下單用例?
這確實是可行起點。若只有一個很小的下單入口,直接在同一連線寫訂單與庫存,比先做多層介面更容易追蹤。
保留模組,是因為後台補單、庫存調整與取消也會碰到同一批資料。如果每個入口各抄一份 UPDATE stock,下次新增預留規則,就得找齊所有副本。漏改的那支程式仍能成功執行 SQL,卻可能留下沒有對應預留的庫存變化。
因此這裡分的是寫入責任,不是資料庫連線:
- 訂單模組寫
orders、order_items與下單冪等結果。它保存這次成立時的價格快照、預留識別值與終局結果。 - 庫存模組寫
stock、reservations。reserve負責條件扣減、數量檢查與預留紀錄;缺貨時不得留下任何庫存副作用。 - 商品模組管理目前商品與價目,回傳價格、幣別與版本等值。訂單保存這份值,不把可寫的商品 ORM entity 帶回去。
呼叫端拿到的是預留結果或缺貨結果,不應拿到庫存 query builder 再自行 update()。否則只是多套一個 repository 名稱,原來四散的寫入入口還在。
這個案例接受「本次下單讀到的伺服器價格」,保存成交快照;讀價之後、提交之前即使有人改價,也不重算這份快照。若產品要求提交時仍是最新價,就要加報價有效期或版本衝突政策,transaction callback 不會替產品作這個決定。
Fowler 在 2014-01-15 的 Bounded Context 談到,同一個詞在不同模型裡可能有不同意思。這裡「商品的目前價格」和「訂單的成交價格」就是需要分開的兩個值。這裡只借它說明模型差異,沒有把每張表都稱作一個限界上下文。
讓交易執行器借連線,模組只做自己的寫入
下單用例負責流程與共同提交。以下是介面契約示意,省略參數驗證、金額型別、鎖順序及完整冪等演算法,不能直接當 TypeScript 執行:
transactionRunner.run(scope => {
previous = scope.orders.findOrClaimOperation(key, payloadFingerprint)
if previous is terminal: return previous
price = scope.catalog.readPrice(sku)
reservation = scope.inventory.reserve(sku, quantity)
if reservation is outOfStock:
return scope.orders.saveRejection(key, "out_of_stock")
order = scope.orders.create(price, reservation)
return scope.orders.saveSuccess(key, order)
})
// run 確認共同交易提交後,HTTP 層才回覆終局結果。
這裡每個 scope 介面都綁定同一條連線。run 要等 callback 內所有非同步操作完成,才能提交。模組不可把寫入丟進未等待的背景工作,也不可在裡面另開 pool 連線自行提交。遇到例外,由 run 結束整筆交易,不能捕捉失敗後照常回成功。
key 是 (customer_id, idempotency_key)。findOrClaimOperation 必須處理同鍵競爭;不能把「SELECT 沒找到」當成已取得資格。同鍵同內容重放,同鍵不同內容拒絕。這份示意只畫出交易傳遞,沒有證明冪等安全,細節留給下一篇的唯一收據。
缺貨分支則正常提交拒絕結果。庫存模組必須已保證沒有扣減或預留,訂單模組才可以保存 out_of_stock。補貨後再送原鍵,依然重放原缺貨;顧客要重新購買,才建立新鍵。若把缺貨一律當例外 rollback,拒絕結果也一起消失,原鍵下次就可能突然成立。
另一個容易誤判的地方是巢狀 transaction:庫存模組又執行一次 START TRANSACTION,不會在外層交易裡生出一個可獨立回滾的小交易。MySQL 會隱式提交當前交易。框架若用 savepoint 模擬巢狀 scope,則要查它的實際行為;函式都叫 transaction() 不代表語意相同。MySQL 隱式提交規則
模組不得自行提交共同交易。 schema migration 也不放進這段流程。許多 DDL 有隱式提交行為,不能拿外層 rollback 當保護。
把庫存改成 HTTP 呼叫,這份承諾會在哪裡斷掉?
假設 scope.inventory.reserve() 改成呼叫另一個服務。庫存服務已提交成功,訂單服務隨後失敗,現在就無法用這筆 MySQL 交易把庫存一起撤回。開頭的漏回滾問題,只是從兩條連線變成兩個服務。
拆服務可能有獨立部署與擴縮的價值,但此時要重新處理預留期限、補償、重試與對帳,也要重訂 201 的承諾。當庫存確實需要獨立團隊、發布週期或安全責任,或量測找到能藉隔離解除的瓶頸,才值得承擔這些工作。檔案變多或入口每秒 20 次,都不是足夠的理由。
留在同一個應用也有代價:訂單和庫存共用故障域與資料庫;使用同一組 DB 權限時,模組 API 是程式協作約定,不是資料庫強制的存取隔離。還要靠相依檢查、adapter 所有權與程式審查,找出繞過入口的 raw SQL。
Shopify 在 2019-02-21 的 Deconstructing the Monolith 描述以公開 API 和資料 ownership 隔開單體元件,也明說完整隔離仍在推進。這裡借用的是這個做法,沒有假定改完目錄結構就已完成隔離。
效能仍要另外量。沿用容量篇的教學假設:每秒 20 次嘗試,每次累計借用 DB 平均 0.08 連線秒,平均占用是 20 × 0.08 = 1.6 條。SQL 藏進模組不會自動縮短占用時間;如果模組另開 pool,還可能增加總量。
因此要記錄連線借用總時間、交易時間與鎖等待。共同提交只涵蓋同一筆本地 InnoDB 交易,日後加入付款時,不能在持鎖期間等金流 HTTP,再以為外層 rollback 能撤銷遠端扣款。
驗收時,把庫存寫完後的程式暫停
第一支整合測試可以直接重現開頭。使用真實 MySQL InnoDB,在 reserve 已完成寫入、但 orders.create 還沒執行的位置設同步屏障:
- 暫停下單流程,確認它還沒有提交,也沒有送出
201。 - 放行後立即注入例外,讓交易執行器 rollback。
- 等 rollback 結束,再用另一條連線的新查詢讀資料。庫存應回到原數量,這次的預留、訂單與成功冪等結果都不存在。
- 故意將庫存 adapter 換成另一條會自行提交的連線,再跑同一測試。這次必須失敗;若還通過,測試根本沒有驗到漏回滾。
這個測試不需要假裝高併發,卻能抓到「每個模組各自正確,組合起來卻不原子」的錯誤。確認回滾後,再補以下情境:
- 正常提交後改商品價格,舊訂單仍顯示原成交快照;另在讀價後、提交前改價,確認結果符合前述價格政策。
- 兩個請求同鍵進入,最終只有一張訂單與一次預留;同鍵不同內容衝突,缺貨結果也能重放。
- 搜尋跨模組 repository import 與 raw SQL。import lint 能抓部分依賴,無法單獨辨認每條 SQL 寫了誰的表。
以上都還是待執行的驗收計畫,沒有實際跑過 MySQL 測試,也沒有量測效能。實作時,先讓第 4 步那個故意寫壞的 adapter 確實把測試弄紅,再檢查正式 adapter 能否通過。
練習:取消訂單時,誰能 commit?
新增尚未付款的訂單取消功能:取消狀態與釋放預留要一起提交,重送不能釋放兩次。訂單模組和庫存模組各提供一個方法,是否就能讓它們各自開交易?
參考思路
不行,這仍是同一筆共同交易。取消用例協調順序與提交,訂單模組驗證並改狀態,庫存模組確認預留仍有效後釋放。兩邊都使用同一 transaction scope,並約定一致的取鎖順序。
把程式停在「釋放預留後、保存取消狀態前」,恢復時拋出例外。rollback 後訂單應仍成立、預留仍有效、可售數不能增加。成功取消後重送則不再釋放。完整的狀態與併發處理見取消訂單篇。
至於成交快照為什麼容易在維護時被改壞,〈寫過的爛 Code,要回頭看才會變成經驗〉用虛構報表事故補上另一個角度。回到目前的程式,最值得先追的是 reserve() 最後拿到哪條連線,以及誰對那條連線執行 commit。
