🎯 什麼情境該想到我

當你「希望端點的收訊 / 送訊能納入交易,和本地資料更新一起提交或一起回滾,避免處理到一半失敗造成不一致」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:讓訊息端點以交易方式與訊息系統互動,收送訊息的動作可與其他工作一同提交或回滾。

做法 / 結構要點:

  • 在交易中接收訊息:處理失敗時回滾,訊息會退回通道可重試。
  • 在交易中送出訊息:交易未提交前訊息不真正發出。
  • 可搭配分散式交易,讓訊息操作與資料庫操作原子化。

★★ 這個模式解決的問題,和它解決不了的問題

★ 解決:「從通道收訊息」與「本地資料庫寫入」在同一個交易裡
        → 處理失敗時訊息回到通道,資料庫也回滾 ⇒ 不會「訊息消失但資料沒寫」

★★ 解決不了:「本地資料庫寫入」與「往通道發訊息」的原子性
        → 這是雙寫問題,要用 [[工具-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),否則「業務改了但下游沒收到」。

🔗 相關工具