RabbitMQ 消息队列大小调整
RabbitMQ 总部面临的一个问题是,虽然我们可能了解很多关于代理的工作原理,但我们并没有积累大量的经验来设计使用 RabbitMQ 的应用程序,并且这些应用程序需要可靠地、无人值守地运行很长时间。我们花费大量时间在邮件列表上回答问题,并且偶尔会进行咨询工作,但在某些情况下,由于用户构建应用程序时联系我们,我们才真正开始思考 RabbitMQ 的长期行为。最近,我们被促使深入思考队列的基本性能,这让我们对 RabbitMQ 的资源配置产生了一些认识。
当 RabbitMQ 的队列为空时,速度最快。当队列为空并且有消费者准备好接收消息时,只要队列接收到消息,就会立即发送给消费者。对于持久队列中的持久消息,是的,它也会写入磁盘,但这以异步方式完成并且被大量缓冲。重点是需要进行很少的簿记工作,很少修改数据结构,并且很少需要分配额外的内存。因此,消息通过空队列的 CPU 负载非常小。
如果队列不为空,则需要做更多工作:消息实际上需要排队。最初,这也很快速且廉价,因为底层函数式数据结构非常快。然而,通过保留消息,队列的总体内存使用量会更高,并且我们每条消息所做的工作比以前更多(每条消息现在都被入队和出队,而之前每条消息只是直接发送给消费者),因此每条消息的 CPU 成本更高。因此,与包含固定 N 条消息的队列的最高速度相比,空队列能够达到的最高速度将会更高,即使 N 很小。
如果队列接收消息的速度快于它可以发送给消费者的速度,那么速度就会变慢。随着队列增长,它将需要更多内存。此外,如果队列收到大量发布消息,则队列必须花费时间处理这些发布消息,这会占用发送现有消息给消费者的 CPU 时间:如果没有任何发布消息到达队列来分散它的注意力,那么包含一百万条消息的队列能够以更高的速度将其排空发送给准备就绪的消费者。这并不算什么火箭科学,但值得记住的是,到达队列的发布消息会降低队列驱动消费者的速度。
最终,随着队列的增长,它会变得非常大,以至于我们必须开始将消息写入磁盘并将其从 RAM 中删除以释放 RAM。此时,每条消息的 CPU 成本远高于消息由空队列处理的情况。
这些内容似乎并不特别深刻,但构建应用程序时牢记这些要点非常重要。
假设您设计并构建了使用 RabbitMQ 的应用程序。将有一组发布者和一组消费者。您对其进行测试,并且假设在系统的一部分中,您发现确保队列保持为空或接近空的最高速率为 2000 条消息/秒。然后,您选择一台机器来运行 RabbitMQ,这可能位于某种虚拟服务器上。当以 2000 条消息/秒的速度进行测试时,您发现运行 RabbitMQ 的机器的 CPU 负载并不高:瓶颈位于应用程序的其他地方——最有可能是在您的队列的消费者(您正在测量最大稳定的端到端性能)——因此 RabbitMQ 本身没有受到过大的压力,因此也没有消耗太多 CPU。因此,您选择了一台功能不太强大的虚拟服务器。然后您启动应用程序,一切看起来都正常。
随着时间的推移,您的应用程序变得越来越受欢迎,因此您的速率也增加了。
最终,您到达一个点,您的消费者全速运行,并且您的队列几乎保持为空。但是,在您应用程序一天中最繁忙的时间,您的发布者将比以前更多地将一些消息推送到您的队列中。这只是正常的增长——您现在有更多用户,因此消息发布速度比以前快一点也就不足为奇了。您希望发生的事情是,RabbitMQ 将愉快地缓冲消息,并最终将它们馈送到您的消费者,消费者将在一天中较空闲的时间处理积压的消息。
问题是这可能无法实现。因为您的队列现在(即使是短暂的)接收的消息比您的消费者能够处理的更多,因此队列花费在处理每条消息上的 CPU 时间比以前队列为空时更多(消息现在必须排队)。这会占用驱动消费者的 CPU 时间,不幸的是,由于您为 RabbitMQ 选择的机器没有太多备用的 CPU 容量,因此您开始耗尽 CPU。因此,您的队列无法像以前那样努力驱动您的消费者,这反过来又会使队列增长速度加快。这反过来又开始将队列推向必须开始将消息推送到磁盘以释放 RAM 的大小,这再次占用您没有的更多 CPU,此时,您很可能遇到大麻烦。
您能做什么?
在此时,您需要将队列排空。因为您的队列花费在处理新到达的消息上的时间比将消息推送到消费者上的时间更多,所以向队列添加更多消费者不太可能会有明显的帮助。您确实需要让发布者停止。
如果您有能力关闭发布者,那么请这样做,并在队列再次为空时重新打开它们。如果您无法做到这一点,则需要将它们的负载转移到其他地方,但是鉴于您的 Rabbit 正在写入磁盘以避免内存不足,并且 CPU 使用率已达到峰值,因此在您当前的 Rabbit 上添加新队列将无济于事——您需要在不同的机器上使用新的 Rabbit。如果您已设置了集群,则可以在 RabbitMQ 集群中负载不高的节点上配置新队列,然后将大量新消费者附加到该队列,并将发布者转移到那里。此时,您将意识到不使用默认的无名交换机并直接寻址队列的价值,并且会很高兴您让发布者发布到您创建的交换机,从而允许您向新的队列添加新的绑定,并删除到旧队列的绑定,转移负载,并且根本不需要中断您的发布者。然后,旧队列将能够尽可能快地驱动其消费者(您没有移除!),并且队列将排空。现在在这种情况下,您可能会遇到消息按顺序处理的问题(新队列中到达的新消息可能会在旧队列中的旧消息被处理之前由您的新消费者处理),但是如果您从单个队列中有多个消费者,那么您可能已经在处理此问题了。
我们经常被告知,预防胜于治疗。那么,您如何设计应用程序以帮助 RabbitMQ 应对这些可能造成灾难性后果的情况呢?
首先,不要在消费者中使用非常低的 basic.qos
预取值。如果您使用值 1,则意味着队列会将一条消息发送给消费者,然后在收到确认消息之前无法向该消费者发送任何其他消息。只有在完成此操作后,它才能发送下一条消息。如果确认消息需要一段时间才能返回到队列(例如,网络上的高延迟,或者 Rabbit 所承受的负载可能意味着确认消息需要一段时间才能完全通过到队列),那么在此期间,该消费者将处于空闲状态。例如,如果您使用 20 的 basic.qos
预取值,则代理将确保 20 条消息被发送给消费者,然后即使第一条消息的确认消息(可能很慢)正在返回到队列,消费者仍然有工作要做(即接下来的 19 条消息)。从本质上讲,预取值越高,消费者对往返队列的时延峰值的抵抗力就越强。
其次,考虑不要确认每条消息,而是确认每 N 条消息并将确认消息中的 multiple
标志设置为 true。当队列过载时,是因为它有太多工作要做(很深刻,我知道)。作为消费者,您可以通过确保您不会用确认消息淹没它来减少它必须做的工作量。因此,例如,您将 basic.qos
预取设置为 20,但您只在处理完每 10 条消息后发送确认消息,并且您将确认消息中的 multiple
标志设置为 true。队列现在将接收之前接收到的十分之一的确认消息。它仍然会在内部确认所有十条消息,但如果它收到一个确认消息来解释多条消息,而不是大量的单个确认消息,那么它可以用更有效的方式来做到这一点。但是,如果您只确认每 N 条消息,请确保您的 basic.qos
预取值高于 N。可能至少是 2*N。如果您的预取值与 N 相同,那么您的消费者将再次处于空闲状态,而确认消息正在返回到队列,并且队列发送出一批新的消息。
第三,如果最坏的情况发生,制定一个策略将负载转移到其他机器上的其他队列。是的,使用 RabbitMQ 作为缓冲区是一个好主意,它可以隔离发布者和消费者,并可以吸收峰值。但是同样,您需要记住 RabbitMQ 的队列在为空时速度最快,我们总是说您应该设计您的应用程序,以便队列通常为空。或者换句话说,当您最需要队列来吸收大量峰值时,队列的性能最低。结果是,除非您进行测试以确保知道它会恢复,否则如果出现非平凡的峰值导致队列长度大幅增加,您可能会感到意外。您可能没有考虑过,在这种情况下,您现有的消费者最终可能会比以前运行得更慢,仅仅因为您的队列忙于执行其他操作(入队消息),并且这会导致一个恶性循环,最终导致队列性能灾难性下降。
问题的关键在于队列是一个单线程资源。如果您已经以某种方式设计了路由拓扑,使其已经或至少可以将消息分散到多个队列中,而不是将所有消息都发送到单个队列中,那么您更有可能能够快速轻松地响应此类问题的发生,通过转移负载并能够利用额外的 CPU 资源,从而确保您可以最大限度地减少每条消息的 CPU 占用。