RabbitMQ 原理解析(下):存储、集群与可靠投递

上篇讲了 RabbitMQ 的骨架:AMQP 模型和 Erlang 进程架构。这一篇我们换个问法:一条消息进入 RabbitMQ 之后,到底安不安全?

这个问题会带着我们走完五层:消息存在哪、内存满了怎么办、服务器重启还在吗、节点挂了还在吗、它会被正确处理吗。每一层都是下一层的前提。

引子:一条消息进来之后

上篇说到,消息经过 Exchange 路由后,进入了一个 Queue 进程。Queue 进程是单线程的,它负责把消息存下来、投出去。

但你有没有想过:

  • 消息到底存在哪里?内存还是磁盘?
  • 如果内存满了,会怎么样?
  • RabbitMQ 重启,消息还在吗?
  • 集群里挂了一个节点,消息还在吗?
  • 消费者处理失败了,消息会丢吗?

这五个问题,就是这篇要一层层回答的。


第一层:消息存在哪里

先破除一个误解

"RabbitMQ 把消息存在内存里"------这是一个流传很广的误解。

RabbitMQ 中,消息几乎总是写到磁盘上,内存只是处理过程中的缓冲区。

消息到达后,先进入一个小型内存缓冲区,缓冲区满了或达到一定操作次数后,批量刷写到磁盘。

这意味着:发布 1GB 消息到一个没有消费者的队列,RabbitMQ 不会占用 1GB 内存。内存里最多是缓冲区和部分缓存的消息,绝大部分数据在磁盘上。

那为什么内存占用还是很高

因为磁盘存储的索引结构 、消息的元数据 、每个消费者的未确认记录 、以及 Erlang 进程本身的栈和 Mailbox,都占用内存。

消息体在磁盘上,但"关于消息的管理信息"在内存里。这就是为什么一个堆积了几百万条消息的队列,内存占用可能只有几十 MB,但文件系统上已经有几个 GB 的数据。

到这里第一层回答了:消息主要在磁盘上。

但新问题来了:如果消息持续进来,内存缓冲区一直刷盘,内存使用会不会失控?于是进入第二层。


第二层:内存满了怎么办

内存高水位线

RabbitMQ 有一个可配置的 内存高水位线 ,默认是可用内存的 40%vm_memory_high_watermark = 0.4)。

当节点内存使用超过这个阈值时,RabbitMQ 会做两件事:

第一,触发发布者流控。 所有正在发布消息的连接进入 blocking 状态,生产者发不进去消息。

第二,触发分页(Paging)。 把队列内存中缓存的消息更多地刷到磁盘上,腾出内存。

一个重要的行为特征

内存水位线触发的是集群范围的阻塞。一个节点内存超了阈值,即使其他节点内存充裕,整个集群的生产者都可能被阻塞。

这不是 bug,而是设计------防止一个节点 OOM 被操作系统杀掉,导致更严重的数据不一致。

到这里第二层回答了:内存满了会触发流控,生产者被阻塞。

但新问题来了:如果 RabbitMQ 服务器重启,磁盘上的消息还在吗?于是进入第三层。


第三层:服务器重启,消息还在吗

持久化的三个层次

消息不丢,需要三个层次同时成立,缺一不可:

  1. 交换机持久化ExchangeBuilder.durable(true),交换机元数据写盘;
  2. 队列持久化QueueBuilder.durable(...),队列元数据写盘;
  3. 消息持久化setDeliveryMode(PERSISTENT),消息体写盘。

三者缺一不可。少了一层,重启后相关数据就丢了。

一个容易被忽略的细节

即使消息是持久化的,如果发布者没有收到 Publisher Confirm,在 Broker 崩溃时这条消息仍然可能丢失。

Confirm 的语义是"Broker 已经接管了这条消息的责任"。没有 Confirm,责任就还在网络传输中。

还有一个更深的细节

消息进入队列后,Queue 进程负责把它写入磁盘。但这个写入是批量刷盘的,不是每条消息单独 fsync。

text 复制代码
持久化消息的一生:

  Queue 进程内存缓冲区
         │
         │ 批量刷盘(不是每条都 fsync)
         ▼
  msg_store_persistent
  ┌────────────────────┐
  │ 段文件 1            │
  │ 段文件 2            │
  │ ...                 │
  └────────────────────┘

  极端情况(操作系统崩溃):
  最近几毫秒内 Confirm 过的消息可能还没落盘

这也是为什么 RabbitMQ 官方文档坦诚地说:即使收到了 Confirm,服务器崩溃时消息在理论上仍可能丢失

到这里第三层回答了:持久化分三层,但批量刷盘仍有理论上的丢失窗口。

但新问题来了:单机重启问题解决了,如果是集群,挂了一个节点呢?于是进入第四层。


第四层:集群里挂了一个节点,消息还在吗

先理解集群里什么是共享的

在 RabbitMQ 集群中,交换机、绑定、用户、权限、vhost 这些元数据在所有节点上保持一致。3.x 时代由 Mnesia 存储,4.2 之后默认用 Khepri(基于 Raft 的元数据存储)。

消息数据不是集群共享的。一个队列的消息体只存在于它所在的节点(以及可能的副本节点)上。如果队列所在的节点挂了,而队列没有副本,消息就不可用了。

镜像队列:为什么它被放弃了

镜像队列(Mirror Queue)是早期的高可用方案。工作方式是:主队列 + 多个从队列,所有从队列都保存成功后,主队列才向生产者发送确认

这个设计有两个致命缺陷:

  • 节点恢复后数据不同步:节点下线又上线,从队列数据全部丢失,恢复后是空队列,运维必须手动决定是否触发同步;
  • 同步是阻塞的:触发同步时,整个队列不可用,生产者和消费者都被阻塞。堆积几百万条消息时,同步可能持续几分钟甚至几小时。

镜像队列的本质问题是:它用的是链式复制算法,没有共识机制,同步行为是破坏性的

Quorum 队列:Raft 带来的改变

Quorum 队列从 RabbitMQ 3.8 引入,基于 Raft 共识算法。它与镜像队列的核心差异是"确认范围":

Raft 的核心思想是多数派确认 。一个 Quorum 队列有多个副本(通常 3 或 5 个),主副本接收消息后复制到从副本,当多数副本确认保存后,才向生产者发送确认

与镜像队列的"所有从队列确认"不同,Quorum 队列只需要多数派(3 副本中的 2 个,5 副本中的 3 个)确认。好处是:

  • 更低的确认延迟:不需要等最慢的那个副本;
  • 更安全的故障恢复:Raft 有严格的日志一致性保证,节点恢复后不需要"阻塞式全量同步";
  • 更好的性能:Raft 的日志复制是流水线式的,不是链式的。

代价是:只支持持久化消息、不支持优先级队列、不支持部分 TTL 用法、至少需要 3 个节点。

节点数的含义

Quorum 队列的"多数派"要求决定了节点数:

  • 1 节点:无高可用;
  • 2 节点:挂 1 个 → 失去多数派 → 不可用;
  • 3 节点:挂 1 个 → 还有 2/3 → 可用(最低配置);
  • 5 节点:挂 2 个 → 还有 3/5 → 可用(高可用场景)。

生产环境的 RabbitMQ 集群,3 节点是起步,5 节点是高可用场景的常见选择

到这里第四层回答了:Quorum 队列用多数派确认保证节点故障时消息不丢。

但新问题来了:消息存得好好的,消费者处理失败了怎么办?于是进入第五层。


第五层:它会被正确处理吗

三段独立确认

RabbitMQ 的可靠性不是"一个开关",而是三段独立确认的叠加。

每一段都要单独保证,缺一个环节,可靠性就有缺口。

第一段:生产者到 Broker(Publisher Confirm)

Broker 的 Exchange 进程收到消息后返回 basic.ack ,表示消息已经被 Broker 接管 ,但不代表已经路由到队列

如果 Exchange 找不到匹配的队列,根据 mandatory 设置决定:丢弃(默认)还是退回(Publisher Return)。

Confirm 的底层实现 :Broker 为每条消息分配递增的 deliveryTag,生产者通过 CorrelationData 关联这些 tag。Broker 可以批量确认,也可以单条确认。这就是 publisher-confirm-type: correlated 是推荐配置的原因------它用异步回调避免同步等待。

第二段:Broker 内部的持久化

见第三层。核心结论:批量刷盘,不是每条 fsync,极端情况有丢失窗口。

第三段:Broker 到消费者(Consumer Ack)

消费者从队列取到消息后,消息从 Ready 变为 Unacked 。RabbitMQ 不会立刻删除它,而是等待消费者显式发送 basic.ack

自动 ACK 的危险在于:消息一投递就立刻被认为"已确认",从队列中移除。如果消费者在处理过程中崩溃,消息已经没了。这就是为什么生产环境必须手动 ACK。

死信与 TTL 的底层行为

死信(Dead Letter)不是一个独立的"队列类型",而是消息被重新发布到另一个 Exchange 的机制。官方的文档里触发条件有三种:

  1. 消费者 basicNack(requeue=false)
  2. 消息 TTL 过期;
  3. 队列超过 maxLength

一个重要的性能陷阱 :RabbitMQ 的 TTL 检查只发生在队列头部

text 复制代码
队列(FIFO):
┌─────────────────────────────────────────────┐
│ 队头 消息A (TTL=60s)                         │
│ 消息B (TTL=1s)   ← 不会单独过期!             │
│ 消息C (TTL=1s)                               │
│ ...                                          │
└─────────────────────────────────────────────┘

消息B 必须等 消息A 过期出队后,才能被检查

如果队头消息的 TTL 是 60 秒,而它后面的第二条消息 TTL 是 1 秒,第二条消息不会在 1 秒后被单独清除------它必须等队头消息过期并出队后,才能被检查。

这就是为什么用 TTL + DLX 实现延迟队列时,消息的延迟时间必须一致,或者按延迟时间递增的顺序发送

延迟交换机插件(rabbitmq_delayed_message_exchange)解决了这个问题:它引入 x-delayed-message 类型,消息在交换机层面被延迟,到期后才被路由到队列,不依赖队列头部的 TTL 检查,支持任意延迟时间。

到这里第五层回答了:消费端靠手动 ACK 和死信队列兜底,但要注意 TTL 队头阻塞的陷阱。


复盘:一条消息的安全地图

五层走完,一条消息的完整安全链路是这样的:

五层对应关系

回答的问题 对应环节
第一层 消息存在哪里 内存缓冲 + 批量刷盘
第二层 内存满了怎么办 高水位线 + 流控
第三层 服务器重启还在吗 持久化三层 + Confirm
第四层 集群挂节点还在吗 Quorum 队列 + 多数派
第五层 会被正确处理吗 三段确认 + 手动 ACK + 死信

理解原理之后:四个反直觉的结论

结论一:RabbitMQ 的队列是单线程的。 一个队列的吞吐上限约等于一个 CPU 核心的处理能力。如果你的业务需要单个逻辑队列承载极高吞吐,RabbitMQ 的队列模型会成为瓶颈,要么拆队列,要么考虑 Kafka / Pulsar 这类分区模型。

结论二:消息主要在磁盘上,不在内存里。 管理后台的内存占用高,通常不是消息体造成的,而是索引、元数据、Unacked 记录和 Erlang 进程本身的开销。调优内存时,关注的是 vm_memory_high_watermark 和队列的 Unacked 数量,而不是"把消息从内存挪到磁盘"。

结论三:镜像队列的阻塞式同步是设计缺陷,不是配置问题。 无论怎么调 ha-sync-mode,只要队列有大量堆积,同步就是灾难。Quorum 队列是正确方向,代价是牺牲了非持久化消息和部分高级特性。

结论四:Publisher Confirm 不等于消息不丢。 Confirm 只保证 Broker 接管了消息,不保证已经落盘、不保证已经路由到队列。真正的端到端可靠性 = Confirm + Return + 持久化 + 手动 ACK + 消费幂等,缺一个环节,可靠性就有缺口。


写在最后

上一篇讲骨架,这篇讲血肉。两篇连起来看,你会看到 RabbitMQ 从"网络进来的字节"变成"可靠的业务消息"的完整旅程。

理解原理之后,再看那些配置项和最佳实践,就不再是"别人说该这么配",而是"我知道为什么必须这么配":

  • prefetch 是对单线程 Queue 进程的保护;
  • mandatory 是对 Exchange 静默丢弃的兜底;
  • Quorum 队列的多数派要求决定了集群最少 3 节点;
  • TTL 的队头检查决定了延迟队列不能用 TTL + DLX 做任意延迟。

每一个选择背后都有原理在支撑。

如果这两篇对你有帮助,欢迎点赞、收藏、关注

相关推荐
SelectDB1 小时前
某头部保险公司基于 SelectDB 的统一 OLAP 架构实践
后端
ServBay1 小时前
ServBay 1.33.0 来了:AI Gateway 一键接管主流 AI CLI 与多模型
后端·aigc·ai编程
行者全栈架构师1 小时前
Spring Boot 接入 MaxKey 单点登录:6 个内部系统,一次登录全通行
java·vue.js·后端
JuiceFS1 小时前
卓驭:百 PB 级智驾数据存储架构演进
后端·自动驾驶
鱼弦1 小时前
跨模态迁移的极限:语言模型的逻辑能力能否完全迁移到视觉?
后端
ModStart1 小时前
写歌、翻唱、可编辑乐谱,YuE2-3B 在 AIGCPanel 一键跑通
后端
故作春风1 小时前
elpis-core 核心从入门到理解
后端·架构·node.js
LEE2 小时前
前端转型全栈 01:数据建模,前端最大的盲区
前端·javascript·后端
Sam_Deep_Thinking2 小时前
单一职责原则:JAVA LocalDate的设计取舍
java·后端·程序员·单一职责原则