跳到主要内容

消息如何存储?并非在内存中!

·14 分钟阅读

是时候摒弃 RabbitMQ 将消息存储在内存中的神话了。虽然这在 RabbitMQ 的早期是事实,并且在过去 10 年中也是一种选择,但现代 RabbitMQ 版本几乎总是立即将消息写入磁盘。在这篇博文中,我们将回顾不同队列类型如何存储消息,但简短的答案是:并非在内存中!

让我们首先澄清一下我们所说的“将消息存储在内存中”是什么意思,因为它不是一个精确的说法。如果理解为“RabbitMQ 使用内存来处理消息”,那么这句话肯定是正确的。当客户端应用程序将数据发送到 RabbitMQ 时,该数据首先出现在内存缓冲区中(对于所有基于网络的软件来说都是如此)。同样,RabbitMQ 可能会将一些消息缓存在内存中,例如为了提高性能(同样,对于几乎所有需要服务数据的软件来说都是如此)。

但是,我一直听到这句话被用来表达对消息持久性的担忧——如果您关闭服务器电源,您的消息将丢失!在这种情况下,现代 RabbitMQ 版本几乎从不将消息存储在内存中。

重要提示

没有任何配置可以将 1GB 的消息发布到没有连接消费者的 RabbitMQ,会导致使用 1GB 的内存来存储这些消息。消息的某些子集可以缓存在内存中,但消息存储在磁盘上。

我们将介绍不同的队列类型,并讨论消息如何被处理、存储,以及何时向发布者确认。发布者确认在这里至关重要——RabbitMQ 不为未确认的消息提供保证。如果您没有收到确认,您甚至无法知道消息是否到达了 RabbitMQ(例如,网络连接可能已失败)。考虑到这一点,让我们来看看不同的队列类型。

经典队列

经典队列是 RabbitMQ 中最古老的队列类型,也是“RabbitMQ 将消息存储在内存中”这种误解的主要来源。RabbitMQ 最初于 2007 年发布。当时的磁盘速度明显较慢,因此经典队列的设计初衷是尽量避免将消息写入磁盘。当时有很多设置可以配置 RabbitMQ 何时应将消息写入磁盘(称为“分页”的过程),这样它就不会无限期地将消息保存在内存中,但实际上,你可以说 RabbitMQ 当时确实将消息存储在内存中。然而,那已经是很久以前的事了。

在 2015 年发布的 RabbitMQ 3.6 中,引入了“惰性模式”。配置为惰性的队列始终将消息存储在磁盘上,并且根本不会将其保存在内存中。这意味着“RabbitMQ 将消息存储在内存中”在 10 年前就不是真的了。它仍然会默认这样做,但这完全是可选的。

惰性模式在 2023 年发布的 RabbitMQ 3.12 中被移除,但默认(也是唯一可用的)行为发生了变化,并且类似于惰性模式,尽管并不完全相同。因此,一年多以来,经典队列不再将消息存储在内存中,甚至无法配置为这样做。这个神话在这一点上几乎完全是错误的。

几乎完全?以下是经典队列现在的工作方式:它们将传入的消息累积在一个小的内存缓冲区中,并在内存缓冲区满后立即将它们批量写入磁盘。由于我们不知道是否会有更多消息到来,因此还有其他触发器会刷新该缓冲区,包括在一定数量的批量消息之后刷新(即使它们太小而无法使用整个缓冲区)以及在该批次上执行一定数量的操作之后刷新。此外,队列会监控消息被消费的速度,并据此做出决策(如果有快速的消费者,则会在内存中缓存更多消息)。最后,还有最后的触发器,它将每 200 毫秒刷新缓冲区。因此,消息可以在内存中存储的最长时间为 200 毫秒,但在实践中,我从未见过这种情况发生。发布者通常会在几毫秒内收到确认,并且只有在消息写入磁盘后才会发送确认。

但是当我使用经典队列时,我没有看到任何磁盘活动!

的确,完全有可能将消息发布到经典队列,但几乎看不到任何磁盘写入或磁盘读取。这怎么可能呢?这是一种针对非常特殊但相对常见的用例的优化。如上所述,消息可以短暂地保存在内存中,但是如果有正在等待消息的活跃消费者(它们的预取缓冲区未满),则到达队列的消息会立即分发给消费者,而无需等待它们的批次写入磁盘。在消息的批次写入磁盘之前被消费者确认的消息将根本不会写入磁盘,因为它根本不需要写入。队列不存储已确认的消息,因此如果在消息写入之前被确认,则不会写入。如果消息在写入后被确认,则会从队列中删除(从磁盘中实际删除将在稍后异步发生,但它被视为立即删除)。

值得一提的是,经典队列有两种独立的存储机制。小于 4kb 的消息(可通过 queue_index_embed_msgs_below 配置)存储在每个队列的消息存储中,而高于该阈值的消息存储在每个 vhost 的消息存储中。上面提到的优化仅适用于将存储在每个队列的消息存储中的消息。

所以这就是它,在现代 RabbitMQ 版本中,经典队列将消息在内存中存储非常短的时间(毫秒),并且肯定不超过 200 毫秒。如果消息很小并且消费速度足够快,它们可能根本不会将消息写入磁盘,但这只是一种性能优化。我将由您来决定这是否符合“RabbitMQ 将消息存储在内存中”,但我认为更准确的说法是“当消息传递到经典队列时,RabbitMQ 会在短暂延迟后将消息写入磁盘”。但是,是的,这意味着在短暂的时刻,它们仅存在于内存中。

当然,瞬时消息是存储在内存中的吗?

不是的。同样,过去的情况有所不同,但从 RabbitMQ 4.0 开始,持久消息和瞬时消息之间的唯一区别是 RabbitMQ 何时发回发布者确认。消息的存储方式与上述相同。

对于持久消息,当以下两个事件之一发生时,会发送确认

  1. 消息已写入磁盘
  2. 消息已交付并被消费者确认(如果在写入磁盘之前发生)

对于瞬时消息,确认会在消息到达队列并进入内存缓冲区后立即发送。由于消息是瞬时的,因此保证是宽松的:队列已收到消息,发布者可以继续进行。

关于 fsync 呢?

fsync 是一种低级文件系统操作,应确保消息真正写入磁盘。在用户空间进程(如 RabbitMQ)和实际硬件之间存在多层 I/O 缓冲区,包括操作系统缓冲区和内部磁盘缓冲区。执行写入而不执行 fsync 并不能保证数据在突然断电后能够幸存下来。不幸的是,fsync 是一种相对缓慢的操作,因此任何 I/O 密集型软件都必须决定是否以及何时调用它。虽然经典队列在某些情况下会调用 fsync(例如,当 RabbitMQ 正常停止时),但在发送发布者确认之前不会执行 fsync。因此,即使发布者收到确认的持久消息,如果服务器崩溃,也可能在技术上丢失。如果您需要更强的保证,可以使用仲裁队列

仲裁队列

从 RabbitMQ 3.8(2019 年发布)的初始版本开始,仲裁队列始终将消息存储在磁盘上。虽然初始版本为消息添加了一个额外的内存缓存,但它在 RabbitMQ 3.10 中被移除。

因此,情况很简单:如果发布者收到确认,则意味着消息已写入磁盘并在仲裁节点上进行了 fsync 操作(在最常见的 3 节点集群场景中,这意味着它已在至少 2 个节点上写入和 fsync)。

由于 RabbitMQ 不为未确认给发布者的消息提供任何保证,因此我们几乎可以在这里停止。但是,为了完整起见,我将提到一些消息在技术上是在内存中的

  1. 队列进程有一个邮箱(Erlang/OTP 概念),其中到达队列进程的请求(例如入队/出队操作)以进行处理。仲裁队列进程从邮箱接收消息并批量处理它们。当有很多请求时,这些操作可能会在邮箱中累积,因此,假设那里有入队操作,此时,某些消息仅在内存中。但是,这通常意味着 RabbitMQ 至少暂时过载,并且无论如何,这些操作通常在几毫秒内处理完毕。此外,这些消息尚未确认。
  2. 仲裁队列依赖于 Raft 协议和我们的 Raft 实现,它们将最新的 Raft 操作存储在内存中。对于入队操作,这意味着消息也在内存中。但是,此时消息已写入磁盘并进行了 fsync 操作,或者尚未确认。

对于,情况甚至比仲裁队列更简单:流从不支持将消息保存在内存中,一直如此。队列和流之间的主要区别在于,流可以被多次读取,因此消费消息不会从流中删除该消息。如果我们需要能够多次将消息传递给消费者,并且可能在发布后很长时间,那么仅将消息存储在内存中是没有意义的。

流不执行 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 需要跟踪队列中所有消息的总大小,因此当它传递消息时,它会将消息大小(而不是消息体本身)保存在内存中,以便在消费者确认消息后快速从队列的总大小中减去它。

这种元数据由不同的队列类型以不同的方式存储,但即使存储在内存中,它消耗的内存也将远少于消息体,并且不会改变有关消息持久性的任何保证。

以下是我们如何存储不同队列类型的元数据

  • 经典队列
    • 对于存储在每个队列的消息存储中的消息,不存储任何数据在内存中
    • 对于存储在每个 vhost 的消息存储中的消息,内存中存在一些元数据
  • 仲裁队列
    • 元数据存储在内存中(每个消息至少 32 字节,有时更多一点,例如当使用消息 TTL 时)
    • 不存储任何消息元数据在内存中

这基本上意味着,对于存储在经典队列中的小于 4KB 的消息,以及对于流,无论队列/流中有多少消息,内存使用量都是恒定的。您将在内存耗尽之前耗尽磁盘空间(您应该配置保留/长度限制以避免耗尽磁盘空间,但那是另一个故事)。

这是一个图示,突出了两种经典队列存储机制之间的区别。在这个测试中,我首先发布了 100 万条每条 4000 字节的消息,然后删除了队列并发布了 100 万条每条 4100 字节的消息。如您所见,在第一阶段,内存使用率是稳定的(尽管有小的波动),但是当发布更大的消息时,我们可以看到内存使用率也在增长。这是因为 4100 字节高于阈值,因此这些消息存储在每个 vhost 的消息存储中,而每个 vhost 的消息存储在内存中保留一些元数据。一百万条 4KB 的消息将占用 4GB 的内存来存储,而实际使用量仍然低于 400MB。

Classic Queues: memory usage when publishing small and large messages
经典队列:发布小消息和大消息时的内存使用情况

总结

以下是关键点的总结。

类型消息何时写入磁盘?fsync?发布者确认何时发送?
经典几毫秒后或内存缓冲区满后立即写入磁盘(以先发生者为准)持久消息:当消息写入磁盘或被消费和确认时
瞬时消息:在内存中批量处理后立即发送
仲裁立即(除了邮箱中等待的未确认消息,详情请参见上文)当由仲裁节点(最常见的是 3 个节点中的 2 个)写入磁盘并进行 fsync 操作时
立即(除了邮箱中等待的未确认消息,详情请参见上文)当由仲裁节点(最常见的是 3 个节点中的 2 个)写入磁盘时

RabbitMQ 提供的灵活性,支持多种协议、队列类型和其他配置(例如,单节点与具有队列复制的集群),加上 18 年的历史和发展,意味着几乎任何“RabbitMQ 执行/不执行 X”的说法都是不正确的,或者至少是不精确的。它们几乎总是应该用特定的版本和配置来量化。

回到这篇文章的标题,我认为说“RabbitMQ 不将消息存储在内存中”比仍然在涉及 RabbitMQ 的讨论中流传的相反说法更接近事实。无论队列类型如何,都没有任何配置可以将,比如说,1GB 的消息发布到没有连接消费者的 RabbitMQ,会导致使用 1GB 的内存来存储这些消息。最重要的是,如果您想要高数据安全保证,仲裁队列可用,并且默认情况下安全地存储数据。如果您将消息发布到仲裁队列并收到确认,那么 RabbitMQ 需要发生灾难性事件才会丢失它(如果您想保护消息免受灾难性事件的影响,您可能会对商业Warm Standby Replication 插件感兴趣)。

如果您不需要如此高的数据安全保证,则不必支付数据安全性的内在开销。只需为工作选择合适的工具即可。

© . All rights reserved.