🎯 什麼情境該想到我
當你「希望端點的收訊 / 送訊能納入交易,和本地資料更新一起提交或一起回滾,避免處理到一半失敗造成不一致」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓訊息端點以交易方式與訊息系統互動,收送訊息的動作可與其他工作一同提交或回滾。
做法 / 結構要點:
- 在交易中接收訊息:處理失敗時回滾,訊息會退回通道可重試。
- 在交易中送出訊息:交易未提交前訊息不真正發出。
- 可搭配分散式交易,讓訊息操作與資料庫操作原子化。
★★ 這個模式解決的問題,和它解決不了的問題
★ 解決:「從通道收訊息」與「本地資料庫寫入」在同一個交易裡
→ 處理失敗時訊息回到通道,資料庫也回滾 ⇒ 不會「訊息消失但資料沒寫」
★★ 解決不了:「本地資料庫寫入」與「往通道發訊息」的原子性
→ 這是雙寫問題,要用 [[工具-Transactional-Outbox-Pattern]]
★★ 這兩件事常被混為一談。 交易式用戶端管的是消費端;
Outbox 管的是生產端。一個完整的系統兩者都需要。
★ 各家 broker 的實際能力(差異極大)
查證 2026-09-25。
| broker | 能不能和本地 DB 同一交易 |
|---|---|
| JMS + XA(EIP 書的時代) | ★ 可以(兩階段提交)——★ 但今天幾乎沒人用:慢、難維運、協調者是單點 |
| Kafka | ★★ 不行。Kafka transactions 只在「Kafka → Kafka」封閉迴圈內成立 |
| RabbitMQ | ★ AMQP 有 tx.select,但只涵蓋 broker 端,不含你的 DB |
| SQS | ★ 沒有交易概念,只有 visibility timeout + delete |
★★ 結論:在 2026 年,「訊息與資料庫的分散式交易」實務上不存在。
取而代之的是下面這個模式:
★★ 今天的正確做法:「ack 在本地交易 commit 之後」+ 冪等
func handle(ctx context.Context, msg Message) error {
// ★ 第 1 步:在「一個」本地交易裡做完所有事,含去重紀錄
err := inTx(ctx, func(tx Tx) error {
_, err := tx.Exec(ctx,
`INSERT INTO processed (message_id) VALUES ($1)`, msg.ID)
if isUniqueViolation(err) { return errAlreadyProcessed } // ★ 處理過了
if err != nil { return err }
if err := applyBusinessChange(ctx, tx, msg); err != nil { return err }
return enqueueOutbox(ctx, tx, derivedMessages(msg)) // ★ 下游訊息也進同一交易
})
switch {
case err == nil, errors.Is(err, errAlreadyProcessed):
return msg.Ack() // ★★ ack 在 commit 之後
case errors.Is(err, ErrInvalidMessage):
return msg.Nack(false) // ★ 永久錯誤 → DLQ
default:
return msg.Nack(true) // ★ 暫時錯誤 → 重送
}
}★★ 這段程式碼包含了整個可靠消費的全部要點:
① 去重紀錄與業務變更在同一交易(原子);
② 衍生訊息走 Outbox(生產端可靠);
③ ack 在 commit 之後(★ 反了就會丟訊息);
④ 錯誤分類決定重送或 DLQ。
★★ 「commit 成功但 ack 失敗」——唯一剩下的窗口
交易 commit 成功 → 程式在 ack 之前崩潰 → ★ 訊息會重送
→ 第二次處理時:INSERT processed 撞上唯一索引 → errAlreadyProcessed → ack
→ ★★ 所以這個窗口是安全的,前提是「去重紀錄和業務變更在同一交易裡」
★★ 這就是為什麼「去重表必須和業務資料在同一個資料庫、同一個交易」。
把去重放 Redis → Redis 寫成功但 DB 交易回滾 ⇒ 那則訊息永遠不會被處理,
而且沒有任何錯誤。
★ 去重紀錄的保留與清理
-- ★ 一定要有清理,否則這張表會無限成長
DELETE FROM processed WHERE created_at < now() - interval '7 days';★ 保留期的下限 = 訊息可能重複的最長時間窗:
broker retention、DLQ retention、人工重放的時間——取最長的那個再加緩衝。
⚠️ 保留期設太短 → 重放三十天前的訊息時去重失效,副作用重複發生。
★ 兄弟辨析
| 差別 | |
|---|---|
| 冪等接收者 | ★ 幾乎是同一件事的另一面;★ 兩者一起才完整 |
| 保證投遞 | broker 端的持久性;這一頁是應用程式端 |
| 輪詢式消費者/事件驅動消費者 | 收取方式;ack 時機在兩者都一樣重要 |
| 工具-Transactional-Outbox-Pattern | ★ 生產端的對應問題 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
分散式 / XA 交易有效能與複雜度成本。
-
交易範圍要拿捏,過大會拖長鎖定、降低吞吐。
-
訊息回滾重送會造成重複,需搭配冪等接收者。
-
★★ 在 2026 年「訊息 + 資料庫的分散式交易」實務上不存在。Kafka transactions 只在 Kafka→Kafka 內成立;XA 幾乎沒人用。
-
★★ 正確做法是「ack 在本地交易 commit 之後」+ 冪等,不是尋找一個交易機制。
-
★★ 去重紀錄必須和業務資料在同一個資料庫、同一個交易裡。 放 Redis 會造成「Redis 記了但 DB 回滾」⇒ 那則訊息永遠不會被處理且無錯誤。
-
★ ack 順序寫反就會丟訊息。 先 ack 再處理 = 處理失敗訊息已消失。
-
★ 去重表要有清理,保留期下限 = broker retention + DLQ retention + 人工重放窗口。設太短會讓舊訊息重放時副作用重複。
-
★ 這一頁管消費端,Outbox 管生產端。 完整的系統兩者都需要。
-
RabbitMQ 的
tx.select只涵蓋 broker 端,不含你的資料庫,而且它很慢。 -
衍生訊息也要在同一交易裡(走 Outbox),否則「業務改了但下游沒收到」。
🔗 相關工具
- 冪等接收者 Idempotent Receiver(同家族,處理回滾重送造成的重複)
- 輪詢式消費者 Polling Consumer(同家族,常在交易中拉取訊息)
- 企業整合模式
- 冪等接收者 —— ★ 同一件事的另一面,兩者一起才完整
- 保證投遞 —— broker 端的持久性
- 工具-Transactional-Outbox-Pattern —— ★ 生產端的對應問題
- 死信通道 —— 錯誤分類與永久失敗
- 工具-交易與隔離等級 —— 唯一索引做冪等
- 訊息端點 —— 完整的消費迴圈