系列导航:

继续沿用同一个订单事件:订单服务发布 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-001ORD-002ORD-003。很多人会自然地期待:既然发布顺序是 1、2、3,那么消费者处理完成的顺序也应该是 1、2、3。RabbitMQ 只能部分满足这个期待,前提还比想象中更苛刻。

先把“有序”说清楚

在 RabbitMQ 里,最容易混淆的是三种不同的“顺序”:

  1. 进入某个 Queue 的顺序:消息按 Broker 实际接收并入队的先后排列。
  2. 被投递给 Consumer 的顺序:Broker 按队列顺序尝试投递,但会受多个 Consumer 和 prefetch 影响。
  3. 业务处理完成的顺序:取决于每条消息各自的处理耗时、失败重试和下游依赖速度。

只有在非常收敛的条件下,三者才会看起来接近一致:

  • 单个发布通道,按顺序发布
  • 单个 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 需要调用慢库存库,耗时 800ms
  • ORD-002 命中缓存,耗时 20ms
  • ORD-003 需要重建索引,耗时 300ms
入队、投递与业务完成顺序分叉三条消息按一二三入队,并发处理耗时为八百、二十和三百毫秒,最终按二三一完成。ONE QUEUE · THREE DIFFERENT ORDERS入队顺序Broker 按实际接收顺序保存1 · ORD-0012 · ORD-0023 · ORD-003投递关系Consumer 并发处理,耗时彼此独立Consumer AORD-001处理 800msConsumer BORD-002处理 20msConsumer CORD-003处理 300ms完成顺序由处理耗时决定:2 → 3 → 11 · ORD-00220ms 后完成2 · ORD-003300ms 后完成3 · ORD-001800ms 后完成并发发布或 requeue 会进一步改变入队与再次投递的位置,不能把 Queue 等同于业务严格有序。入队、投递与业务完成顺序分叉移动布局按入队、并发处理和完成三个阶段展示二三一的完成结果。THREE DIFFERENT ORDERS入队顺序 · 1 → 2 → 31 · ORD-0012 · ORD-0023 · ORD-003投递关系 · 并发处理Consumer A · ORD-001800msConsumer B · ORD-00220msConsumer C · ORD-003300ms完成顺序 · 2 → 3 → 11 · ORD-00220ms2 · ORD-003300ms3 · ORD-001800msQueue 有序 ≠ 业务副作用严格有序并发发布与 requeue 还会继续改变观察结果
入队顺序不等于业务完成顺序Queue 保存 Broker 接收后的入队顺序;并发投递、处理耗时和重投递都会让业务完成顺序发生变化。
ORD-001、ORD-002、ORD-003 按顺序入队并被并发投递;由于处理耗时分别为 800ms、20ms 和 300ms,业务完成顺序变成 ORD-002、ORD-003、ORD-001。

即使它们的投递顺序是 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 的价值之一是缓冲短时突发,但它不是无限大的流量黑洞。判断是否进入积压,可以用一个非常朴素的公式:

持续进入速率 > 持续处理速率
=> 队列长度持续增长
=> 等待时间持续变长
从容量差、队列积压到上游背压每秒流入五千条、处理三千条,队列每秒净增两千条并在十分钟后理论积压一百二十万条,背压信号再返回上游。SUSTAINED RATE MISMATCH · QUEUE GROWTH流入 5,000 条/秒订单服务持续发布处理 3,000 条/秒库存服务稳定能力Queue · 缓冲区净增 2,000 条/秒积压增长Queue 只能延迟吸收容量差开始0 条1 分钟120,000 条10 分钟1,200,000 条背压闭环长期吃不下时,把控制信号返回上游UPSTREAM CONTROL限流降级延迟受理拆分热点增加消费者只有在下游资源仍有余量时才有效;它不是唯一、也不是无上限的解决方案。从容量差、队列积压到上游背压移动布局纵向展示流入、积压增长、消费能力和返回上游的控制动作。RATE MISMATCH流入 5,000 条/秒订单服务持续发布Queue · 净增 2,000 条/秒持续流入大于持续处理处理 3,000 条/秒库存服务稳定能力积压增长开始0 条1 分钟120,000 条10 分钟1,200,000 条背压返回上游限流降级延迟受理拆分热点积压是结果;背压是主动控制动作
持续容量差如何演变成积压与背压Queue 可以吸收短时突发;持续流入高于持续处理时,必须把压力信号传回上游。
订单事件以每秒五千条进入、以每秒三千条处理,队列每秒净增两千条,十分钟理论积压一百二十万条;系统随后通过限流、降级、延迟受理和拆分热点把压力返回上游。

例如订单高峰时每秒写入 5,000 条 order.created,而库存服务长期只能稳定处理每秒 3,000 条,那么每秒就会净增 2,000 条待处理消息。十分钟后,理论上就多出 120 万条积压。RabbitMQ 在这里做的是“延迟吸收”,不是“凭空消灭差值”。

这也是“积压”和“背压”的分界线:

  • 积压:消息还在 Queue 里排队,系统暂时还能继续收。
  • 背压:你已经意识到下游长期吃不下,必须把压力往上游传回去,例如限流、降级、延迟受理、拆分热点,或者谨慎地增加真正能提升吞吐的消费者能力。

如果只看到 Queue 还能继续堆,就默认系统“还扛得住”,那只是把问题从接口超时,换成了更长的消息等待时间和更高的资源占用。

本篇解决了什么:

  • 区分了入队顺序、投递顺序和业务完成顺序,解释了并发发布、并发消费为什么会打破直觉中的严格有序。
  • 说明了 requeue、消息积压与背压各自代表什么,以及为什么 Queue 只能缓冲突发,不能替系统消化持续超载。
  • 补充了有界 Channel<T> 只能提供进程内背压:它能避免多个请求并发操作同一个发布 Channel,却不能持久化消息或协调多个实例。

下一篇:RabbitMQ 常见模式:Work Queue、Pub/Sub、Routing、RPC(六)