调整您的 Rabbit
我们在 RabbitMQ 总部面临的一个问题是,虽然我们对消息代理的工作原理了如指掌,但我们并没有太多关于设计使用 RabbitMQ 并需要长期可靠、无人值守运行的应用程序的经验。我们花了很多时间在邮件列表中回答问题,也会在这里那里做一些咨询工作,但在某些情况下,是用户构建的应用程序促使我们真正思考 RabbitMQ 的长期行为。最近,我们被要求深入思考队列的基本性能,这让我们对 Rabbit 的配置有了新的认识。
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,此时,您很可能陷入了严重的困境。
您能做什么?
此时此刻,您需要清空您的队列。因为您的队列花在处理新到达消息上的时间多于花在将消息发送给消费者上的时间,所以增加更多消费者不太可能带来显著的帮助。您确实需要让发布者停止。
如果您有能力关闭发布者,那就这么做,并在队列再次变为空时重新打开它们。如果您不能这样做,那么您需要将负载转移到别处,但考虑到您的 RabbitMQ 正在写入磁盘以避免内存不足,并且 CPU 已满负荷运行,向当前的 RabbitMQ 添加新队列是无济于事的——您需要一台新机器上的新 RabbitMQ。如果您设置了一个集群,那么您可以将新队列配置到 RabbitMQ 集群中一个负载不那么重的节点上,然后连接大量新消费者,并将发布者导向那里。此时,您将体会到不使用默认的匿名交换机并直接寻址队列的好处,而会庆幸您让发布者发布到一个您创建的交换机,从而允许您将新绑定添加到您崭新的队列,并删除旧队列的绑定,转移负载,而无需中断您的发布者。旧队列将能够以最快的速度驱动其消费者(您没有移除它们!),并且队列将得到清空。现在在这种情况下,您可能会面临消息乱序处理的前景(新队列中新到达的消息可能比旧队列中旧消息先被新消费者处理),但如果您有多个消费者来自同一个队列,那么您可能已经在处理这个问题了。
我们经常被告知,预防胜于治疗。那么,您如何设计您的应用程序以帮助 RabbitMQ 应对这些潜在的灾难性情况呢?
首先,不要在您的消费者中使用非常低的 basic.qos 预取值。如果您使用 1 的值,这意味着队列将一条消息发送给一个消费者,然后直到收到确认消息之前,不能再向该消费者发送任何消息。只有在收到确认消息后,它才能发送下一条消息。如果确认消息返回队列需要一些时间(例如,网络延迟高,或者您 RabbitMQ 的负载可能导致确认消息需要一些时间才能完全到达队列),那么在此期间,该消费者将处于空闲状态。如果您使用例如 20 的 basic.qos 预取值,那么代理将确保将 20 条消息发送给消费者,即使第一条消息的确认(可能缓慢地)返回队列,消费者仍然有工作要做(即接下来的 19 条消息)。本质上,预取值越高,消费者就越能免受返回队列的往返时间峰值的影响。
其次,考虑不确认每条消息,而是确认每 N 条消息并设置确认消息的 multiple 标志。当队列过载时,是因为它有太多的工作要做(我知道,这很明显)。作为消费者,您可以通过确保不以确认消息淹没它来减少它需要做的工作量。因此,例如,您将 basic.qos 预取设置为 20,但您只在处理完每 10 条消息后发送一个确认,并在确认时将 multiple 标志设置为 true。队列现在接收到的确认数量将是以前的十分之一。它仍然会在内部确认所有十条消息,但如果它收到一个包含多条消息的确认,而不是许多单独的确认,它可以更有效地完成。但是,如果您只确认每 N 条消息,请确保您的 basic.qos 预取值高于 N。可能至少是 2*N。如果您的预取值与 N 相同,那么在确认消息返回队列和队列发送下一批消息期间,您的消费者将再次处于空闲状态。
第三,制定一个策略,在最坏的情况下将负载转移到其他机器上的其他队列。是的,使用 RabbitMQ 作为缓冲区来隔离发布者和消费者并吸收峰值是一个好主意。但同样,您需要记住,RabbitMQ 的队列在为空时速度最快,我们一直说您应该设计您的应用程序,使队列通常为空。换句话说,队列的性能在您最需要它来吸收大量峰值时是最低的。其结果是,除非您测试以确保您知道它会恢复,否则如果发生非同寻常的峰值,大大增加了您的队列长度,您可能会感到惊讶。您可能没有考虑到在这种情况下,您现有的消费者可能会比以前驱动得更慢,仅仅因为您的队列正忙于处理其他事情(入队消息),而这可能会导致一个恶性循环,最终导致队列性能的灾难性下降。
问题的核心是,队列是单线程资源。如果您设计的路由拓扑已经能够,或者至少可以,将消息分散到多个队列中,而不是将所有消息都压入一个队列,那么您将更有可能通过转移负载来快速轻松地应对这些问题的发生,并能够利用额外的 CPU 资源来确保您能够最大限度地降低每条消息的 CPU 占用。