雙寫一致性的惡夢:我如何用 Transactional Outbox 救回遺失的訂單訊息
後端工程師,做過加密貨幣交易所撮合引擎與金流系統,在一筆訊息都不能掉的場景裡實戰過 Transactional Outbox。
那天我盯著監控看板,發現一個很詭異的現象:資料庫裡明明有一筆已付款的訂單,但下游的出貨服務完全沒收到通知。客戶付了錢,貨卻沒出。這不是程式邏輯寫錯,而是一個更底層的問題——我同時要寫資料庫、又要發訊息到 MQ,這兩件事根本不是原子的。這篇講我怎麼從這個坑爬出來,用 Transactional Outbox Pattern 把雙寫一致性問題解掉,以及實務上那些沒人會在投影片上告訴你的細節。
問題的本質:你以為的原子操作其實不是
我們先把場景講清楚。一個典型的下單流程,我要做兩件事:
- 把訂單狀態從「待付款」更新成「已付款」,寫進資料庫
- 發一則訊息到 MQ,通知出貨服務、通知服務、對帳服務去做後續處理
最直覺的寫法就是這樣:
- 開一個資料庫交易,更新訂單狀態,commit
- commit 成功後,呼叫 MQ 的 producer 把訊息發出去
看起來很合理對吧?但問題在於,資料庫和 MQ 是兩個完全獨立的系統,它們之間沒有任何共享的交易邊界。你的程式碼夾在中間,只要中間任何一個環節出事,一致性就破了。
我把可能出事的點列一下:
- DB commit 成功,但發 MQ 之前服務掛了:訂單已經是已付款,但訊息永遠不會發出去。下游什麼都不知道,貨不會出。這就是我前面遇到的那一筆。
- DB commit 成功,發 MQ 時網路超時:你以為發失敗了,做了重試或回滾邏輯,但其實 MQ 那邊已經收到了。結果訊息發了兩次。
- DB commit 成功,MQ 也收到了,但 producer 沒收到 ack 就斷線:你不知道到底成功沒,進退兩難。
有人會說,那我把順序反過來,先發 MQ 再寫 DB?這更糟。萬一 MQ 發出去了,DB 卻 commit 失敗,那下游收到一筆「不存在」的訂單訊息,去查資料庫根本找不到對應資料。我在金流串接早期就吃過這個虧——對帳服務收到付款成功通知,回頭查訂單卻是待付款狀態,整個對帳邏輯卡死。
為什麼不能用分散式交易(2PC)解決
懂一點的人會想到 兩階段提交(Two-Phase Commit, 2PC)。理論上 XA 交易確實能把 DB 和 MQ 綁進同一個分散式交易裡。但我實務上幾乎不碰它,原因很實際:
- 效能差:2PC 要鎖住資源等所有參與者準備好,在高併發場景下這個鎖的代價是致命的。我做過搶購系統,那種瞬間幾萬筆的場景,2PC 直接讓你的吞吐量崩掉。
- 協調者單點問題:如果協調者在 prepare 之後、commit 之前掛掉,參與者會卡在「不確定」狀態,資源一直鎖著。
- 支援度差:很多現代的 MQ(像 Kafka)對 XA 的支援根本不完整,雲端託管的 MQ 更不用說。你想用都不一定用得了。
所以結論是:別想著用一個橫跨兩個系統的交易來保證原子性,這條路在實務上走不通。換個思路才是對的。
Outbox 的核心想法:把訊息變成業務資料的一部分
Transactional Outbox 的精髓,用一句話講:既然我沒辦法讓 DB 和 MQ 共享交易,那我就只用 DB 的交易,把「要發什麼訊息」這件事也記在 DB 裡。
具體來說,我在同一個資料庫交易裡做兩件事:
- 更新業務資料(訂單狀態)
- 往一張叫做 outbox 的表插入一筆紀錄,這筆紀錄描述了「我要發一則什麼樣的訊息」
這兩個寫入在同一個本地交易裡,要嘛一起成功,要嘛一起失敗。資料庫的 ACID 保證了這點。這時候根本不存在「訂單更新了但訊息沒記下來」的中間狀態。
然後,發訊息這件事被拆出去由一個獨立的元件——我習慣叫它 relay(中繼)——來做。relay 去讀 outbox 表,把還沒發的訊息撈出來,發到 MQ,發成功後標記為已發送。
你發現了嗎?我把一個「跨兩個系統的雙寫問題」,轉化成了「一個本地交易 + 一個非同步的訊息搬運」。雙寫沒了,剩下的只是「保證 outbox 裡的訊息最終會被發出去」,而這是個容易解的問題。
outbox 表長什麼樣
我實務上的 outbox 表大概是這些欄位(用文字描述,避免符號干擾):
- id:主鍵,通常用自增或雪花 ID,relay 靠它排序
- aggregate_type:聚合根類型,例如 Order、Payment,方便 routing
- aggregate_id:對應的業務實體 ID,例如訂單編號
- event_type:事件類型,例如 OrderPaid、OrderShipped
- payload:訊息內容本體,我通常存 JSON
- created_at:建立時間
- published_at:發送完成時間,沒發的時候是空的
- status:狀態,例如 pending、published(有些設計只靠 published_at 是否為空判斷,不另外存 status)
關鍵欄位是 published_at 或 status,relay 靠它判斷哪些是還沒發的。payload 我會把消費端需要的資訊都包進去,盡量讓它自包含,避免下游收到訊息後還要回頭查我的資料庫——那又會引入新的耦合。
relay 怎麼做:輪詢 vs CDC
把訊息寫進 outbox 只是上半場,下半場是怎麼可靠地把它們搬到 MQ。這裡有兩種主流做法,我兩種都用過。
做法一:輪詢 outbox 表(Polling Publisher)
最直接的做法,就是寫一個背景程式,固定間隔去掃 outbox 表:
- 查出 published_at 為空(還沒發)的紀錄,按 id 排序,撈一批出來
- 逐筆發到 MQ
- 發成功後,更新 published_at 為現在時間
我用 Go 寫過好幾版這種 relay,講幾個關鍵點:
- 批次處理:別一次撈一筆,那 QPS 上來會把 DB 打爛。我通常一次撈幾百筆,發完再更新。
- 撈取時要鎖:如果你跑多個 relay 實例(為了高可用),會有兩個實例同時撈到同一批紀錄的問題。我的做法是用 SELECT ... FOR UPDATE SKIP LOCKED,讓每個實例撈到的是不同的紀錄,互不干擾。這是 PostgreSQL 的好用功能,MySQL 8 之後也支援。
- 輪詢間隔的取捨:間隔太長,訊息延遲高;間隔太短,DB 負擔重而且大部分時候掃了個寂寞。我通常設在幾百毫秒到一兩秒之間,看業務對延遲的容忍度。
輪詢的優點是實作簡單、好理解、好除錯,不需要動到資料庫的底層機制。缺點是它本質上是個輪詢,有延遲,而且對 DB 有持續的查詢壓力。對大部分中小規模的系統,我會優先選這個。
做法二:CDC(Change Data Capture)
當規模上來、或者你不想讓 relay 一直去 poll DB,可以改用 CDC。CDC 的想法是:直接去讀資料庫的交易日誌(MySQL 的 binlog、PostgreSQL 的 WAL),任何對 outbox 表的 insert 都會出現在日誌裡,工具把它捕捉下來、發到 MQ。
實務上最常見的組合是 Debezium + Kafka Connect。Debezium 去訂閱 binlog,看到 outbox 表有新紀錄,就轉成 Kafka 訊息發出去。
CDC 的好處很明顯:
- 沒有輪詢延遲:日誌一寫進去幾乎就被捕捉,近即時。
- 不增加 DB 查詢壓力:它讀的是 binlog,不是去 query 你的表。
- 天然有序:binlog 本身就是按交易順序寫的。
但 CDC 也有它的代價,我踩過的坑:
- 架構複雜:你要多維護 Debezium、Kafka Connect 這一整套東西,運維成本不低。團隊小的時候,這套東西本身就是個負擔。
- schema 變更要小心:outbox 表結構一改,CDC 的 pipeline 可能就要跟著調。
- 初次接觸學習曲線陡:binlog 的設定、權限、各種邊界情況,不熟的話很容易卡很久。
我的原則是:團隊規模小、訊息量不大,就用輪詢,簡單可靠;規模大、對延遲敏感、而且團隊有能力運維 CDC 那套基礎設施,才上 Debezium。不要為了「看起來比較高級」就一上來就搞 CDC,那通常是過度設計。
至少一次與消費端冪等:這是 Outbox 的隱含前提
Outbox 解決了「訊息一定會被發出去」,但它帶來一個你必須接受的特性:訊息是至少一次(at-least-once)送達,不是剛好一次(exactly-once)。
為什麼?想像 relay 把訊息發到 MQ 成功了,但在更新 published_at 之前掛掉。下次 relay 重啟,這筆紀錄還是 pending,於是它又發了一次。同一則 OrderPaid 訊息,下游可能收到兩次。
這不是 bug,這是 Outbox 的本質。你不可能在「發 MQ」和「更新 DB 標記」之間做到原子,所以重複是無法完全避免的。正確的態度不是去消滅重複,而是讓消費端能容忍重複——也就是冪等(idempotent)。
消費端怎麼做冪等
我在金流場景上最常用的冪等做法:
- 每則訊息帶一個唯一的 message_id(可以直接用 outbox 的 id,或另外生一個 UUID)。
- 消費端維護一張「已處理訊息」表,收到訊息先檢查這個 id 處理過沒。
- 處理過就直接 ack 跳過;沒處理過,就在同一個交易裡做業務邏輯 + 寫入這個 id。
舉個具體例子:出貨服務收到 OrderPaid,要建立一筆出貨單。它先看「已處理訊息」表有沒有這個 message_id:
- 有 → 代表這則訊息之前處理過了,直接 ack,什麼都不做
- 沒有 → 在一個交易裡建立出貨單 + 把 message_id 寫進已處理表,一起 commit
這樣就算同一則訊息來十次,出貨單也只會建一筆。冪等做好了,至少一次就完全不可怕了。
另一種更輕量的冪等是靠業務本身的唯一性約束。例如出貨單對 order_id 建唯一索引,重複插入直接被資料庫擋下來,捕捉到唯一鍵衝突就當作已處理。這招在某些場景比維護一張額外的表更乾淨。
實務上那些細節:補發、順序、清理
骨架搭好之後,魔鬼都在細節裡。講幾個我真的踩過或想過的點。
已發送標記與補發
published_at(或 status)這個標記不只是給 relay 看哪些要發,它也是補發的依據。如果哪天 MQ 出問題、或下游消費爆掉要重灌資料,我可以直接從 outbox 表把某個時間區間的訊息撈出來重發。因為 outbox 本身就是一份完整的事件紀錄,這份紀錄的價值有時候比即時發送還高。
所以我通常不會發完就馬上刪掉 outbox 紀錄。先標記 published_at,紀錄留著,過一段時間(例如保留七天或三十天)再由另一個清理任務批次刪除已發送的舊紀錄。這樣既能補發,又不會讓表無限長大拖垮查詢。
順序問題
很多人忽略順序。如果業務要求嚴格順序——例如同一筆訂單的 OrderCreated 一定要比 OrderPaid 先到——你要特別處理:
- relay 撈取時按 id 排序,保證發送順序大致正確。
- 但光這樣不夠,因為 MQ 那邊如果有多個 partition / queue,順序又可能亂掉。Kafka 的做法是把同一個 aggregate_id 的訊息發到同一個 partition(用 aggregate_id 當 partition key),這樣同一筆訂單的訊息就保證有序。
- 如果你跑多個 relay 實例又用 SKIP LOCKED,全域順序就很難保證了。這時候要嘛接受「只保證同一個 aggregate 內有序」,要嘛犧牲一點並行度。
我的經驗是,大多數業務只需要「同一個實體內有序」,不需要全域有序。把這個範圍想清楚,能省掉很多不必要的複雜度。
relay 的可靠性
relay 自己也會掛。所以 relay 必須是可重啟、可水平擴展、且重啟後能接續處理的。這就是為什麼前面強調 outbox 的標記和 SKIP LOCKED——它們讓 relay 變成無狀態的、可以隨便重啟和擴展。relay 掛了,重啟後從 outbox 表還沒發的紀錄繼續發,一筆都不會掉。
什麼時候其實不需要這套
講了這麼多 Outbox 的好,我必須誠實說:它不是免費的,而且很多時候你根本不需要它。
我會問自己幾個問題:
- 這個訊息真的關鍵到不能掉嗎? 如果只是發個「使用者更新了頭像」的通知,掉了就掉了,那加一張 outbox 表、養一個 relay,純粹是過度工程。直接發 MQ,失敗就記個 log,夠了。
- 我的下游能不能改成自己來拉? 有時候與其我主動推訊息,不如讓下游定時來查我的狀態。雖然有延遲,但完全沒有雙寫問題,架構更簡單。
- 能不能根本不要拆? 如果這兩個操作其實可以在同一個服務、同一個資料庫裡完成,那就別硬拆成「寫 DB + 發 MQ」。最好的分散式問題就是不要有分散式問題。
Outbox 真正該用的場景,是那些「訊息絕對不能掉、而且必須跟業務資料保持一致」的地方——金流、訂單、對帳、扣款這類。在我做過的交易所和金流系統裡,這些地方一筆訊息掉了就是真金白銀的損失,甚至是法遵問題,那 Outbox 帶來的複雜度就完全值得。但如果你只是在做一個內容網站,每個非同步通知都套 Outbox,那只是在折磨自己和維運的人。
小結
Transactional Outbox 的本質,是把一個無解的「跨系統雙寫」問題,巧妙地降維成一個可解的「本地交易 + 非同步搬運」問題。它的代價是引入至少一次語意和一個 relay 元件,所以你必須在消費端做好冪等。它不是萬靈丹,小事別用它,但在金流訂單這種一筆都不能掉的場景,它是我目前見過最務實、最可靠的解法。
說到底,分散式系統裡沒有真正的原子,只有「把不一致的窗口縮到你能接受、能補救的程度」。Outbox 就是把那個窗口收進一個本地交易裡——而這,已經足夠救回那些本來會遺失的訂單了。