RabbitMQ 顺序与背压:消息积压时到底发生了什么(五)
系列导航:
- 上一篇:RabbitMQ 消费控制:ack、nack、prefetch 与消息确认(四)
- 当前篇:RabbitMQ 顺序与背压:消息积压时到底发生了什么(五)(本文)
- 下一篇:RabbitMQ 常见模式:Work Queue、Pub/Sub、Routing、RPC(六)
继续沿用同一个订单事件:订单服务发布 order.created,库存、通知和对账等下游围绕它异步处理。上一篇解决了“消息何时算处理完成”;本篇继续解决另一个高频误解:为什么你明明按顺序发了消息,结果却可能乱序、重投递,甚至越堆越慢。
还是从同一个 order.created 开始
{
"eventId": "01JABCXYZ9Q4T2R8M7N6P5K4H3",
"eventType": "order.created",
"occurredAt": "2026-08-05T10:00:00Z",
"orderId": "ORD-20260805-001",
"customerId": "C10086",
"amount": 299.00
}
假设订单服务连续发布三条消息,分别对应 ORD-001、ORD-002、ORD-003。很多人会自然地期待:既然发布顺序是 1、2、3,那么消费者处理完成的顺序也应该是 1、2、3。RabbitMQ 只能部分满足这个期待,前提还比想象中更苛刻。
先把“有序”说清楚
在 RabbitMQ 里,最容易混淆的是三种不同的“顺序”:
- 进入某个 Queue 的顺序:消息按 Broker 实际接收并入队的先后排列。
- 被投递给 Consumer 的顺序:Broker 按队列顺序尝试投递,但会受多个 Consumer 和
prefetch影响。 - 业务处理完成的顺序:取决于每条消息各自的处理耗时、失败重试和下游依赖速度。
只有在非常收敛的条件下,三者才会看起来接近一致:
- 单个发布通道,按顺序发布
- 单个 Queue
- 单个 Consumer
- 较小的
prefetch,最好接近1 - 处理过程中没有失败、重投递和长尾耗时
一旦这些条件被并发打破,你最后观察到的“完成顺序”就会偏离“发布顺序”。
并发发布为什么会改变你看到的顺序
如果多个线程、多个实例或多个 Channel 都在同时向同一个 Queue 对应的路由发布消息,Broker 看到的是“谁先到,谁先入队”。它不会根据业务上的先后语义替你重排。
例如:
- 应用实例 A 先创建了
ORD-001 - 应用实例 B 紧接着创建了
ORD-002 - 由于网络与调度差异,
ORD-002先到达 Broker
那么队列里的顺序就可能先是 ORD-002,再是 ORD-001。这不是 RabbitMQ 乱序,而是 并发发布把你的业务时间顺序,转换成了 Broker 接收顺序。
如果业务真的依赖同一实体的严格先后,例如同一个 orderId 的状态流转不能乱,就不能只说“我用了一个队列,所以天然有序”,而要先控制发布侧的并发边界,或者按业务键做分区与串行化。
并发消费为什么更容易打乱结果
就算消息已经按 1、2、3 的顺序进入同一个 Queue,只要消费侧引入并发,完成顺序仍然可能变成 2、1、3 或 3、1、2。
最典型的两个来源是:
- 多个 Consumer 竞争同一个 Queue:1 号消息可能给实例 A,2 号消息可能给实例 B,哪个实例先完成取决于各自耗时。
- 单个 Consumer 的
prefetch大于 1:同一消费者提前拿到多条消息后,后拿到的短任务完全可能先完成并先ack。
例如库存服务同时消费三条 order.created:
ORD-001需要调用慢库存库,耗时 800msORD-002命中缓存,耗时 20msORD-003需要重建索引,耗时 300ms
即使它们的投递顺序是 1、2、3,业务完成顺序也大概率不是 1、2、3。
因此,RabbitMQ 能保证的从来不是“你的业务副作用严格按发布时间落地”,而只是“队列按接收顺序保存待投递消息”。一旦处理阶段引入并发,顺序保证就必须由业务设计重新补上。
requeue 不是回到什么都没发生之前
消息失败后,如果 Consumer 选择 nack(requeue: true),很多人会下意识地理解成“把这条消息原封不动放回原来的位置”。实际使用中,不能这样假设。
更准确的理解是:
- RabbitMQ 会尽量把被重回队列的消息放回合适位置。
- 但在并发消费和其他消息持续进出的情况下,它未必还能回到你心里想象的那个“原位”。
- 于是,重投递后的消息可能更早再次出现,也可能在其他消息之后才再次被处理。
这会直接影响你观察到的顺序。也就是说,requeue 解决的是“稍后再试一次”,不是“把系统时间倒回投递前”。
如果某类错误一旦发生就会稳定复现,持续 requeue 只会把同一条消息反复塞回队列前部附近,拖慢后面的正常消息。这时问题已经不是“顺序会不会乱”,而是“失败消息是否正在放大积压”。
应用内缓冲不是 RabbitMQ 的替代品
发布端也可能先遇到背压:HTTP 请求并发涌入,而共享的 RabbitMQ IChannel 只能由一个后台执行流操作。这时可以把请求先写入 System.Threading.Channels.Channel<T>,再让一个 BackgroundService 单独读取并发布:
using System.Threading.Channels;
var publishQueue = Channel.CreateBounded<PublishRequest>(
new BoundedChannelOptions(1_000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
多个请求是 writer;后台服务是唯一的 reader,因此它可以独占 RabbitMQ 的 IChannel:
await foreach (var request in publishQueue.Reader.ReadAllAsync(stoppingToken))
{
await channel.BasicPublishAsync(
exchange: request.Exchange,
routingKey: request.RoutingKey,
mandatory: request.Mandatory,
basicProperties: request.Properties,
body: request.Body,
cancellationToken: stoppingToken);
}
这里的 PublishRequest 只需要带上发布所需的 exchange、routing key、消息属性和 body。关键不在这个类型长什么样,而在于 SingleReader = true:真正写 RabbitMQ Channel 的代码只有一个执行流。共享 IChannel 为什么需要这样的所有权边界,见.NET 发布端实战(七)。
有界队列的 FullMode = Wait 不是装饰。容量耗尽时,调用 WriteAsync 的上游会等待,压力才会被明确传回接口、定时任务或业务调用方;如果改成静默丢弃,背压就会变成不可见的数据丢失。
不过,它只能解决当前进程内的排队与背压:
- 写入
Channel<T>成功,只代表消息进入本进程内存,不代表 RabbitMQ 已接管。 - 进程在后台 Publisher 发出消息前重启,未读取的项目会丢失。
- 部署三个应用实例,就会有三个互相不可见的
Channel<T>。
因此,Channel<T> 很适合保护进程与控制短时突发,却不是可靠消息存储。订单等不能漏发的事件需要写入 Transactional Outbox;“内存队列成功”和“Broker 已接管”之间的发布确认边界见发布可靠性(九)。
积压与背压的边界在哪里
Queue 的价值之一是缓冲短时突发,但它不是无限大的流量黑洞。判断是否进入积压,可以用一个非常朴素的公式:
持续进入速率 > 持续处理速率
=> 队列长度持续增长
=> 等待时间持续变长
例如订单高峰时每秒写入 5,000 条 order.created,而库存服务长期只能稳定处理每秒 3,000 条,那么每秒就会净增 2,000 条待处理消息。十分钟后,理论上就多出 120 万条积压。RabbitMQ 在这里做的是“延迟吸收”,不是“凭空消灭差值”。
这也是“积压”和“背压”的分界线:
- 积压:消息还在 Queue 里排队,系统暂时还能继续收。
- 背压:你已经意识到下游长期吃不下,必须把压力往上游传回去,例如限流、降级、延迟受理、拆分热点,或者谨慎地增加真正能提升吞吐的消费者能力。
如果只看到 Queue 还能继续堆,就默认系统“还扛得住”,那只是把问题从接口超时,换成了更长的消息等待时间和更高的资源占用。
本篇解决了什么:
- 区分了入队顺序、投递顺序和业务完成顺序,解释了并发发布、并发消费为什么会打破直觉中的严格有序。
- 说明了
requeue、消息积压与背压各自代表什么,以及为什么 Queue 只能缓冲突发,不能替系统消化持续超载。 - 补充了有界
Channel<T>只能提供进程内背压:它能避免多个请求并发操作同一个发布 Channel,却不能持久化消息或协调多个实例。