本文延續上一篇「防止 Shopify 線上商店與實體門市超賣」的討論,探討反向情境:當 Shopify 的 Webhook Event 漏接,導致線上訂單無法即時同步進內部資料庫時,應該如何設計系統來保障資料一致性,以及避免先下單的顧客受到損失。
問題情境
承接上篇的架構背景:線上訂單透過 Shopify Webhook 即時回傳至內部 DB,並有一支 Timer Program 每小時定期補漏。
當 Webhook 漏接時,空窗期內可能發生以下情境:
線上顧客成功下單(Shopify 已收款)
→ Webhook 發送失敗或漏接
→ 內部 DB 不知道這筆訂單的存在
→ 內部 DB 庫存仍顯示舊的(較高)數字
同一時間,實體門市售出同一商品
→ 扣減內部 DB 庫存
Timer 補跑,終於處理到那筆線上訂單
→ 此時庫存已不足
→ 先下單的線上顧客反而無法出貨 ❌
這個問題的核心是:Shopify 與內部 DB 是兩個獨立的系統,Webhook 是主要的同步橋樑,一旦橋樑出現延遲,兩端就會產生資料落差。
解法方向
方案一:縮短 Timer 間隔
最直覺的做法是將 Timer 的同步週期從一小時縮短,例如改為每五分鐘一次,讓系統更快發現未處理的線上訂單並補上;這個方案不需要改動架構,成本最低且能夠立即縮短風險窗口。
然而它存在一個根本性的隱憂:當系統因負荷過高而開始變慢,頻繁的 Timer 仍會不斷觸發新的同步任務,不斷消耗系統資源,反而讓系統雪上加霜,最終可能完全卡死;這在系統設計上是一種 Thundering Herd(驚群效應)的變體,意指用更頻繁的 Polling 來彌補事件驅動的不足,但 Polling 本身在系統壓力大時會成為壓垮駱駝的稻草。
縮短間隔治標不治本,空窗期的長短仍然取決於 Timer 的頻率,只是縮小了問題的規模。
方案二:Timer 補跑時,訂單同步優先於庫存校正
這是一個執行順序的調整;Timer 補跑時應先將所有未同步的線上訂單補進內部 DB,再進行庫存比對與扣減,避免實體銷售的庫存扣減蓋過尚未被認知的線上訂單。
Timer 執行順序(調整後):
1. 先同步所有未處理的 Shopify 訂單至內部 DB
2. 再進行庫存比對與校正
這個方案能降低因執行順序錯誤造成誤扣的機率,但根本問題依然存在:在 Timer 補跑之前,內部 DB 對線上訂單是完全無感知的。
方案三:引入 Message Queue 提升 Webhook 接收可靠性
治本的解法是讓漏接更難發生,核心概念是在接收 Webhook 的入口加一層 Message Queue(如 RabbitMQ、AWS SQS):
Shopify Webhook
→ 輕量接收服務(只負責入隊,立即回傳 200)
→ Message Queue(持久化儲存)
→ 消費者服務(實際處理訂單邏輯)
├── 處理成功 → 寫入訂單、扣減庫存
└── 處理失敗 → Queue 自動重試(指數退避)
└── 反覆失敗 → Dead Letter Queue(DLQ)
這個設計有幾個優點:
- 接收服務極為輕量,幾乎不會因為處理邏輯出錯而漏接
- 訊息持久化儲存,即使消費者當機,訊息不會消失,系統恢復後會繼續處理
- 自動重試機制,暫時性的處理失敗不需要人工介入
- Dead Letter Queue 捕捉反覆失敗的訊息,供人工追蹤與處理
Shopify 本身對失敗的 Webhook 也有指數退避的重試機制,搭配 Queue 後可以大幅降低漏接機率。
關於回傳 200 後的責任轉移
一旦接收服務回傳 200,責任就從 Shopify 轉移到系統;這也是為什麼 Queue 本身的持久化與可靠性至關重要,因為訊息一旦入隊就必須保證不丟失,後續的失敗才有重試的機會。
若 Queue 本身出現問題導致訊息丟失,此時不應立即對 Shopify 端的訂單做任何標記,因為進 Queue 失敗只代表你的系統暫時無法處理,不代表訂單本身有問題;建議的處置順序是:讓 Queue 自動重試 → 反覆失敗後進入 DLQ → 人工介入或觸發補償機制。
方案四:補償機制
即使有了 Message Queue,極端情況下仍可能有訂單無法履行,例如 Queue 處理完成時庫存已被實體門市清空;這時需要的不是讓系統停在一個不一致的中間態,而是主動執行反向補償操作:
發現訂單無法履行
→ 透過 Shopify API 取消訂單並退款
→ 主動通知顧客並說明原因
→ 寫入異常日誌,供人工追蹤
這對應到分散式系統中的 Saga Pattern 的概念:承認某些情況下交易無法完成,並以補償交易(Compensating Transaction)回復到一致的狀態。
值得注意的是,補償機制的觸發點應該在 DLQ 或確認無法履行後,而非第一次處理失敗時就觸發;透過 Shopify API 取消訂單並退款是一個不可逆的操作,在還有機會重試的階段不應輕易觸發。
建議的實作優先順序
方案三與方案四是互補的關係,缺一不可:
- 方案三解決的是可靠傳遞的問題,盡力讓漏接不發生。
- 方案四解決的是最終一致性的問題,確保問題發生時損害可控。
方案一與方案二的效果相對有限,但在方案三尚未實作之前,可以作為過渡期的風險緩解手段,尤其是方案二的執行順序調整,成本極低。
短期(過渡期)
└→ 調整 Timer 執行順序:先補訂單,再校庫存 ← 方案二
中期(根本解法)
└→ 引入 Message Queue 保障 Webhook 可靠接收 ← 方案三
└→ 建立補償機制:DLQ 觸發退款 + 通知 ← 方案四
POS 系統是否也需要 Message Queue?
這個問題的答案取決於 POS 與內部 DB 之間的通訊方式。
Windows Application 直寫 DB
若 POS 系統是 Windows Application,透過區網直接寫入內部 DB,寫入是否成功當下就知道,失敗可以立即在 POS 介面提示店員,不存在訊息漏失的問題;這種情況下不需要 Queue,可靠性由資料庫的 Transaction 保證即可。
轉為 Web API
若日後 POS 系統轉型為透過 HTTP 呼叫後端 Web API 再寫入 DB,POS 與 DB 之間多了一層網路,性質就從「直接寫入」變成「跨網路呼叫」,任何網路不穩定、API 服務重啟或逾時都可能導致訂單沒有入庫;這時的情境就會與 Shopify Webhook 越來越接近,但有一個差異:**POS 是主動發起方,我們對它有控制權。**因此比起直接引入 Message Queue,更推薦的優先順序會是:
- 第一步:確保 API 回傳明確的成功或失敗,POS 端對失敗要有重試邏輯,而不是靜默丟棄
- 第二步:若門市網路環境不穩定,可以考慮在 POS 端實作 Local Queue 或離線暫存,待網路恢復後再補送
- 第三步:若未來訂單量大,或有多個門市同時寫入的並發需求,後端再考慮引入 Message Queue 作為緩衝
這個判斷背後的原則是:**問題是我可以在發起端解決的,還是我對發起端沒有控制權?**前者優先從發起端處理,後者才需要在接收端引入 Message Queue。
設計原則
- 事件驅動優於 Polling:Polling 在系統壓力大時會成為負擔,事件驅動搭配 Message Queue 才能解決可靠傳遞的問題。
- 分層防禦:Message Queue 盡力讓問題不發生,補償機制確保問題發生時損害可控,兩者缺一不可。
- 控制權決定解法位置:對發起端有控制權時,優先在發起端處理可靠性;對發起端沒有控制權時,才在接收端引入 Queue。
- 補償操作不可輕易觸發:不可逆的操作(如退款、取消訂單)應在確認無法恢復後才執行,而非在第一次失敗時就觸發。