本文源自一次技術面試的討論題目,紀錄了問題的成因分析與逐步推導出的解決方案,可以作為電商系統設計的學習參考:假設公司同時擁有 Shopify 線上商店與實體銷售門市,兩個渠道共用同一套內部資料庫(庫存 DB 與訂單 DB)。

現有架構如下:

  • 線上訂單:Shopify 透過 Webhook Event 即時回傳至訂單資料庫。
  • 補漏機制:由於擔心 Webhook 漏接,系統另有一支 Timer Program,定期(約每小時一次)與 Shopify 比對並同步訂單與庫存。
  • 實體訂單:門市銷售的訂單會寫入內部訂單 DB,但不會同步至 Shopify。

資料庫的語意定義如下:

內部 DB 庫存  =  全渠道實際庫存總量
內部 DB 訂單  =  線上訂單 + 實體訂單(所有訂單)
Shopify 庫存  =  僅反映線上銷售,對實體銷售一無所知

問題根源

由於 Timer Program 每小時才同步一次,在同步之前存在一個空窗期:

實體門市售出商品
    → 扣減內部 DB 庫存           正確
    → Shopify 庫存尚未更新       數字虛高

線上顧客在空窗期內下單
    → Shopify 顯示庫存充足       錯誤資訊
    → 顧客成功下單               實際已超賣

矛盾在於:內部 DB 才是庫存的 Single Source of Truth,但 Shopify 並不知道實體門市的銷售情況,導致其顯示的庫存數字在空窗期內是虛高的。


解決方案

問題的解法分為兩個互補的方向,建議同時實施。

方案一:結帳時以內部 DB 做最終庫存驗證(優先實作)

即使 Shopify 放行了訂單,在訂單真正寫入內部系統前,以內部 DB 的庫存數字做最後一道驗證。

Shopify Webhook 傳入線上訂單
    → 訂單處理服務接收
    → 查詢內部 DB 確認庫存是否充足
        ├── 足夠 → 扣減庫存、建立訂單
        └── 不足 → 拒絕訂單、通知顧客、觸發退款流程

建議使用資料庫 Transaction 搭配悲觀鎖。

這一步不能只是單純的 SELECTUPDATE,因為同一時間可能有多筆線上訂單同時通過驗證,造成 Race Condition;建議做法是使用 SELECT ... FOR UPDATE 將庫存記錄鎖住,確保扣減操作是原子性的:

 1BEGIN TRANSACTION;
 2
 3-- 鎖住這筆庫存記錄,其他交易必須等待
 4SELECT available_qty
 5FROM inventory
 6WHERE product_id = ?
 7FOR UPDATE;
 8
 9-- 驗證庫存足夠後才執行扣減
10UPDATE inventory
11SET available_qty = available_qty - ?
12WHERE product_id = ?;
13
14-- 建立訂單記錄
15INSERT INTO orders (...) VALUES (...);
16
17COMMIT;

FOR UPDATE 確保同一時間只有一個交易能讀取並修改這筆庫存,後續的交易必須等到前一筆 COMMITROLLBACK 後才能繼續,徹底防止超賣。

這樣的作法只有純後端邏輯調整而不動 Shopify 設定,實作快且風險低,能立即解決超賣問題。不過,潛在問題是顧客在 Shopify 上看到的庫存仍可能是錯誤的,下單後才發現失敗,使用者體驗會比較較差。

方案二:實體門市售出時主動推送庫存至 Shopify(改善體驗)

將庫存同步從「定時 Polling」改為「事件驅動(Event-Driven)」;每當實體門市成立一筆訂單,立即呼叫 Shopify Admin API 更新庫存,將空窗期從一小時壓縮到秒級。

實體門市成立訂單
    → 扣減內部 DB 庫存
    → 立即呼叫 Shopify Admin API 推送最新庫存數量

Shopify 的庫存更新是透過 GraphQL API 處理,目前官方推薦使用的 Mutation 為 inventorySetQuantities;這個 API 的輸入格式接受的是絕對數量,也就是將庫存設定成的最終目標值而非差值:

 1mutation {
 2  inventorySetQuantities(input: {
 3    reason: "correction",
 4    setQuantities: [{
 5      inventoryItemId: "gid://shopify/InventoryItem/...",
 6      locationId: "gid://shopify/Location/...",
 7      quantity: 42        # 目標絕對庫存數,非差值
 8    }]
 9  }) {
10    inventoryAdjustmentGroup {
11      changes {
12        name
13        delta           # delta 出現在 response,是 Shopify 回傳的變動結果
14      }
15    }
16  }
17}

Response 中的 delta 欄位是 Shopify 計算出的本次變動量,屬於回傳資訊而非輸入格式。

此外,API 支援一個選填欄位 changeFromQuantity,這是一種 Compare-and-Swap(樂觀鎖)機制:只有當 Shopify 目前的庫存數量與 changeFromQuantity 相符時才會執行更新;這可以有效防止並發推送時產生的數字錯誤,是 Shopify 原生提供的額外安全保障。

​這樣的作法能夠讓 Shopify 顯示的庫存隨時貼近真實庫存,顧客不會因為看到錯誤庫存數字而感到困惑​;不過,由於需要修改實體門市的訂單建立流程,若實使用的 POS 系統為第三方系統時,整合複雜度會比較較高。


最終架構全貌

兩個方案相輔相成,Timer Program 的角色也從「主要同步機制」調整為「校正」:

實體門市售出
    └→ 扣內部 DB 庫存
    └→ 立即推最新庫存至 Shopify API          ← 方案二(治本)

線上顧客結帳
    └→ Shopify 收款
    └→ Webhook 傳至內部系統
    └→ DB Transaction(FOR UPDATE)驗證庫存   ← 方案一(治標)
         ├── 庫存足夠 → 扣庫存、建立訂單
         └── 庫存不足 → 退款 + 通知顧客

Timer Program(每小時)
    └→ 定期比對 Shopify 與內部 DB 數字是否一致
    └→ 角色:校正,補 Webhook 漏接的邊緣情況

這個問題的解題思路可以歸納為三個核心概念:

  • Single Source of Truth:內部 DB 是庫存的唯一依據,Shopify 只是下游的顯示端,任何庫存數字都應由內部 DB 主動推送而非反向同步。
  • Event-Driven vs Polling:從依賴 Timer 的定時輪詢改為事件觸發,實體門市售出時立即推送,Timer 退居校正的角色。
  • DB Transaction + 悲觀鎖:在訂單寫入的關鍵路徑上使用 SELECT ... FOR UPDATE,確保庫存扣減是原子性操作,從根本上防止 Race Condition 造成的超賣。

方案一確保系統正確性,方案二確保使用者體驗良好,Timer 則是整個系統的安全網;這種分層防禦的設計思維,適用於大多數多渠道庫存同步的場景。