vault backup: 2026-06-08 23:08:57
This commit is contained in:
@@ -7,7 +7,7 @@ create time: 2026-05-24 19:52
|
||||
|
||||
## 概述
|
||||
|
||||
RabbitMQ 基于 Erlang/OTP 平台构建,消息存储依赖 Erlang 进程模型和 Mnesia 分布式数据库。本文深入分析 RabbitMQ 的消息持久化机制、队列类型演进(经典队列、仲裁队列、流式队列)、内存管理策略,以及消息从写入到删除的完整生命周期。
|
||||
RabbitMQ 基于 Erlang/OTP 平台构建,其存储体系分为两层:**元数据**存储在 Mnesia 分布式数据库中(Exchange 定义、Binding 关系等),**消息体**则由各队列进程通过 ETS 表和磁盘文件自行管理。本文深入分析 RabbitMQ 的消息持久化机制、队列类型演进(经典队列、仲裁队列、流式队列)、内存管理策略,以及消息从写入到删除的完整生命周期。
|
||||
|
||||
## 正文
|
||||
|
||||
@@ -15,7 +15,9 @@ RabbitMQ 基于 Erlang/OTP 平台构建,消息存储依赖 Erlang 进程模型
|
||||
|
||||
RabbitMQ 的每个 Queue 本质上是一个 Erlang 进程(gen_server),拥有独立的 mailbox 和状态。这种设计天然隔离了不同队列的故障,但也意味着每个队列的资源消耗(内存、调度时间)受到 Erlang 虚拟机(BEAM)的约束。
|
||||
|
||||
持久化元数据(Exchange 定义、Binding 关系、用户权限等)存储在 **Mnesia** 分布式数据库中。Mnesia 是 Erlang 原生的数据库,支持事务和分布式复制,但它的设计目标是存储小量元数据,而非大规模消息数据。实际的消息体存储由各队列进程自行管理。
|
||||
持久化元数据(Exchange 定义、Binding 关系、用户权限等)存储在 **Mnesia** 分布式数据库中。Mnesia 是 Erlang 原生的数据库,支持事务和分布式复制,但它的设计目标是存储小量元数据,而非大规模消息数据。
|
||||
|
||||
实际的消息体存储由各队列进程自行管理,核心依赖 Erlang 的 **ETS(Erlang Term Storage)** 表实现内存层缓存。ETS 是 BEAM 虚拟机内置的高性能键值存储,读写无需经过消息传递,单进程即可高效访问。经典队列在内存中使用 ETS 表暂存待投递的消息,磁盘层则通过自定义的文件存储引擎持久化。仲裁队列和流式队列则各自有不同的底层实现。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -36,30 +38,35 @@ graph TD
|
||||
|
||||
### 2. 消息持久化机制
|
||||
|
||||
RabbitMQ 中消息要真正持久化,需要同时满足两个条件:
|
||||
RabbitMQ 中消息要真正持久化,需要同时满足三个条件:
|
||||
|
||||
- **Queue 声明为 durable**:队列的元数据在 Broker 重启后保留。
|
||||
- **Message 的 delivery mode 设为 2(persistent)**:消息体写入磁盘。
|
||||
- **启用 Publisher Confirms**:生产者端获得 Broker 的写入确认,确保消息不会在传输窗口中丢失(详见 §7)。
|
||||
|
||||
> [!question] durable 和 persistent 有什么区别?
|
||||
>
|
||||
> `durable` 是队列的属性,控制的是"队列本身在重启后是否还存在";`persistent` 是消息的属性,控制的是"这条消息是否需要写入磁盘"。两者必须同时设置才能实现真正的持久化。如果只有 durable 没有 persistent,队列重启后还在,但里面的消息会丢失;反过来则队列重启后直接消失,消息也就无从谈起了。
|
||||
> `durable` 是队列的属性,控制的是"队列本身在重启后是否还存在";`persistent` 是消息的属性,控制的是"这条消息是否需要写入磁盘"。两者必须同时设置才能实现真正的持久化。如果只有 durable 没有 persistent,队列重启后还在,但里面的消息会丢失;反过来则队列重启后直接消失,消息也就无从谈起了。不过即使两者都设置了,还需要配合 **Publisher Confirms** 才能让生产者端确认消息已被 Broker 安全接收(见 §7)。
|
||||
|
||||
消息写入磁盘的时机并非立即的。RabbitMQ 使用一种"惰性"写入策略:消息先写入内存,当满足一定条件(如内存压力达到阈值、或显式 flush)时才批量刷入磁盘。这意味着在极端情况下(如 Broker 突然崩溃),少量消息可能丢失。
|
||||
消息写入磁盘的时机因消息类型而异。对于**持久化消息**(`delivery mode = 2`),经典队列会将其立即提交给独立的 **persister 进程**,由 persister 异步地批量刷入磁盘。消息在被 persister 确认写入磁盘之前,会一直保存在内存中,所以即使 Broker 崩溃也不会丢。但对于**非持久化消息**,它们只存在于内存中,Broker 重启后即丢失。
|
||||
|
||||
> [!tip] persister 进程
|
||||
>
|
||||
> 经典队列的磁盘写入并不由队列进程自己完成,而是委托给一个专门的 persister 进程。这种异步设计避免了 I/O 阻塞队列调度,但也意味着"消息写入磁盘"和"消息被队列接受"之间存在一个短暂的窗口。这就是为什么需要 **Publisher Confirms**(见下文)来给生产者端到端的持久化保证。
|
||||
|
||||
### 3. Queue 类型演进
|
||||
|
||||
RabbitMQ 3.8+ 引入了三种队列类型,各自面向不同的可靠性与性能需求:
|
||||
|
||||
**经典队列(Classic Queue)**是 RabbitMQ 最早的队列实现。消息存储在 Erlang Mnesia 表中(实际上从 3.x 开始使用自定义的文件存储引擎),支持可选持久化。单 Master 架构,不支持复制。在消息堆积时性能急剧下降,因为队列进程需要维护一个按 offset 索引的磁盘文件结构,大量消息会导致 GC 压力和 I/O 放大。
|
||||
**经典队列(Classic Queue)**是 RabbitMQ 最早的队列实现,经历了底层存储的重大演进:早期版本使用 Mnesia 表存储消息,从 3.x 起改用自定义的**文件存储引擎**(类似日志结构的段文件)。经典队列支持可选持久化,采用单 Master 架构,不支持原生复制(镜像队列插件已废弃)。在消息堆积时性能急剧下降,因为队列进程需要维护按消息 ID 索引的磁盘文件结构,大量消息会导致 Erlang 进程 GC 压力增大和 I/O 放大。
|
||||
|
||||
**仲裁队列(Quorum Queue)**基于 Raft 共识协议,每个队列在多个节点上维护副本(奇数个,默认 3 个)。消息写入需要多数节点确认,天然保证了数据安全。底层使用 WAL(Write-Ahead Log)顺序写盘,性能在大量消息堆积时保持稳定。
|
||||
**仲裁队列(Quorum Queue)**基于 Raft 共识协议,每个队列在多个节点上维护副本(奇数个,默认 3 个)。消息写入需要多数派(majority)确认后才返回成功,天然保证了数据安全。底层使用 **WAL(Write-Ahead Log)** 顺序写盘——所有队列共享同一个 WAL 文件,避免了经典队列的随机 I/O 问题。消息被消费并 ACK 后,通过 Raft 快照(snapshot)机制定期清理已确认的记录,性能在大量消息堆积时保持稳定。
|
||||
|
||||
**流式队列(Stream)**是 3.9 引入的新类型,借鉴了 Kafka 的设计思想。消息以 append-only log 形式存储,支持多消费者独立读取(非竞争消费),消息不会被消费后删除,支持回溯重放。适合事件驱动、审计日志等场景。
|
||||
**流式队列(Stream)**是 3.9 引入的新类型,借鉴了 Kafka 的设计思想。消息以 **append-only log**(段文件,segment file)形式存储在磁盘上,每个 segment 有固定大小,写满后滚动到下一个。支持多消费者独立读取(非竞争消费),各消费者维护自己的 offset,消息不会被消费后删除,支持按时间或 offset 回溯重放。适合事件驱动、审计日志等场景。
|
||||
|
||||
| 特性 | Classic Queue | Quorum Queue | Stream |
|
||||
|------|:---:|:---:|:---:|
|
||||
| 复制 | 不支持 | Raft 多副本 | 可选 |
|
||||
| 复制 | 不支持 | Raft 多副本 | Leader-Follower |
|
||||
| 持久化 | 可选 | 必须 | 必须 |
|
||||
| 消费后删除 | 是 | 是 | 否 |
|
||||
| 消息回溯 | 不支持 | 不支持 | 支持 |
|
||||
@@ -83,7 +90,7 @@ RabbitMQ 的内存管理是一个三层防护体系:
|
||||
|
||||
**流控(Flow Control)**:当某个 Erlang 进程(如队列进程)的消息积压超过一定阈值,会主动向 TCP 连接进程发送 `pause` 信号,让生产者的 TCP 接收窗口降为 0,从而在协议层面实现背压。
|
||||
|
||||
**信用机制(Credit Flow)**:这是 Erlang 进程间的流控机制。每个进程维护一个 credit 计数器,消息传递消耗 credit,消费端处理完毕后归还 credit。当 credit 归零时,发送端阻塞,防止过快地往下游进程灌消息。
|
||||
**信用机制(Credit Flow)**:这是 Erlang 进程间的流控机制。每个进程维护一个 credit 计数器(初始值默认 200,称为 `credit_flow_default_credit`),每传递一条消息消耗 1 个 credit,消费端处理完毕后归还 credit(默认每处理一条归还 1 个)。当 credit 归零时,发送端进程被阻塞(blocked),不再向下游发送,直到收到下游归还的 credit 重新恢复。这个机制保证了 Erlang 进程间的"管道"不会溢出。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -103,44 +110,84 @@ graph TD
|
||||
|
||||
消息从进入 RabbitMQ 到最终消失,经历以下阶段:
|
||||
|
||||
1. **写入内存**:消息到达队列进程后,先存入进程内存(Erlang ETS 表或进程状态)。
|
||||
2. **写入磁盘**(如果 persistent):当内存压力达到一定阈值,或消息被标记为 persistent 时,消息体被写入磁盘文件。
|
||||
3. **投递消费者**:消息从内存中读取并发送给消费者。
|
||||
4. **等待 ACK**:消息在未收到 ACK 前不会被删除。
|
||||
1. **写入内存**:消息到达队列进程后,先存入进程内存(ETS 表)。
|
||||
2. **写入磁盘**(如果 persistent):持久化消息被立即提交给 persister 进程异步写入磁盘文件;非持久化消息仅保留在内存中。
|
||||
3. **投递消费者**:消息从内存中读取并发送给消费者。此时消息被标记为 "unacked",不会重复投递给其他消费者。
|
||||
4. **等待 ACK**:消息在未收到 ACK 前不会被删除。如果消费者断开连接,unacked 消息会被重新入队。
|
||||
5. **收到 ACK 后删除**:消息从内存和磁盘中移除。对于持久化消息,磁盘文件中的标记被清除,空间在文件 compaction 时回收。
|
||||
|
||||
### 7. Go 代码示例
|
||||
### 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, _ := amqp.Dial("amqp://guest:guest@localhost:5672/")
|
||||
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
|
||||
if err != nil {
|
||||
log.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ch, _ := conn.Channel()
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("打开通道失败: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
// 声明持久化队列:durable = true, autoDelete = false, exclusive = false
|
||||
ch.QueueDeclare("order_events", true, false, false, false, amqp.Table{
|
||||
"x-queue-type": "quorum", // 使用仲裁队列,替代默认的经典队列
|
||||
// 使用仲裁队列(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
|
||||
ch.Publish("", "order_events", false, false, amqp.Publishing{
|
||||
err = ch.Publish("", "order_events", false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent, // 消息持久化
|
||||
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]]
|
||||
|
||||
Reference in New Issue
Block a user