本文源自一次技術面試的討論題目,紀錄了問題的成因分析與逐步推導出的解決方案,可以作為電商系統設計的學習參考:假設公司同時擁有 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 搭配悲觀鎖。
這一步不能只是單純的 SELECT 再 UPDATE,因為同一時間可能有多筆線上訂單同時通過驗證,造成 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 確保同一時間只有一個交易能讀取並修改這筆庫存,後續的交易必須等到前一筆 COMMIT 或 ROLLBACK 後才能繼續,徹底防止超賣。
這樣的作法只有純後端邏輯調整而不動 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 則是整個系統的安全網;這種分層防禦的設計思維,適用於大多數多渠道庫存同步的場景。