RabbitMQ 发布可靠性:mandatory、publisher confirms 与不可路由消息(九)
前两篇已经把 order.created 的发布端和消费端代码搭起来了,但还有一个最容易被误解的边界没有单独讲透:发布者究竟什么时候才能说“这条消息已经可靠交给 RabbitMQ 了”? 本篇把 mandatory、returned messages、publisher confirms、RabbitMQ.Client 的确认跟踪,以及网络中断产生的结果未知窗口拆开说明。
本文不讨论消费者优雅关闭、manual ack、prefetch,也不回到 Outbox 或幂等实现。这里只收束发布端到 Broker 之间的可靠性语义。
系列导航:
- 上一篇:RabbitMQ + .NET 实战:消费消息、手动确认与优雅关闭(八)
- 当前篇:RabbitMQ 发布可靠性:mandatory、publisher confirms 与不可路由消息(九)(本文)
- 下一篇:RabbitMQ 消费可靠性:幂等、重试与死信队列(十)
统一示例:继续发布 order.created
仍然沿用前两篇完全相同的事件:
{
"eventId": "01J7X8A6YQ4Q9M6Q4QG7H2J8F1",
"eventType": "order.created",
"occurredAt": "2026-07-15T10:00:00Z",
"orderId": "ORD-20260715-001",
"customerId": "C10086",
"amount": 199.00,
"currency": "CNY"
}
它通过 Topic Exchange orders.events 发布,路由键为 order.created。如果至少有一个 Queue 匹配这条路由规则,消息就是“可路由”的;如果一个都匹配不到,它就是不可路由消息。
mandatory:不要让不可路由消息静默丢失
AMQP 0-9-1 的常见默认行为是:发布到某个 Exchange 的消息,如果没有任何 Queue 匹配成功,Broker 可以直接丢弃它。发布者如果什么都没额外设置,往往不会立刻知道这次路由失败。RabbitMQ 的发布者指南把这种情况称为 unroutable message。
mandatory: true 就是为了解决这个盲点。它的语义非常直接:
- 如果消息至少路由到一个 Queue,发布流程按正常路径继续。
- 如果消息一个 Queue 都路由不到,Broker 不要静默吞掉,而是把它退回给发布者。
在 .NET 客户端里,可以先订阅 BasicReturnAsync,再使用 mandatory: true:
using System.Text.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
channel.BasicReturnAsync += (_, args) =>
{
Console.Error.WriteLine(
$"Returned: replyCode={args.ReplyCode}, replyText={args.ReplyText}, routingKey={args.RoutingKey}");
return Task.CompletedTask;
};
var orderCreated = new
{
eventId = "01J7X8A6YQ4Q9M6Q4QG7H2J8F1",
eventType = "order.created",
occurredAt = DateTimeOffset.Parse("2026-07-15T10:00:00Z"),
orderId = "ORD-20260715-001",
customerId = "C10086",
amount = 199.00m,
currency = "CNY"
};
ReadOnlyMemory<byte> body = JsonSerializer.SerializeToUtf8Bytes(orderCreated);
await channel.BasicPublishAsync(
exchange: "orders.events",
routingKey: "order.created",
mandatory: true,
body: body,
cancellationToken: cancellationToken);
如果这时拓扑里没有任何 Binding 能匹配 order.created,发布者就能通过 return 观察到这次失败,而不是等业务方几小时后才发现“为什么库存和通知都没收到”。
但这里有一个容易被忽略的限制:mandatory 只检查有没有路由到至少一个 Queue,不检查“所有预期订阅者是否都具备正确 Binding”。假设库存 Queue 匹配成功、通知 Queue 的 Binding 丢失,消息仍然算可路由,不会 return。因此,多订阅者场景还需要拓扑声明、启动检查和监控共同保证完整性。
如果 Exchange 配置了 Alternate Exchange,消息经 Alternate Exchange 最终进入 Queue,也算成功路由,同样不会触发 return。官方的Alternate Exchange 说明明确指出,它会参与 mandatory 的路由判断。
returned messages 解决的是“路由失败可观察”,不是“业务失败可观察”
BasicReturnAsync 能告诉你的是:Broker 没能把这条消息路由到任何 Queue。 它解决的是“不可路由”这个问题,不是所有发布失败的总开关。
最容易混淆的地方有三个:
- 返回了 returned message,不代表 Broker 崩了;它可能只是配置上没有匹配到任何 Queue。
- 没有 returned message,不代表消费者已经处理成功;它只说明消息至少进入了某个 Queue 的路由结果。
- returned message 也不等于 publisher nack。不可路由消息在协议层仍会收到 publisher confirm;使用
mandatory时,RabbitMQ 会先发送basic.return,再发送basic.ack。
所以更准确的说法是:
mandatory+ returned message:解决“有没有路由到至少一个 Queue”,但不能证明所有预期 Queue 都存在。- publisher confirm:说明消息已按实际路由结果完成 Broker 侧接收;它本身不能证明消息一定可路由。
- manual ack:解决“消费者是否声明这次投递已经处理完成”。
它们是三段不同边界,不能用一句“消息发成功了”替代。
publisher confirms:实际路由结果被 Broker 接受的证据
发布者最常见的误判是:在没有启用 confirmation tracking 的 Channel 上,看到 BasicPublishAsync() 返回,就以为 RabbitMQ 一定接管了这条消息。此时调用完成只能说明客户端的发送操作已经结束,不能证明 Broker 已按目标 Queue 的语义接受消息。
publisher confirms 就是为这个边界设计的。创建 Channel 时启用确认和客户端跟踪:
var channelOptions = new CreateChannelOptions(
publisherConfirmationsEnabled: true,
publisherConfirmationTrackingEnabled: true);
await using IChannel channel =
await connection.CreateChannelAsync(channelOptions, cancellationToken);
然后正常发布:
var properties = new BasicProperties
{
ContentType = "application/json",
DeliveryMode = DeliveryModes.Persistent,
MessageId = orderCreated.eventId,
Type = orderCreated.eventType,
Timestamp = new AmqpTimestamp(orderCreated.occurredAt.ToUnixTimeSeconds())
};
await channel.BasicPublishAsync(
exchange: "orders.events",
routingKey: "order.created",
mandatory: true,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
仓库示例固定使用 RabbitMQ.Client 7.2.1。按照 CreateChannelOptions 和 BasicPublishAsync 的语义,同时启用 confirmation 和 tracking 后,BasicPublishAsync() 会等待这一次发布的确认结果:
- 消息获得 publisher confirm,且没有被
mandatoryreturn,则调用正常完成。 - Broker nack 或
mandatoryreturn 会抛出PublishException;后者的IsReturn为true。 - 等待期间超时、连接中断或 Channel 关闭,也会以异常结束,但结果不一定都是“Broker 没收到”。
BasicReturnAsync 仍然会先被触发,适合记录 reply code、routing key 和被退回的消息;同一次 return 随后还会让正在等待的 BasicPublishAsync() 抛出异常。应用应把事件当作观测入口,把调用异常当作当前发布任务的控制流,避免把同一次 return 重复记成两次失败。
这一结论有明确前提:confirmation tracking 已启用。 如果只启用协议层 confirms、没有启用客户端 tracking,BasicPublishAsync() 不会替你把每次调用与对应 confirm 关联起来,不能套用上述“正常返回即获得确认”的判断。
RabbitMQ 对 confirm 的准确承诺取决于实际路由结果和 Queue 语义。官方的publisher confirms 指南给出的边界是:
- 不可路由消息:Exchange 确认无法路由后仍会发送
basic.ack;设置mandatory时,先 return,再 ack。 - 可路由消息:消息被所有实际路由到的 Queue 接受后发送
basic.ack。 - persistent 消息进入 durable Queue:确认通常要等消息完成持久化。
- 进入 Quorum Queue:确认要等法定数量副本接受消息。
所以,“Broker accepted”只是便于交流的简称,不能脱离 Queue 类型、消息持久化属性和 mandatory 结果单独理解。
并发模型不会改变发布结果的边界
SemaphoreSlim、多个独占 IChannel 和进程内 Channel<T> 解决的是“发布任务如何安全地到达 RabbitMQ 客户端”或“请求如何排队”,不是“Broker 是否已经接管”。它们和 publisher confirms 是两个正交的问题:
| 观察到的结果 | 最多能证明什么 | 仍然不能证明什么 |
|---|---|---|
写入 Channel<T> 成功 | 当前进程接受了待发布任务 | RabbitMQ 已收到;进程重启前不会丢 |
开启 confirmation tracking 后,BasicPublishAsync 正常完成 | 实际路由到的 Queue 已按自身语义接受消息,且没有 mandatory return | 所有预期 Queue 都存在;消费者已完成业务 |
| 本地事务写入 Outbox 成功 | 事件进入了可恢复的待发布集合 | RabbitMQ 已接管消息 |
所以,单后台 Publisher 能避免多个请求同时使用一个 IChannel,但它不能把“进了内存队列”升级为“发布成功”;Channel Pool 能提高并行度,也不能缩小确认尚未返回时的结果未知窗口。共享 Channel 的正确并发模型见.NET 发布端实战(七),数据库写入 Outbox 后如何继续可靠发布见端到端可靠性(十一)。
发布确认不等于业务完成
收到 publisher confirm,说明 RabbitMQ 已按实际路由结果完成 Broker 侧接收,但它仍然不能证明消费者完成了业务,也不能证明所有设计上预期存在的 Queue 都正确绑定。更清晰的分层是:
| 阶段 | 机制 | 最多能证明什么 | 仍然不能证明什么 |
|---|---|---|---|
| 路由覆盖 | mandatory + return | 至少有一个 Queue 接收路由,或不可路由被明确发现 | 所有预期 Queue 都配置正确 |
| Broker 侧接收 | publisher confirms | 所有实际路由到的 Queue 已按自身语义接受消息 | 消费者已经完成业务 |
| Broker -> Consumer 处理完成 | manual ack | 消费者声明投递处理成功 | 业务外部副作用绝对没有重复 |
回到 order.created:
mandatory+ tracking 下的发布调用正常完成,说明消息没有被 return,且实际路由到的 Queue 已接受消息。- 它仍然不能发现“库存 Queue 正常、通知 Queue 的 Binding 丢失”这种部分拓扑错误。
- 库存服务是否真的预留成功,不在 confirm 的证明范围内。
- 通知服务是否真的发出短信,也不在 confirm 的证明范围内。
因此,在业务语言里,把“消息已被 Broker 接收”描述成“订单创建流程已完成”是错误的。Broker accepted 是基础设施层成功,business completed 是业务层成功,两者之间还隔着消费与业务执行。
unknown-result window:网络在错误时间断开时,结果会变得不确定
真正棘手的场景不是“明确成功”或“明确失败”,而是结果未知。围绕 order.created,可能出现两条在发布者看来无法区分的路径:
- RabbitMQ 已经让目标 Queue 接受消息,并发出了 confirm,但响应在返回发布者前遇到网络中断。
- 发布帧还没有完整到达 RabbitMQ,连接就已经中断,Broker 实际上没有接收消息。
发布者看到的都可能只是超时或连接异常,却无法仅凭这个异常还原 Broker 里的真实结果。这就是发布端最关键的 unknown-result window:网络和进程故障把“真实结果”和“调用方观察结果”切开了。
对发布者而言,这时只能确定两件事:
- 不能证明 Broker 没收到。
- 也不能证明 Broker 一定收到了。
于是系统必须在“不确定”里做选择。最常见的策略是:
- 记录这次发布的稳定
MessageId,例如eventId。 - 把这次结果标记为 unknown,而不是误记成 definite failure。
- 后续若选择重发,必须重发同一个
MessageId,并让消费端准备好处理重复。
也就是说,unknown-result window 直接把系统推回 at-least-once 现实:要避免丢失,就必须接受可能重复。
按结果分类,而不是统一标记成“发送失败”
发布代码最终需要把观察结果分成可行动的类别:
| 观察结果 | 当前能确定什么 | 建议处理 |
|---|---|---|
BasicPublishAsync() 正常完成 | tracking 已启用时,消息获得 confirm 且没有 return | 记录发布成功;仍不要声称业务完成 |
PublishException.IsReturn == true | 消息明确不可路由 | 记录 Exchange、routing key 与 reply code;修复拓扑或路由数据,不要盲目原路重试 |
其他 PublishException | Broker 对该发布给出 nack | 记录为明确的 Broker 侧拒绝,结合 Queue 限制和 Broker 状态决定是否重试 |
| 发布到不存在的 Exchange,Channel 被关闭 | 目标 Exchange 或拓扑声明有误 | 重建 Channel 前先修复拓扑;原 Channel 不能继续使用 |
| confirm 超时、连接中断、进程退出 | 可能已接收,也可能未接收 | 标记为 unknown;保留相同 MessageId 重发,并依赖消费端幂等收敛重复 |
写入进程内 Channel<T> 成功 | 任务只进入本进程内存队列 | 不得记为 RabbitMQ 发布成功 |
围绕 order.created,发布者至少应做到:为消息设置稳定 MessageId;在业务要求“至少有一个接收队列”时使用 mandatory: true;启用 publisher confirms 与客户端 tracking;区分 return、nack、拓扑错误和 unknown,而不是把所有异常都归为同一种失败。RabbitMQ 的可靠性指南同样指出,连接故障后重发未确认消息可能造成重复,因此消费端必须去重或保持幂等。
本篇解决了什么:
- 区分了
mandatory、returned messages、协议层 confirm 与客户端 tracking 各自解决的发布边界,明确 confirm 本身不能证明可路由或业务完成。 - 说明了安全并发、进程内排队与发布确认是独立问题:前两者不能替代 Broker 接管证据。
- 解释了 return、nack、拓扑错误和 unknown 应如何分类,以及发布端为何要用稳定
MessageId面对可能重发与重复。