Files
cs-note/hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md
T
2026-06-08 23:08:57 +08:00

197 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MQ, RabbitMQ, 存储引擎, Erlang]
create time: 2026-05-24 19:52
---
# RabbitMQ 消息存储
## 概述
RabbitMQ 基于 Erlang/OTP 平台构建,其存储体系分为两层:**元数据**存储在 Mnesia 分布式数据库中(Exchange 定义、Binding 关系等),**消息体**则由各队列进程通过 ETS 表和磁盘文件自行管理。本文深入分析 RabbitMQ 的消息持久化机制、队列类型演进(经典队列、仲裁队列、流式队列)、内存管理策略,以及消息从写入到删除的完整生命周期。
## 正文
### 1. 存储架构:Erlang 进程 + Mnesia
RabbitMQ 的每个 Queue 本质上是一个 Erlang 进程(gen_server),拥有独立的 mailbox 和状态。这种设计天然隔离了不同队列的故障,但也意味着每个队列的资源消耗(内存、调度时间)受到 Erlang 虚拟机(BEAM)的约束。
持久化元数据(Exchange 定义、Binding 关系、用户权限等)存储在 **Mnesia** 分布式数据库中。Mnesia 是 Erlang 原生的数据库,支持事务和分布式复制,但它的设计目标是存储小量元数据,而非大规模消息数据。
实际的消息体存储由各队列进程自行管理,核心依赖 Erlang 的 **ETS(Erlang Term Storage)** 表实现内存层缓存。ETS 是 BEAM 虚拟机内置的高性能键值存储,读写无需经过消息传递,单进程即可高效访问。经典队列在内存中使用 ETS 表暂存待投递的消息,磁盘层则通过自定义的文件存储引擎持久化。仲裁队列和流式队列则各自有不同的底层实现。
```mermaid
graph TD
Producer["Producer"] -->|"AMQP 发布"| Exchange["Exchange"]
Exchange -->|"路由规则"| Binding["Binding"]
Binding --> Queue1["Queue 进程 1"]
Binding --> Queue2["Queue 进程 2"]
Queue1 -->|"持久化写入"| MsgStore["消息存储"]
Queue1 -->|"内存缓存"| Memory["进程内存"]
MsgStore --> Disk["磁盘文件"]
Queue1 --> Consumer["Consumer"]
style Exchange fill:#4A90D9,color:#fff
style Queue1 fill:#F5A623,color:#fff
style Queue2 fill:#F5A623,color:#fff
style MsgStore fill:#6EC1E0,color:#fff
```
### 2. 消息持久化机制
RabbitMQ 中消息要真正持久化,需要同时满足三个条件:
- **Queue 声明为 durable**:队列的元数据在 Broker 重启后保留。
- **Message 的 delivery mode 设为 2(persistent)**:消息体写入磁盘。
- **启用 Publisher Confirms**:生产者端获得 Broker 的写入确认,确保消息不会在传输窗口中丢失(详见 §7)。
> [!question] durable 和 persistent 有什么区别?
>
> `durable` 是队列的属性,控制的是"队列本身在重启后是否还存在";`persistent` 是消息的属性,控制的是"这条消息是否需要写入磁盘"。两者必须同时设置才能实现真正的持久化。如果只有 durable 没有 persistent,队列重启后还在,但里面的消息会丢失;反过来则队列重启后直接消失,消息也就无从谈起了。不过即使两者都设置了,还需要配合 **Publisher Confirms** 才能让生产者端确认消息已被 Broker 安全接收(见 §7)。
消息写入磁盘的时机因消息类型而异。对于**持久化消息**(`delivery mode = 2`),经典队列会将其立即提交给独立的 **persister 进程**,由 persister 异步地批量刷入磁盘。消息在被 persister 确认写入磁盘之前,会一直保存在内存中,所以即使 Broker 崩溃也不会丢。但对于**非持久化消息**,它们只存在于内存中,Broker 重启后即丢失。
> [!tip] persister 进程
>
> 经典队列的磁盘写入并不由队列进程自己完成,而是委托给一个专门的 persister 进程。这种异步设计避免了 I/O 阻塞队列调度,但也意味着"消息写入磁盘"和"消息被队列接受"之间存在一个短暂的窗口。这就是为什么需要 **Publisher Confirms**(见下文)来给生产者端到端的持久化保证。
### 3. Queue 类型演进
RabbitMQ 3.8+ 引入了三种队列类型,各自面向不同的可靠性与性能需求:
**经典队列(Classic Queue)**是 RabbitMQ 最早的队列实现,经历了底层存储的重大演进:早期版本使用 Mnesia 表存储消息,从 3.x 起改用自定义的**文件存储引擎**(类似日志结构的段文件)。经典队列支持可选持久化,采用单 Master 架构,不支持原生复制(镜像队列插件已废弃)。在消息堆积时性能急剧下降,因为队列进程需要维护按消息 ID 索引的磁盘文件结构,大量消息会导致 Erlang 进程 GC 压力增大和 I/O 放大。
**仲裁队列(Quorum Queue)**基于 Raft 共识协议,每个队列在多个节点上维护副本(奇数个,默认 3 个)。消息写入需要多数派(majority)确认后才返回成功,天然保证了数据安全。底层使用 **WAL(Write-Ahead Log)** 顺序写盘——所有队列共享同一个 WAL 文件,避免了经典队列的随机 I/O 问题。消息被消费并 ACK 后,通过 Raft 快照(snapshot)机制定期清理已确认的记录,性能在大量消息堆积时保持稳定。
**流式队列(Stream)**是 3.9 引入的新类型,借鉴了 Kafka 的设计思想。消息以 **append-only log**(段文件,segment file)形式存储在磁盘上,每个 segment 有固定大小,写满后滚动到下一个。支持多消费者独立读取(非竞争消费),各消费者维护自己的 offset,消息不会被消费后删除,支持按时间或 offset 回溯重放。适合事件驱动、审计日志等场景。
| 特性 | Classic Queue | Quorum Queue | Stream |
|------|:---:|:---:|:---:|
| 复制 | 不支持 | Raft 多副本 | Leader-Follower |
| 持久化 | 可选 | 必须 | 必须 |
| 消费后删除 | 是 | 是 | 否 |
| 消息回溯 | 不支持 | 不支持 | 支持 |
| 适合场景 | 简单队列 | 高可靠 | 事件流 |
> [!question] 经典队列在大量消息堆积时性能为什么会急剧下降?
>
> 经典队列的存储引擎在消息堆积时面临两个瓶颈:一是 Erlang 进程的 mailbox 膨胀导致调度延迟增大,GC 暂停时间变长;二是磁盘存储使用了按消息 ID 索引的文件结构,大量随机 I/O 使得写入性能断崖式下跌。仲裁队列通过 WAL 顺序写解决了 I/O 问题,通过 Raft 副本机制分散了单节点压力,从而在堆积场景下保持稳定。
### 4. Lazy Queue:磁盘优先策略
Lazy Queue 是经典队列的一种特殊模式,将消息尽可能早地写入磁盘,只在消费者需要时才加载到内存。适合消息堆积量大但消费速度跟不上的场景。
配置方式:声明队列时设置 `x-queue-mode: lazy`。在 Quorum Queue 中,由于 WAL 本身就是顺序写磁盘的,不需要额外的 Lazy 模式。
### 5. 内存管理:告警、流控与信用机制
RabbitMQ 的内存管理是一个三层防护体系:
**内存告警(Memory Alarm)**:当节点内存使用超过阈值(默认物理内存的 40%),Broker 触发内存告警,阻塞所有生产者的消息发布,直到内存回落到安全水位。
**流控(Flow Control)**:当某个 Erlang 进程(如队列进程)的消息积压超过一定阈值,会主动向 TCP 连接进程发送 `pause` 信号,让生产者的 TCP 接收窗口降为 0,从而在协议层面实现背压。
**信用机制(Credit Flow)**:这是 Erlang 进程间的流控机制。每个进程维护一个 credit 计数器(初始值默认 200,称为 `credit_flow_default_credit`),每传递一条消息消耗 1 个 credit,消费端处理完毕后归还 credit(默认每处理一条归还 1 个)。当 credit 归零时,发送端进程被阻塞(blocked),不再向下游发送,直到收到下游归还的 credit 重新恢复。这个机制保证了 Erlang 进程间的"管道"不会溢出。
```mermaid
graph TD
Producer["Producer"] -->|"发送消息"| Conn["连接进程"]
Conn -->|"credit 检查"| Queue["队列进程"]
Queue -->|"内存检测"| MemCheck{"内存超阈值?"}
MemCheck -->|"是"| Alarm["触发内存告警"]
Alarm -->|"阻塞生产"| Producer
MemCheck -->|"否"| Process["正常处理"]
Process --> Consumer["Consumer"]
style Alarm fill:#D0021B,color:#fff
style Queue fill:#F5A623,color:#fff
```
### 6. 消息的生命周期:写入与删除
消息从进入 RabbitMQ 到最终消失,经历以下阶段:
1. **写入内存**:消息到达队列进程后,先存入进程内存(ETS 表)。
2. **写入磁盘**(如果 persistent):持久化消息被立即提交给 persister 进程异步写入磁盘文件;非持久化消息仅保留在内存中。
3. **投递消费者**:消息从内存中读取并发送给消费者。此时消息被标记为 "unacked",不会重复投递给其他消费者。
4. **等待 ACK**:消息在未收到 ACK 前不会被删除。如果消费者断开连接,unacked 消息会被重新入队。
5. **收到 ACK 后删除**:消息从内存和磁盘中移除。对于持久化消息,磁盘文件中的标记被清除,空间在文件 compaction 时回收。
### 7. Publisher Confirms:生产端的持久化保证
前面提到,消息写入磁盘是异步的。`ch.Publish` 返回 `nil` 只表示消息进入了 Broker 进程的内存,并不保证已经落盘。要获得端到端的持久化保证,必须使用 **Publisher Confirms** 机制。
工作原理:生产者在通道上开启 confirm 模式(`channel.confirmSelect()`),之后每条消息都会被 Broker 分配一个序列号。当消息被成功处理(写入所有镜像/副本)后,Broker 回调 confirm 并携带该序列号;如果处理失败,回调会携带 nack 标记。
> [!question] Publisher Confirms 和 AMQP 事务(tx)有什么区别?
>
> AMQP 的 `tx.select/commit/rollback` 是同步阻塞的事务模式,性能很差(每秒只能处理几百条消息)。Publisher Confirms 是异步的,吞吐量比事务高 1-2 个数量级,是生产环境的唯一推荐方案。
### 8. 消息策略:TTL 与 Max-Length
RabbitMQ 提供了两个队列级参数来控制消息的生命周期和队列大小,防止无限堆积:
- **`x-message-ttl`**(毫秒):消息在队列中的最大存活时间,超时后自动丢弃(或进入死信队列)。适合对时效性敏感的业务场景。
- **`x-max-length`**:队列中允许的最大消息数量(包括 unacked)。达到上限后新消息的处理策略由 `x-overflow` 决定:默认 `drop-head`(丢弃队头最老的消息),也可以设为 `reject-publish`(拒绝新消息)。
这两个参数在声明队列时通过 `x-arguments` 传入,也可以通过 Policy 在运行时动态设置。
### 9. Go 代码示例
```go
package main
import (
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
func main() {
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
log.Fatalf("打开通道失败: %v", err)
}
defer ch.Close()
// 声明持久化队列:durable = true, autoDelete = false, exclusive = false
// 使用仲裁队列(quorum)替代默认的经典队列,获得 Raft 多副本保障
_, err = ch.QueueDeclare("order_events", true, false, false, false, amqp.Table{
"x-queue-type": "quorum",
})
if err != nil {
log.Fatalf("声明队列失败: %v", err)
}
// 发布持久化消息:DeliveryMode = Persistent
err = ch.Publish("", "order_events", false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent, // 消息持久化,配合 durable 队列实现真正持久化
Body: []byte(`{"order_id":"12345","status":"created"}`),
})
if err != nil {
log.Fatalf("发布消息失败: %v", err)
}
log.Println("消息发布成功")
}
```
上面的代码展示了两个关键点:一是 `QueueDeclare` 设置 `durable: true` 并指定 `x-queue-type: quorum` 使用仲裁队列;二是 `Publishing` 设置 `DeliveryMode: amqp.Persistent` 确保消息持久化。两者缺一不可。
> [!tip] 生产环境建议启用 Publisher Confirms
>
> 代码中 `ch.Publish` 返回 `nil` 只代表消息进入了 TCP 缓冲区,并不代表 Broker 已持久化。要获得端到端的写入确认,需要调用 `ch.Confirm(false)` 启用确认模式,然后通过 `ch.NotifyPublish` 监听 Broker 的 ack/nack。这在高可靠场景下是必须的。
## 关联笔记
- [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]]
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
- [[03-协议与标准/5-AMQP-协议|AMQP 协议]]