消息是如何存储的?不在内存中!
是时候打破“RabbitMQ 将消息存储在内存中”的迷思了。虽然这在 RabbitMQ 的早期是事实,并且在过去十年中一直是一个选项,但现代 RabbitMQ 版本几乎总是会立即将消息写入磁盘。在这篇博文中,我们将回顾不同类型的队列如何存储消息,但简短的答案是:不在内存中!
让我们先澄清一下“将消息存储在内存中”这一说法的含义,因为它并不够精确。如果将其解释为“RabbitMQ 使用内存来处理消息”,那么这句话绝对正确。当客户端应用程序将数据发送到 RabbitMQ 时,这些数据首先出现在内存缓冲区中(所有基于网络的软件都是如此)。同样正确的是,RabbitMQ 可能会在内存中缓存一些消息,例如为了提高性能(这同样适用于几乎所有需要提供数据的软件)。
然而,我经常听到有人用这句话来表达对消息持久性的担忧——如果服务器断电,消息就会丢失!在这种语境下,现代 RabbitMQ 版本几乎从不将消息存储在内存中。
没有任何配置会让向 RabbitMQ 发布 1GB 消息(且没有连接消费者)导致 1GB 的内存被用于存储这些消息。部分消息可能会被缓存在内存中,但消息最终是存储在磁盘上的。
我们将详细介绍不同的队列类型,并探讨消息是如何被处理、存储以及何时向发布者确认的。发布者确认(Publisher confirmations)在这里至关重要——RabbitMQ 不会对未确认的消息提供保证。如果你没有收到确认,你甚至无法知道消息是否到达了 RabbitMQ(例如,网络连接可能已中断)。考虑到这一点,让我们来看看不同的队列类型。
经典队列
经典队列(Classic queues)是 RabbitMQ 中最古老的队列类型,也是“RabbitMQ 将消息存储在内存中”这一误解的主要来源。RabbitMQ 最初发布于 2007 年。当时磁盘速度非常慢,因此经典队列的设计初衷是尽量避免将消息写入磁盘。当时有许多设置用来配置 RabbitMQ 何时将消息写入磁盘(这一过程称为“分页/paging”),这样它就不会无限期地将消息保存在内存中。确实,你可以说当时的 RabbitMQ 将消息存储在内存中。然而,那已经是很久以前的事了。
2015 年发布的 RabbitMQ 3.6 引入了“惰性模式(lazy mode)”。配置为惰性的队列总是将消息存储在磁盘上,根本不会将它们保存在内存中。这意味着“RabbitMQ 将消息存储在内存中”的说法在 10 年前就已经不成立了。虽然它在默认情况下仍会这样做,但这完全是可选的。
2023 年发布的 RabbitMQ 3.12 移除了惰性模式,但默认(且唯一可用)的行为发生了变化,类似于惰性模式,尽管不完全相同。因此,一年多以来,经典队列不再将消息存储在内存中,甚至无法配置为这样做。这个误解在目前看来几乎完全是错误的。
“几乎”完全错误?以下是经典队列目前的工作方式:它们将传入的消息累积在一个小型内存缓冲区中,一旦缓冲区满了,就将其批量写入磁盘。由于我们不知道后续是否会有更多消息,因此还有其他触发器来刷新缓冲区,包括在达到一定数量的批量消息后(即使它们太小而无法占满整个缓冲区)刷新,以及在对该批次执行一定数量的操作后刷新。此外,队列会监控消息的消费速度并据此做出决定(如果消费者处理速度快,更多的消息会被缓存在内存中)。最后,还有一个最后的触发器,每 200 毫秒强制刷新一次缓冲区。因此,消息在内存中存储的时间绝对上限是 200 毫秒,但在实践中,我从未见过这种情况发生。发布者通常在几毫秒内收到确认,而且确认只有在消息写入磁盘后才会发送。
但是我使用经典队列时没看到磁盘活动!
确实,完全可能向经典队列发布消息却几乎看不到任何磁盘写入或读取。这怎么可能呢?这是针对一种非常特定但相对常见的情况所做的优化。如上所述,消息可以短暂保存在内存中,但如果有活动的消费者正在等待消息(它们的预取缓冲区未满),到达队列的消息会立即分发给消费者,而无需等待批次写入磁盘。如果消息在批次写入磁盘之前就被消费者确认,它将根本不会被写入磁盘,因为它没有必要被写入。队列不会存储已确认的消息,因此如果消息在写入前被确认,它就不会被写入。如果它在写入后被确认,它会从队列中删除(从磁盘实际的移除会在稍后异步发生,但它会被视为立即删除)。
值得一提的是,经典队列拥有两种独立的存储机制。小于 4kb 的消息(可通过 queue_index_embed_msgs_below 配置)存储在每个队列的消息存储中,而超过该阈值的消息则存储在每个虚拟主机(vhost)的消息存储中。上述优化仅适用于将存储在每个队列消息存储中的消息。
总之,在现代 RabbitMQ 版本中,经典队列仅将消息在内存中存储极短的时间(毫秒级),绝对不会超过 200 毫秒。如果消息很小且消费速度足够快,它们可能根本不会写入磁盘,但这仅仅是一种性能优化。至于这是否能被称为“RabbitMQ 将消息存储在内存中”,由你自己决定。但我认为更准确的说法是:“当消息被传送到经典队列时,RabbitMQ 会在短暂延迟后将消息写入磁盘”。但确实,这意味着在短暂的一瞬间,它们只存在于内存中。
那么,瞬态消息(Transient Messages)肯定是存储在内存中的吧?
不。同样,过去情况有所不同,但从 RabbitMQ 4.0 开始,持久消息和瞬态消息之间的唯一区别在于 RabbitMQ 何时返回发布者确认。消息的存储方式与上述相同。
对于持久消息,当以下两个事件之一发生时发送确认:
- 消息已被写入磁盘
- 消息已被消费者接收并确认(如果这发生在写入磁盘之前)
对于瞬态消息,确认会在消息到达队列并进入内存缓冲区时立即发送。由于消息是瞬态的,保证较为宽松:队列收到了消息,发布者可以继续后续操作。
那么 fsync 呢?
fsync 是一种低级文件系统操作,旨在确保消息真正写入磁盘。在像 RabbitMQ 这样的用户空间进程和实际硬件之间,存在多层 I/O 缓冲区,包括操作系统缓冲区和内部磁盘缓冲区。在不执行 fsync 的情况下进行写入,不能保证数据在突然断电时依然存活。遗憾的是,fsync 是一个相对较慢的操作,因此任何 I/O 密集型软件都必须决定是否以及何时调用它。虽然经典队列在某些情况下会调用 fsync(例如当 RabbitMQ 优雅停止时),但在发送发布者确认之前并不会执行 fsync。因此,即使是发布者已收到确认的持久消息,如果服务器崩溃,从技术上讲也可能会丢失。如果你需要更强的保证,可以使用仲裁队列(quorum queues)。
仲裁队列
从 RabbitMQ 3.8(2019 年发布)首次发布起,仲裁队列就始终将消息存储在磁盘上。虽然最初的版本有一个额外的内存缓存用于消息,但该功能已在 RabbitMQ 3.10 中移除。
因此情况很简单:如果发布者收到了确认,这意味着消息已经写入磁盘,并在仲裁节点集上完成了 fsync(在 3 节点集群的最常见场景中,意味着它在至少 2 个节点上被写入并完成了 fsync)。
由于 RabbitMQ 不对未向发布者确认的消息提供任何保证,我们几乎可以到此为止。不过,为了完整起见,我提一下有些消息在技术上确实在内存中:
- 队列进程有一个邮箱(Erlang/OTP 的概念),队列进程的处理请求(如入队/出队操作)会到达这里进行处理。仲裁队列进程从邮箱接收消息并批量处理它们。当请求很多时,这些操作可能会积压在邮箱中,因此,假设那里有入队操作,此时某些消息确实只在内存中。然而,这通常意味着 RabbitMQ 至少处于短时间的过载状态,而且无论如何,这些操作通常会在几毫秒内处理完毕。此外,这些消息尚未得到确认。
- 仲裁队列依赖于 Raft 协议,我们的 Raft 实现将最新的 Raft 操作存储在内存中。对于入队操作,这意味着消息也位于内存中。然而,此时消息要么已经写入磁盘并完成了
fsync,要么尚未得到确认。
流
对于流(Streams),情况比仲裁队列更简单:流从不支持将消息保存在内存中,就是这样。队列和流的主要区别在于,流可以被多次读取,因此消费消息不会从流中删除该消息。如果我们需要能够在消息发布很久之后将其多次分发给消费者,那么仅仅将消息存储在内存中是毫无意义的。
流不执行 fsync,因为它们针对高消息吞吐量进行了优化。
为了完整起见,就像仲裁队列(以及任何其他 Erlang 进程)一样,流进程有一个邮箱,请求会到达那里。因此,确实有一个瞬间消息会短暂地存储在内存中。但同样,这些都是尚未得到确认的消息,它们很少在内存中停留超过几毫秒。
MQTT QoS 0 队列
RabbitMQ 3.12 引入了原生 MQTT 支持,作为该工作的一部分,引入了一种新的队列类型,专门用于 MQTT QoS 0 消费者(你不能显式声明这种类型的队列,你必须创建 MQTT QoS 0 订阅)。由于 QoS 0 基本意味着尽力而为但不提供保证,因此 QoS 0 消息根本不会写入磁盘,而是直接分发给当前存在的消费者。实际上,根本没有队列(除了 Erlang 邮箱)。从发布者收到的消息会立即分发给消费者并从内存中删除。
这能算作将消息存储在内存中吗?我认为不算——消息最初在内存中,仅仅是因为计算机的工作原理如此,而且它们一旦分发给消费者就会从内存中删除。在这种情况下,我们并没有真正将它们存储在内存中,我们只是处理它们而从不将它们写入磁盘。你可以不同意并说这正是“在内存中存储消息”的意思,但即便如此,这仅适用于 MQTT QoS 0 用例,且消息通常在内存中的停留时间不会超过十分之一秒。
消息元数据
到目前为止,我一直专注于消息体,因为这就是人们在谈论“将消息存储在内存中”时通常的意思。然而,RabbitMQ 还需要跟踪当前队列中存在的消息。例如,当队列定义了 x-max-length 限制时,RabbitMQ 需要跟踪队列中所有消息的总大小,因此当它分发消息时,它会将消息大小(而不是消息体本身)保存在内存中,以便在消费者确认消息后,快速从队列总大小中扣除。
这种元数据在不同队列类型中的存储方式不同,但即使存储在内存中,它消耗的内存也远小于消息体,且不会改变任何关于消息持久性的保证。
以下是我们如何为不同的队列类型存储元数据:
- 经典队列
- 对于存储在每个队列消息存储中的消息,内存中不存储任何数据
- 对于存储在每个虚拟主机消息存储中的消息,内存中有一些元数据
- Quorum Queues
- 元数据存储在内存中(每条消息至少 32 字节,有时更多,例如使用消息 TTL 时)
- Streams
- 不存储任何消息元数据
这基本意味着对于经典队列中 4KB 以下的消息,以及对于流,无论队列/流中有多少条消息,内存占用都是恒定的。你会先耗尽磁盘空间而不是内存(你应该配置保留策略/长度限制以避免耗尽磁盘空间,但那是另一回事了)。
这是一个说明两种经典队列存储机制之间差异的图示。在此测试中,我首先发布了 100 万条 4000 字节的消息,然后删除了队列,并发布了 100 万条 4100 字节的消息。如你所见,第一阶段内存使用率保持稳定(尽管有细微波动),但在发布较大消息时,我们可以看到内存使用率也在增长。这是因为 4100 字节超过了阈值,所以这些消息被存储在每个虚拟主机的消息存储中,而该存储会在内存中保留一些元数据。一百万条 4KB 消息本应占用 4GB 内存,而实际使用量仍低于 400MB。

总结
以下是关键点的总结。
| 类型 | 消息何时写入磁盘? | fsync? | 何时发送发布者确认? |
|---|---|---|---|
| 经典 | 几毫秒后,或内存缓冲区已满时(以先到者为准) | 否 | 持久消息:当消息写入磁盘或被消费并确认时 瞬态消息:一旦在内存中分批即发送 |
| 仲裁 | 立即(除了在邮箱中等待的未确认消息,详情见上文) | 是 | 当由仲裁节点写入磁盘并完成 fsync 时(通常为 3 个节点中的 2 个) |
| Streams | 立即(除了在邮箱中等待的未确认消息,详情见上文) | 否 | 当由仲裁节点写入磁盘时(通常为 3 个节点中的 2 个) |
RabbitMQ 提供的灵活性(支持多种协议、队列类型和其他配置,例如单节点 vs 集群队列复制),结合 18 年的历史和演进,意味着几乎所有“RabbitMQ 执行/不执行 X”的陈述都是不正确的,或者至少是不精确的。它们几乎总是应该限定在特定的版本和配置下。
回到这篇文章的标题,我认为说“RabbitMQ 不会将消息存储在内存中”比相反的说法更接近事实,而那种说法仍在涉及 RabbitMQ 的讨论中流传。无论队列类型如何,没有任何配置会让向 RabbitMQ 发布 1GB 消息(且没有连接消费者)导致 1GB 内存被用于存储这些消息。最重要的是,如果你需要高数据安全保证,仲裁队列可用,并且默认情况下可以安全地存储数据。如果你向仲裁队列发布消息并收到确认,RabbitMQ 除非发生灾难性事件否则不会丢失它(如果你想保护消息免受灾难性事件影响,你可能对商业版的温备复制插件感兴趣)。
如果你不需要这种数据安全保证,就不必承担数据安全带来的内在开销。只需为工作选择合适的工具即可。
