跳至主内容

队列的性能:少即是多

·9 分钟阅读
Matthew Sackman

自从 RabbitMQ 2.0.0 中引入了新的持久化机制(是的,它已经不算那么了)以来,RabbitMQ 在处理不断增长、大到无法完全存储在内存中的队列方面,一直表现得相当不错。RabbitMQ 会尽早开始将消息写入磁盘,并以温和的速率持续进行,这样当内存变得非常紧张时,我们已经完成了大部分繁重的工作,从而避免了突然的写入高峰。只要您的消息速率不是太高或太不稳定,这一切都应该能顺利进行,而不会对任何连接的客户端造成实际影响。

最近与一位客户的讨论促使我们重新审视了一个我们认为已经相对解决的问题,并促使我们做出了一些改变。

首先,我们花了一些时间来更好地理解消息的 CPU 使用量如何随着队列变长并移至磁盘而变化,以及这对性能的影响。结论并不一定显而易见。然后,我们开始思考队列在从纯内存队列转变为纯磁盘队列的过程中所做的某些决定的依据。

RabbitMQ 中的 AMQP 队列并不是一个简单的功能性 FIFO 队列。实际上,每个 AMQP 队列内部使用至少四个“队列”,每个队列都允许以各种不同的状态存储消息。这些状态包括:消息是否存储在内存中(无论它是否另外已写入磁盘);消息本身是否在磁盘上,但其在队列中的位置仍然只存储在内存中?诸如此类。AMQP 队列中的每条消息在任何给定时间只会出现在其中一个内部队列中,但如果其状态发生变化,它们可以在队列之间移动(尽管在这些内部队列之间的移动会遵守 AMQP 队列中消息的整体顺序)。还有一个第五个“队列”,它实际上并不是一个队列。它更像是一对数字,用于指示仅存储在磁盘上的消息的范围(如果您愿意,这些是指向仅存储在磁盘上的“队列”的头部和尾部的指针)。理论上,这种形式的消息的内存成本为零(这取决于您如何计算(数字也需要内存!),而在 RabbitMQ 的其他地方,您很可能最多只能节省每条消息几字节的内存)。完整的细节可以在variable_queue 模块顶部的文章中找到。它并没有那么吓人,但也不是非常简单。棘手的部分是如何决定哪些消息应该处于哪种状态,以及何时,我将在本文中不详细介绍这些决定。

一条被第五个“队列”捕获的消息,要从这种纯磁盘形式完全恢复为纯内存消息,可能需要两次读取。这是因为每条消息都有一个消息 ID(它是随机的、无序的,并在每条消息到达任何队列之前分配给它,因此对于确定 AMQP 队列内的相对位置没有用),并且在每个 AMQP 队列内,每条消息都通过其每个队列的序列 ID 来标识,该 ID 强制执行 AMQP 队列内消息的相对顺序。可以将这个第五个“队列”视为从序列 ID 到消息 ID 的映射(加上一些每条消息每队列的状态),然后您可以使用不同的子系统将该消息 ID 转换为实际消息。

由于这两次读取(尽管我们的结构是,其中一次读取在 16k 条消息之间共享,因此每条消息的读取次数更接近 1 + (1/16384) 次,至少默认情况下是这样),我们之前曾试图避免使用这个第五个“队列”:过去,即使在内存非常低的情况下,我们也会将消息完全写入磁盘,但仍然保留内存中的记录(尽管此时每条消息的记录相当小),假设这以后会带来优势:是的,它会消耗更多内存,但如果其他某个大型 AMQP 队列突然被删除并释放大量内存,那么通过保留这个每条消息的相对较小的记录,我们可以避免进行两次读取才能从第五个“队列”恢复到完整消息,而只需进行一次读取。只有当内存完全耗尽时,我们才会突然将(几乎)所有内容转储到这个第五个“队列”(尽管此时,一切都已在磁盘上,所以这或多或少是无操作——我们只是在这次转换中释放内存)。

然而,由于至少一次读取的有效摊销,使用第五个“队列”的成本并不像我们担心的那么高。此外,如果您更早开始使用它,队列在内存中的增长速度会变慢:当消息处于第五个“队列”时,每条消息的内存成本最低,因此您对这个“队列”的使用越多,您的队列消耗内存的速度就越慢。这本身有助于 RabbitMQ 稳定过渡到纯磁盘操作(在相同的消息增长速率下,较低的内存增长速率将导致较低的磁盘操作速率)。

因此,我们改变了 RabbitMQ AMQP 队列的行为,以更积极地使用这个第五个“队列”。基准测试表明,这似乎导致 AMQP 队列的内存使用量在其增长的早期阶段趋于平缓,并且实际上似乎使 RabbitMQ 能够更快地将大型 AMQP 队列中的消息传递给消费者(可能是因为通过限制其他四个内部队列的大小,避免了一些效率非常低的操作(例如连接两个功能队列(Erlang 的默认队列模块在此处执行简单的追加 (++),这很昂贵)),从而有更多的 CPU 可用于驱动消费者)。缺点是队列现在花费更多时间进行读取,但这似乎被较低的每条消息使用的 jiffies 所抵消。

下面是一张图表。这非常令人兴奋——不仅仅因为我的大多数博客文章都是冗长的文字。它展示了同一测试程序的三个运行。该测试程序执行以下操作:

  1. 它创建 3 个队列。
  2. 它将这 3 个队列绑定到一个扇出交换器。
  3. 然后,它开始以每秒 600 条消息的速度向该交换器发布 200 字节的消息。
  4. 在最初的 120 秒内,每个队列有 20 个消费者进行非自动确认的消费,每条消息确认一次,QoS 预取值为 1。众所周知,这是一种非常昂贵的消费消息的方式。此外,确认是故意延迟的,因此,忽略网络延迟,最大的聚合消费速率将为每秒 1200 条消息。
  5. 120 秒后,停止消费者,并且在总积压量达到 500,000 条消息之前(即每个队列大约有 166,000 条消息)不会重新启动它们。
  6. 之后,消费者恢复如前,您希望队列能够处理继续发布的消息并将积压的消息传递给消费者。希望所有队列最终都会再次变空。

现在,根据您的 CPU、内存、网络延迟和high_watermark 设置,此积压可能纯粹在内存中,因此永远不会进行磁盘操作;或者它可能纯粹在磁盘上;或者介于两者之间。我们办公室的台式机通常过于强大,以至于此测试不会造成任何问题(积压总是会清除),但在某些 EC2 主机上,使用旧版本的 Erlang 和旧版本的 Rabbit,有可能达到积压永远不会清除,反而会增长的地步。

在下面的图表中,我们在m1.large EC2 实例上成功运行了三次测试,测试是在独立的 EC2 实例上进行的(也就是说,我们确实是通过网络进行的)。这些实例运行的是 Ubuntu 映像,但安装了本地编译的 Erlang R14B04。三次运行是:1) 在此工作合并之前的默认分支;2) 在此工作合并后的默认分支;3) 2.6.1 版本。

自 2.6.1 版本发布以来,已经进行了大量性能优化,这体现在积压消息消失的速度更快。但是,“更改前的默认”和“2.6.1”的内存使用情况相当相似,而“更改后的默认”平均内存使用量较低。然而,内存测量结果并不特别令人信服,因为 Erlang 是一种自动垃圾回收语言,内部内存效率的提高并不总是会导致 VM 请求更少的 RAM 或更快地将 RAM 返回给操作系统。更令人信服的是积压消息的消除速度更快以及累积的 jiffies 更低:即使“更改后的默认”每秒处理的消息比其他任何一次运行都多,但它每秒使用的 jiffies 仍然比其他运行少。

希望这项更改能够为许多场景下的许多用户带来改进。有可能在某些用例中它的性能会更差——我们当然不能排除这种可能性。软件工程中没有一个值得解决的问题只有一个正确的解决方案。这种情况表明,有时,使用更少的内存和执行更多的磁盘操作实际上可以使整体性能更快。

© . This site is unofficial and not affiliated with VMware.