197 lines
13 KiB
Markdown
197 lines
13 KiB
Markdown
---
|
||
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 协议]]
|