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