返回文章列表
rabbitmq

冪等性與 Exactly-Once 語義

說明 RabbitMQ 的 At-Least-Once 投遞語義,以及 Consumer 端如何透過冪等設計避免重複處理訊息

Aaron

At-Least-Once 是 RabbitMQ 的基本語義

RabbitMQ broker 不對訊息持久性提供無條件保證。要讓訊息安全抵達 consumer,需要 durable queue、persistent message、publisher confirms 三者同時成立,而 DLX 轉送過程更是沒有 publisher 的盲區。結論是 RabbitMQ 採用的是 at-least-once 投遞——寧可多送,也不要丟。

這個選擇把責任推給了 consumer:既然 broker 可能把同一則訊息送超過一次,consumer 處理一次或多次就必須產生相同結果。這就是冪等性。

三種投遞語義

語義意義特徵
At-most-once最多一次,可能丟效能好,但不可靠
At-least-once至少一次,可能重複可靠,但 consumer 要處理重複
Exactly-once剛好一次分散式系統中無法嚴格達成

RabbitMQ、Kafka、SQS、NATS JetStream、RocketMQ 都選 at-least-once。真正的 exactly-once 被 FLP 不可能性與雙將軍問題證明無法在一般分散式環境下達成。工業界的共識是:broker 保證至少送一次,去重交給 consumer。

與其問「怎麼讓 RabbitMQ 不重複」,要問的是「我的 consumer 能不能安全吃下重複」。

重複從哪裡來

即使 broker、producer、consumer 都正常運作,重複仍會在幾個場景自然出現:

  • Consumer 處理完但 ACK 前 crash:broker 沒收到 ACK,重新派送。
  • ACK 在網路上遺失:broker 認為 consumer 掉線,requeue 派給別人。
  • Retry 機制重送:DLX-based delayed retry、publisher confirm 逾時重發都會刻意或非刻意地重複。
  • Cluster failover:Quorum queue 切換 leader 時,in-flight 訊息可能被新 leader 重投。

問題從來不是「會不會發生」,而是「發生時 consumer 會不會出錯」。

什麼是冪等

冪等(idempotent):同一筆請求送 1 次或送 100 次,系統最終狀態一樣。

操作冪等?原因
SET balance = 100再做一次結果一樣
balance = balance + 10×多做一次多加一次
DELETE WHERE id=5已刪掉再刪也一樣
INSERT×會多一筆
UPSERT / INSERT IGNORE有就跳過
發送 email×收件人會收到兩封
呼叫第三方 API視 API 而定看是否支援 idempotency key

業務裡天生冪等的操作其實不多,多半要主動包裝。下面是四種實作方法。

方法一:唯一鍵 / 資料庫約束

讓資料庫擋重複。Message ID 當 primary key 或 unique index,重複插入失敗就當作已處理。

async def handler(message):
    try:
        await db.execute(
            "INSERT INTO processed_orders (msg_id, data) VALUES ($1, $2)",
            message.message_id, message.body,
        )
    except UniqueViolationError:
        pass  # 重複訊息,直接 ACK 丟掉
    await message.ack()

簡單可靠,適合天生有業務 ID 的場景(例如訂單)。對「加錢」這類沒有明顯唯一性的 side effect 不適用。

方法二:去重表(Dedup Table)

把去重從業務表剝離出來,單獨一張 dedup table:

CREATE TABLE message_dedup (
    message_id VARCHAR(64) PRIMARY KEY,
    processed_at TIMESTAMP DEFAULT NOW()
);
async def handler(message):
    async with db.transaction():
        try:
            await db.execute(
                "INSERT INTO message_dedup (message_id) VALUES ($1)",
                message.message_id,
            )
        except UniqueViolationError:
            await message.ack()
            return
        await do_business_logic(message.body)
    await message.ack()

關鍵:dedup insert 必須和業務邏輯在同一個 transaction。如果先插 dedup 再執行業務,中間 crash 會導致 dedup 記號存在但業務沒做,下次重送會被誤判為已處理,變成假冪等。

Dedup table 會無限成長,需要清理策略:TTL 欄位加定期清理、按日期 partition 整批 drop、或直接用 Redis SET key EX 86400。時間窗口抓「大於訊息可能重複的最大時間窗」即可,retry 最長一小時就設 24 小時。

方法三:狀態機(業務層冪等)

有明確生命週期的業務物件可以把冪等內建到狀態機。訂單從 PENDINGPAIDSHIPPED,只能從特定前置狀態轉入:

async def process_payment(message):
    order = await get_order(message["order_id"])
    if order.status == "PAID":
        return  # 已付過
    if order.status != "PENDING":
        raise InvalidStateError()  # 非法狀態,進 DLQ
    await mark_as_paid(order.id)

不需要 dedup table、不依賴 message ID,但要求業務有清楚的狀態轉換圖。不適合純事件、沒有中心狀態物件的場景(log 聚合、metrics)。

方法四:版本號 / CAS

樂觀鎖:

UPDATE orders SET status = 'PAID', version = version + 1
WHERE id = $1 AND version = $2

Version 對不上代表已被改過,這次是重複。配合 rowcount 判斷,無需 primary key 衝突也能達成冪等。

Message ID 從哪裡來

來源優點缺點
Producer UUID通用、不需業務知識重發會生新 ID,無法去重
業務 ID重發自動冪等、可追查需要保證業務 ID 唯一
內容 hash內容相同自動去重欄位微小變化就失效

能用業務 ID 就用業務 ID,並設到 AMQP 的 message_id property,讓 trace、log、監控讀到同一個識別。

await exchange.publish(
    aio_pika.Message(
        body=json.dumps({"order_id": "ORD-123", ...}).encode(),
        message_id="ORD-123",
    ),
    routing_key="order.created",
)

Exactly-Once 是神話嗎

嚴格的答案是:不能,任何分散式訊息系統都不能。FLP 不可能性與「網路可能任意丟包」從根本上禁止真正的 exactly-once——只要「訊息送出但回應遺失」存在,發送方就無法分辨「對方沒收到」和「對方收到但 ACK 丟了」。

Kafka 宣稱的 exactly-once 是封閉迴路內的 exactly-once:producer idempotence + transactional write 在「Kafka 讀→處理→Kafka 寫」這個迴路裡有效。一旦 consumer 寫到外部系統(資料庫、第三方 API、email),保證立刻失效——外部系統不參與 Kafka transaction。

所以 Kafka 也要求 consumer 在外部寫入時自己做冪等。RabbitMQ 沒有 producer 去重機制,但終點效果一樣:at-least-once + consumer idempotency ≈ 業務語義的 exactly-once。這是整條可靠性 chain 的最後一環,前面的 durable queue、persistent message、publisher confirms、DLX 監控讓「至少一次」成立,冪等 consumer 則把它收斂成「業務上只發生一次」。