本文延續上一篇「防止 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。
  • 補償操作不可輕易觸發:不可逆的操作(如退款、取消訂單)應在確認無法恢復後才執行,而非在第一次失敗時就觸發。