Thinkin Markdown

RabbitMQ 發佈沒有例外,訊息卻沒進 Queue:補上 Publisher Confirms 與非同步 Retry

解析 RabbitMQ 掉訊息問題,釐清發佈確認、重試與重複消費的可靠性權衡

發佈時間 2026-10-14
閱讀時間 14 分鐘
主題 .NET

最近處理了一個 RabbitMQ 問題:應用程式的發佈前後 log 都正常,也沒有收到例外,但訊息偶爾就是沒有出現在 Queue。

如果只看呼叫端,這次發佈似乎已經成功。但沿著程式往下查,才發現「方法執行完畢」、「broker 接受訊息」及「訊息能路由到 Queue」,原本沒有被完整串在一起。

這次修正先補齊發佈確認與錯誤處理,再將發佈流程改成可以一路 await 的非同步 API,讓失敗真正進入 Retry,也能回到呼叫端。

✨ 可靠的訊息發佈,第一步是讓「成功」有明確定義,讓「失敗」有機會被看見。

本文以專案當時使用的 RabbitMQ.Client 7.2.1 與 Polly 傳統 Policy API 為背景,未採用 v8 的 ResiliencePipeline API。程式碼使用通用名稱並省略專案封裝;測試結果整理自這次修正的本機驗證。

先看結論

  • 根因:沒啟用 Publisher Confirms、mandatory 退回沒被處理、同步 Retry 包住 async 發佈,再加上 async void 與 Channel 狀態檢查不足。
  • 修法:啟用確認追蹤、處理 mandatory 退回,並把發佈改成一路 await 的非同步 Retry。
  • 刻意不做:不加發佈逾時,也沒有訊息去重。
  • 仍存在的邊界:單次發佈可能持續等待;結果不明時的重送仍可能造成重複訊息;取得發佈確認不代表 Consumer 已處理完成;共用 IChannel 的併發發佈尚未驗證。

沒有例外,為什麼不代表發佈成功?

原本的問題並不是單一設定,而是幾個非同步與協定處理上的缺口疊在一起:

缺口後果
建立 Channel 時沒有啟用 Publisher Confirms呼叫端沒有 broker 確認的依據,BasicPublishAsync 完成不等於 broker 已接受訊息
設定了 mandatory: true,卻沒處理退回無法路由的訊息被 broker 透過 basic.return 退回,應用程式卻仍視為成功
同步 Policy 包住 async lambda外層提早返回,稍後的例外進不了重試流程(最關鍵)
Setup 使用 async void,只檢查 Connection宣告是否完成無從得知,Channel 單獨關閉時也不會被發現

第三個缺口最容易被忽略:同步的 Policy 收到 async lambda 卻沒有等待它完成,結果就是 log 看起來走完了,真正的發佈工作卻還沒結束。

以下是這類錯誤的示意,並非原專案程式碼。這裡明確將 async lambda 指派給 Action,因此它會成為 async void:

Action publish = async () => await PublishCoreAsync(message);
syncRetryPolicy.Execute(publish);

當非同步操作尚未完成、lambda 在 await 處交還控制權時,Action 就會返回。同步 Policy 沒有可等待的 Task,因此無法等待整次發佈,也無法透過同步呼叫捕捉非同步發佈的例外。

async void 的例外無法由呼叫端的 try/catch 捕捉,而會交由執行環境的例外處理機制處理;若未被處理,可能導致行程終止。

若另一種寫法回傳了 Task,卻沒有讓 Policy 等待它,同樣無法讓非同步失敗進入重試。修正的重點是讓 Policy 與呼叫端都等待同一條非同步流程。

先定義這次的成功條件

這次處理的是 Producer 到 broker 這一段的可靠性。

我們希望發佈方法完成時,代表它已取得 broker 的發佈確認;若訊息無法路由,或發佈收到失敗結果,就必須進入錯誤處理,而不是靜默成功。

機制要解決的問題注意事項
Publisher Confirms取得 broker 對發佈的確認不代表 Consumer 已完成業務處理
mandatory 與退回處理發現無法路由到 Queue 的訊息不代表指定的每一個 Consumer 都收到
非同步 Retry對已觀察到的發佈失敗重新嘗試本文未設定單次發佈逾時
連線與 Channel 重建恢復後續發佈能力不會自動補送所有未確認訊息

Publisher Confirms 與 Consumer Acknowledgements 是不同方向的確認:前者確認發佈結果,後者由 Consumer 回報訊息處理結果。本文沒有調整接收端的業務處理或 ACK 邏輯。

用 Publisher Confirms 與 mandatory 補齊發佈結果

建立 Channel 時,同時啟用確認與追蹤:

var channel = await connection.CreateChannelAsync(
    new CreateChannelOptions(
        publisherConfirmationsEnabled: true,
        publisherConfirmationTrackingEnabled: true));

Confirmations 讓 Channel 進入 confirm mode,由 broker 回報發佈結果;Tracking 則讓 .NET Client 追蹤每筆發佈。兩者一起啟用後,BasicPublishAsync 會等待該筆訊息的確認,並在收到 nack(broker 對發佈的負向確認)或 mandatory 訊息退回時拋出 PublishException(可用 IsReturn 區分是否為退回)。nack 表示 broker 未能承擔該次發佈的責任;return 則表示訊息無法路由到任何 Queue,兩者是不同的結果。

這也改變了 await 的意義:發佈流程不再只是送出資料後往下走,而是等待確認結果。

接著記錄退回原因:

channel.BasicReturnAsync += (_, args) =>
{
    logger.LogError(
        "Message returned: {ReplyCode}, {ReplyText}, " +
        "Exchange: {Exchange}, RoutingKey: {RoutingKey}",
        args.ReplyCode,
        args.ReplyText,
        args.Exchange,
        args.RoutingKey);

    return Task.CompletedTask;
};

這個 handler 負責留下診斷資訊;讓發佈失敗的關鍵,是前面啟用的確認追蹤,並不是單靠 log 或在事件裡丟例外。

實際發佈仍需設定 mandatory: true:

var properties = new BasicProperties
{
    Persistent = true
};

await channel.BasicPublishAsync(
    exchange: exchangeName,
    routingKey: routingKey,
    mandatory: true,
    basicProperties: properties,
    body: Encoding.UTF8.GetBytes(message));

Persistent 會將訊息的 delivery mode 設為 persistent,但不取代確認機制。以 Classic Queue 為例,要讓訊息在節點重啟後保留下來,還需要使用 durable Queue;不同 Queue 類型的持久化與複寫語意也不完全相同。

也要注意:無法路由的訊息仍可能收到 broker 的 ack。ack 表示 broker 已處理這次發佈,不等於一定有 Queue 接收;因此不能只看 ack,忽略 mandatory return。

非同步工作,要交給非同步 Retry

發佈流程改成回傳 Task,讓呼叫端明確等待:

await eventBus.PublishAsync(message);

Producer 內部則使用 WaitAndRetryAsync 與 ExecuteAsync。以下示意沿用這次的預設值:最多重試 5 次,每次等待時間以 2 的次方增加。

const int maxRetries = 5;

var retryPolicy = Policy
    .Handle<Exception>()
    .WaitAndRetryAsync(
        retryCount: maxRetries,
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
        onRetry: (exception, delay, attempt, context) =>
        {
            logger.LogWarning(
                exception,
                "Publish retry {Attempt}/{MaxRetries} after {DelaySeconds}s",
                attempt,
                maxRetries,
                delay.TotalSeconds);
        });

await retryPolicy.ExecuteAsync(() => PublishCoreAsync(message));

PublishCoreAsync 代表一次完整的發佈嘗試,包含必要的連線確認與 BasicPublishAsync。這個方法必須把失敗向外傳遞,Policy 才能觀察並重試。

重試次數等待時間
12 秒
24 秒
38 秒
416 秒
532 秒

這是初次嘗試之外,最多再執行 5 次,合計最多 6 次發佈嘗試。

退避等待合計為 62 秒,但不包含每次連線與發佈所花的時間,不能當成總逾時。

這次也修正了重試 log,分別輸出「第幾次/最多幾次」與「下次等待幾秒」,避免把等待時間誤認成重試次數。

既有策略處理所有 Exception,因此無法路由這類問題也會重試。它提供暫時恢復的機會,但不會自動修復錯誤的 Routing Key 或缺少的 Binding;條件始終沒有改善時,重試用盡後仍會把例外交回呼叫端。

發佈卡住時,Retry 不會啟動

在沒有自行設定發佈逾時的情況下,「等 Queue 狀況恢復後再試」需要分成兩種情況來看:

當下狀態程式行為
發佈已拋出例外依退避間隔重試,直到成功或次數用盡
發佈仍在等待 broker 回應繼續等待同一次操作,不會因等待時間長而自動 Retry

例如原本唯一能接收訊息的 Queue 被刪除,訊息已無法路由到任何 Queue,mandatory 訊息被退回,就會進入 Retry。在後續嘗試時,如果 Queue 與路由已恢復,發佈才有機會成功。在本文實作中,若既有 Connection 與 Channel 仍開啟,重試不會重新執行 Queue 宣告;這時需要外部(人或部署流程)先恢復 Queue 與 Binding。若重試觸發連線重建,則仍可能由初始化流程重新宣告。

但若 broker 暫時沒有回應,BasicPublishAsync 還沒有完成,也沒有拋出例外,外面的 Retry 就不會啟動。這不是定期輪詢 Queue,而是等待原本那次發佈的結果。

當次驗證把 RabbitMQ 容器暫停,分別等待約 10 秒與 45 秒,發佈沒有因為等待過久而失敗;解除暫停後,原本的發佈才完成。

Heartbeat 也可能讓連線層先偵測到故障。實際逾時值由用戶端與 broker 協商,不能只依預設值推論本次測試的斷線時機;本次沒有驗證更長時間的容器暫停。

沒有發佈逾時,表示 Retry 次數有上限,但單次發佈的等待時間沒有由這次實作設定上限。

連線故障仍可能由用戶端或網路層偵測並回報例外;不能把「沒有自行設定發佈逾時」理解成所有故障都一定永久等待。

非同步等待也不等於業務已經完成。它可以釋放執行緒,但呼叫它的 HTTP request 或背景工作仍可能長時間保持未完成狀態。RabbitMQ 的資源壓力也可能阻擋發佈,因此不能只看 Queue 是否存在,還需要觀察 broker 狀態。

為什麼沒有直接加上逾時?

驗證期間曾試過加入發佈逾時,並在逾時後重新發佈。

但逾時只表示用戶端沒有及時取得結果,不代表 broker 一定沒有接受先前的訊息。容器恢復之後,前面那些已逾時的發佈仍可能被處理,加上後續重試,就出現同一則訊息收到 4 次的實測結果。

因此,最後沒有保留這項機制。

⚡ 加上逾時可以限制等待,但若結果不明時重新發佈,就必須一起考慮重複訊息。

若之後要採用逾時重試,需要另行設計穩定的訊息識別、接收端去重或冪等處理。只有加上 MessageId,並不會讓 RabbitMQ 自動替我們去重。

這次選擇先等待確認,避免主動因逾時而再次發佈。然而,這也不等於能保證完全不重複:如果 broker 已接受訊息,但確認在連線故障中遺失,後續重試仍可能重送。沒有去重機制,就不能宣稱 exactly-once。

連線恢復,也要處理 Channel 與併發建立

原本只看 Connection 是否開啟,會漏掉 Channel 已被單獨關閉的情況。修正後會一起檢查兩者,並在需要重建時釋放舊資源。

bool IsConnected =>
    connection is not null && connection.IsOpen
    && channel is not null && channel.IsOpen;

Queue 與 Exchange 的 setup 也從 async void 改為 Task,讓宣告完成後才繼續發佈,並讓 setup 的例外可以被觀察。

新增非同步發佈時,還發現第一次併發建立連線的問題:多個請求同時看到「尚未連線」,便各自建立 Connection,後建立的資源覆蓋前一組,造成多餘連線沒有釋放。

處理方式是在非同步鎖內再次檢查狀態:

private readonly SemaphoreSlim connectLock = new(1, 1);

async Task EnsureConnectedAsync(Func<Task> connectAsync)
{
    if (IsConnected)
        return;

    await connectLock.WaitAsync();
    try
    {
        if (!IsConnected)
            await connectAsync();
    }
    finally
    {
        connectLock.Release();
    }
}

Producer 本身的首次建立也補上鎖內檢查。實測同時送出 50 個請求,修正前建立了 50 條發佈連線,修正後只建立 1 條;強制關閉連線後再同時送出 50 個請求,也只重建一次,沒有依賴重試。

這個併發問題的觀察結果是連線增加,不是訊息遺失。連線建立鎖只保護初始化與重建流程,並沒有保護後續的 BasicPublishAsync。

若多個執行流程共用同一個 IChannel,必須確保發佈操作互斥,或讓各發佈流程使用各自的 Channel,避免同一個 Channel 被同時用來發佈。這是安全使用共用 Channel 的必要條件;上面的連線建立鎖並未滿足這項要求。因此,這次 50 筆併發測試只能證明連線沒有被重複建立,不能視為共用 Channel 已通過完整的併發安全驗證。

這是本文方案的已知限制。若你的服務會併發發佈,建議在照抄之前先為發佈加上互斥(例如另一個 SemaphoreSlim),或改為每個發佈流程使用各自的 Channel,並自行驗證。

我們怎麼驗證這些行為?

這次使用既有測試站台,搭配手動啟動的 RabbitMQ 容器進行驗收,沒有新增自動化測試專案。

測試站台有兩個發佈入口:這次修正前就存在的既有入口,以及這次新增、可一路 await 的 PublishAsync 入口,兩者共用底層發佈程式。下表的「既有入口」「非同步入口」即指這兩者。

閱讀下表前,先說明驗證範圍:

  • 容器暫停情境是在既有入口實測,新增的 PublishAsync 沒有重新執行這項測試;兩者共用底層發佈程式,但這不等同於新版入口的實測。
  • 手動驗收主要涵蓋 Direct 路徑;Topic 與 Fanout 以程式碼審查確認改法一致。
  • 真實 broker 的 nack 情境沒有成功製造,不能把 return 的實測當成 nack 的實測。
  • 50 筆併發測試用來確認連線建立與重建次數,未完成共用 Channel 的併發安全驗證。
情境觀察結果
正常發佈確認後返回,管理介面可看到訊息
刪除 Queue 後發佈無法路由產生例外;非同步入口重試用盡後回報失敗
暫停容器既有入口等待;解除暫停後完成
強制關閉連線下一次發佈可重建並成功
重啟容器恢復後再發佈,訊息可進入 Queue
首次併發發佈 50 筆一條發佈連線,訊息送達,沒有重試
關閉連線後併發發佈 50 筆只重建一次,沒有重試

刪除 Queue 的案例,是先讓 Producer 完成初始化,再刪除 Queue 並重發,才能觀察既有 Channel 上的無法路由行為,避免首次連線的宣告流程先把 Queue 建回來。

Podman 的暫停與恢復指令如下;rabbitmq-test 是示意容器名稱,請改成自己的名稱:

podman pause rabbitmq-test

# 從另一個終端機或測試站台發佈,觀察尚未完成的請求
# 確認等待行為後恢復容器
podman unpause rabbitmq-test

先讓結果可信,再擴大可靠性邊界

這次最重要的改變,是把確認、例外與重試串成呼叫端能等待的流程:成功要有 broker 確認,無法路由要被看見,失敗要能重試,也要能傳回呼叫端。

這些改善仍有明確邊界。沒有發佈逾時,就可能持續等待;沒有去重,就不能保證重送不會造成重複;取得發佈確認,也不代表 Consumer 已完成業務處理;共用 IChannel 的併發發佈也還需要另外處理。

對這次的需求而言,我們先接受等待的代價,補齊最容易造成靜默失敗的缺口。後續若要限制等待時間,應把逾時、結果不明時的重送,以及接收端去重一起設計,而不是只多加一個 Retry。

參考

💭 RabbitMQ .NET/C# Client API Guide:發佈、mandatory、連線恢復與併發使用的注意事項。

💭 Reliable Publishing with Publisher Confirms:.NET 非同步發佈與確認追蹤範例。

💭 Consumer Acknowledgements and Publisher Confirms:Producer 與 Consumer 確認的差異、mandatory return、ack/nack,以及無法路由與持久化訊息的確認時機。

💭 CreateChannelOptions:Channel 的 Publisher Confirms 與 Tracking 設定。

💭 Memory and Disk Alarms:broker 資源不足時對發佈連線的阻擋行為。

💭 Polly v7 → v8 Migration Guide:傳統 Policy API 與 v8 ResiliencePipeline API 的差異。

如果這篇文章對你有幫助,歡迎分享給更多人!

贊助支持

如果你喜歡我們的文章,或是這些內容對你有幫助,歡迎透過以下平台請我們喝杯咖啡,支持我們持續創作!

Ko-fi

作者群

NE

Neil Tsai

樂於分享所見所聞所覺所知的全端工程師

CH

ChatGPT

Neil Tsai 的文章協作夥伴

留言功能需要 Cookie 授權

為了載入留言功能,我們需要您同意使用「功能性 Cookie」。您可以隨時在設定中調整。

推推,這 3 篇也值得你順手看完~

根據相同主題與共享標籤,幫你整理出最相關的延伸閱讀。

免責聲明

本網站對於任何使用或引用本網站網頁資料引致之損失或損害,概不負責。本網站亦有權隨時刪除、暫停或編輯本網站所登載之各項資料,以維護本網站之權益。除法律有強制規定外,在任何情況下,本網站對於 (1) 使用或無法使用本網站之各項服務;(2) 經由本網站取得訊息或進行交易;(3) 第三人在本網站上之陳述或作為;以及 (4) 其他與本網站服務有關之事項所致生之任何直接、間接、附帶、特別、懲罰性或衍生性損害,一概不負賠償責任。

CopyRight © 2026 Thinkin Markdown