队列性能:少即是多
自从新的持久化器在 RabbitMQ 2.0.0 中出现以来(是的,它已经不那么新了),Rabbit 在处理不断增长并达到无法保存在 RAM 中的队列方面,已经有了一个相对不错的故事。Rabbit 相当早地开始将消息写入磁盘,并以平缓的速度持续这样做,以便在 RAM 真正变得紧张时,我们已经完成了大部分的困难工作,从而避免了突发的写入。如果您的消息速率不是太高或太突发,那么这一切都应该在对任何连接的客户端没有任何实际影响的情况下发生。
最近与一位客户的讨论使我们重新审视了我们认为已经相当解决的问题,并促使我们做出一些改变。
首先,我们花了一些时间来更好地理解,当队列变长并转到磁盘时,每个消息的 CPU 使用率是如何变化的,以及这会产生什么影响。结论不一定显而易见。然后我们开始思考,在队列从纯内存队列转变为纯磁盘队列的过程中,某些决策背后的理由。
Rabbit 中的 AMQP 队列不是简单的函数式 FIFO 队列。实际上,每个 AMQP 队列内部至少使用四个“队列”,每个队列都允许保存处于各种不同状态的消息。这些状态包括:消息是否保存在 RAM 中(无论它是否额外地写入了磁盘)?;消息本身是否在磁盘上,但其在队列中的位置仍然只保存在 RAM 中?诸如此类的事情。AMQP 队列中的每条消息在任何时候只会出现在这些内部队列中的一个,但是如果它们的状态发生变化,它们可以从一个队列移动到另一个队列(尽管这些内部队列之间的移动尊重 AMQP 队列中消息的总体顺序)。然后还有第五个“队列”,它根本不是一个队列。它更像是一对数字,指示仅保存在磁盘上的消息范围(如果您愿意,这些是指向“队列”的首尾的指针,该“队列”仅在磁盘上)。理论上,这种形式的消息零 RAM 成本(取决于您如何计数(数字也消耗 RAM,您知道!),并且在 Rabbit 的其他地方,您可以相当肯定,您能达到的最佳状态是每个消息几个字节)。完整的血腥细节可以从 variable_queue 模块顶部的文章中收集。它并没有那么可怕,但也不是完全简单的东西。棘手的部分是弄清楚如何决定哪些消息应该处于哪些状态,以及何时处于这些状态,我不会在这篇文章中涵盖这些决定。
一条被第五个“队列”捕获的消息可能需要两次读取才能从纯磁盘形式完全恢复为完全内存消息。这是因为每条消息都有一个消息 ID(它是随机的、无序的,并且在消息到达任何队列之前分配给每条消息,因此对于确定 AMQP 队列中的相对位置是无用的),并且在每个 AMQP 队列中,每条消息都通过其每个队列的序列 ID 识别,这强制执行 AMQP 队列中消息的相对顺序。可以将第五个“队列”视为从序列 ID 到消息 ID 的映射(加上一些每个消息每个队列的状态),然后您可以使用不同的子系统将该消息 ID 转换为实际消息。
由于这两次读取(尽管我们构建它的方式,其中一次读取在 16k 条消息之间共享,因此它可能更接近于每个消息 1+(1/16384) 次读取,至少默认情况下是这样),我们之前曾尝试阻止使用第五个“队列”:过去,即使在内存非常低的情况下,我们也会将消息完全写入磁盘,但仍然保留在 RAM 中的记录(尽管此时每个消息的记录相当小),假设这将给我们带来以后的优势:是的,它会消耗更多的 RAM,但是如果一些其他大的 AMQP 队列突然被删除并释放大量 RAM,那么通过保留每个消息的这个小记录,我们避免了必须进行两次读取才能从第五个“队列”返回到完整消息,而只需要进行一次读取。只有当 RAM 完全耗尽时,我们才会突然将(几乎)所有内容转储到第五个“队列”中(尽管此时,所有内容都已经写入磁盘,所以它或多或少是一个空操作 —— 我们只是在这次转换中释放 RAM)。
然而,由于至少其中一次读取的有效摊销,使用第五个“队列”并没有我们担心的那么昂贵。此外,如果您更早地开始使用它,那么队列在 RAM 中的增长速度会更慢:消息在此第五个“队列”中时,每个消息的 RAM 成本最低,因此您对这个“队列”的使用越多,您的队列消耗 RAM 的速率就越低。就其本身而言,这有助于 Rabbit 平滑过渡到纯磁盘操作(在消息增长率相同的情况下,RAM 增长率较低将导致磁盘操作速率较低)。
因此,我们改变了 Rabbit 的 AMQP 队列的行为,以更积极地使用第五个“队列”。基准测试表明,这似乎导致 AMQP 队列的内存使用在其增长的早期就趋于平稳,并且实际上似乎使 Rabbit 能够更快地将大型 AMQP 队列中的消息传递给消费者(可能是因为通过限制其他四个内部队列的大小,避免了一些被发现非常低效的操作(例如将两个函数式队列连接在一起(Erlang 的默认队列模块在此处执行简单的追加 (++
),这很昂贵)),因此有更多的 CPU 可用于驱动消费者)。缺点是队列现在花费更多时间进行读取,但这似乎已被每个消息较低的用户 jiffies 抵消。
下面是一个图表。这非常令人兴奋 —— 不仅仅是因为我的大多数博客文章都是无休止的文字。它显示了同一测试程序的三个运行结果。这个测试程序执行以下操作
- 它创建 3 个队列。
- 它将这 3 个队列绑定到一个扇出交换机。
- 然后它开始以每秒 600 条消息的速度向该交换机发布 200 字节的消息。
- 在前 120 秒内,它有每个队列 20 个消费者在不进行自动确认的情况下进行消费,每个消息一个确认,以及 QoS 预取为 1。众所周知,这是一种非常昂贵的消费消息方式。此外,确认被故意延迟,因此,忽略网络延迟,最大总消费速率将为每秒 1200 条消息。
- 120 秒后,消费者停止,并且在总积压消息达到 500,000 条(即,每个队列将有大约 166,000 条消息)之前不会再次启动。
- 之后,消费者像以前一样恢复,您希望队列能够应对持续的发布并将它们的积压推送到消费者。希望所有队列最终都将再次变空。
现在,根据您的 CPU、RAM、网络延迟和high_watermark设置,此积压可能纯粹只在 RAM 中,因此永远不会发生磁盘操作;或者它可能纯粹在磁盘上;或者介于两者之间的任何位置。我们办公室里的台式机往往过于强大,无法使此测试引起任何问题(积压总是会耗尽),但是在某些 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。
希望此更改将为许多用户在许多场景中带来改进。可能在某些用例中它的性能会更差 —— 我们当然不能排除这种可能性。在软件工程中,任何值得解决的问题都没有唯一的正确解决方案。这种情况表明,有时,使用更少的内存并进行明显更多的磁盘操作实际上可以使事情整体上更快。