跳到主要内容

一些队列理论:吞吐量、延迟和带宽

·12 分钟阅读
Matthew Sackman

你在 Rabbit 中有一个队列。你有一些客户端从该队列消费消息。如果你完全不设置 QoS 设置 (basic.qos),那么 Rabbit 将尽可能快地将队列中的所有消息推送到客户端,速度取决于网络和客户端的允许程度。消费者将在内存中膨胀,因为他们会在自己的 RAM 中缓冲所有消息。如果你询问 Rabbit,队列可能看起来是空的,但可能有数百万条消息未被确认,因为它们位于客户端中,准备被客户端应用程序处理。如果你添加一个新的消费者,队列中就没有消息可以发送给新的消费者了。消息只是在现有客户端中缓冲,并且可能会在那里停留很长时间,即使有其他消费者可以更快地处理这些消息。这是相当不理想的。

因此,默认的 QoS prefetch 设置为客户端提供了无限缓冲区,这可能会导致不良行为和性能。但是你应该将 QoS prefetch 缓冲区大小设置为多少呢?目标是保持消费者工作饱和,但要尽量减少客户端的缓冲区大小,以便更多的消息留在 Rabbit 的队列中,从而可供新的消费者使用,或者只是在消费者空闲时发送给他们。

假设 Rabbit 从这个队列中取一条消息、将其放到网络上并使其到达消费者需要 50 毫秒。客户端处理消息需要 4 毫秒。一旦消费者处理完消息,它会向 Rabbit 发送一个 ack 回复,这需要额外的 50 毫秒才能发送到 Rabbit 并被 Rabbit 处理。因此,我们总共有 104 毫秒的往返时间。如果我们有一个 QoS prefetch 设置为 1 条消息,那么 Rabbit 将不会发送下一条消息,直到此往返完成之后。因此,客户端在每 104 毫秒中只忙 4 毫秒,或者说 3.8% 的时间。我们希望它 100% 的时间都处于忙碌状态。

如果我们做总往返时间 / 客户端处理每条消息的时间,我们得到 104 / 4 = 26。如果我们有一个 QoS prefetch 为 26 条消息,这将解决我们的问题:假设客户端有 26 条消息被缓冲,准备就绪并等待处理。(这是一个合理的假设:一旦你设置了 basic.qos,然后从队列 consume,Rabbit 将从你订阅的队列中尽可能多地发送消息给客户端,最多达到 QoS 限制。如果你假设消息不是很大并且带宽很高,那么 Rabbit 很可能能够比你的客户端处理消息的速度更快地将消息发送到你的消费客户端。因此,从客户端侧缓冲区已满的假设出发进行所有计算是合理(且更简单)的。)如果每条消息需要 4 毫秒的处理时间,那么处理整个缓冲区总共需要 26 * 4 = 104 毫秒。前 4 毫秒是客户端处理第一条消息。然后客户端发出一个 ack 并继续处理缓冲区中的下一条消息。该 ack 需要 50 毫秒才能到达 broker。然后 broker 向客户端发出一条新消息,这需要 50 毫秒才能到达那里,因此当 104 毫秒过去并且客户端完成处理其缓冲区时,来自 broker 的下一条消息已经到达并准备就绪,等待客户端处理。因此,客户端始终保持忙碌状态:拥有更大的 QoS prefetch 不会使其运行得更快;但我们最大限度地减少了缓冲区大小,从而减少了客户端消息的延迟:消息在客户端缓冲的时间不会超过保持客户端工作饱和所需的时间。事实上,客户端能够在下一条消息到达之前完全耗尽缓冲区,因此缓冲区实际上保持为空。

只要处理时间和网络行为保持不变,这个解决方案就绝对没问题。但考虑一下如果网络速度突然减半会发生什么:你的 prefetch 缓冲区不再足够大,现在客户端将空闲,等待新消息到达,因为客户端能够比 Rabbit 提供新消息的速度更快地处理消息。

为了解决这个问题,我们可能会决定将 QoS prefetch 大小加倍(或接近加倍)。如果我们将它从 26 推到 51,那么如果客户端处理仍然保持在每条消息 4 毫秒,我们现在在缓冲区中有 51 * 4 = 204 毫秒的消息,其中 4 毫秒将用于处理消息,剩下 200 毫秒用于将 ack 发送回 Rabbit 并接收下一条消息。因此,我们现在可以应对网络速度减半的情况。

但是,如果网络运行正常,将我们的 QoS prefetch 加倍现在意味着每条消息将在客户端缓冲区中停留一段时间,而不是在到达客户端后立即处理。同样,从现在包含 51 条消息的完整缓冲区开始,我们知道新消息将在客户端完成处理第一条消息后 100 毫秒开始出现在客户端。但在那 100 毫秒内,客户端将处理 100 / 4 = 25 条可用消息中的消息。这意味着当一条新消息到达客户端时,它将被添加到缓冲区的末尾,因为客户端从缓冲区的头部移除消息。因此,缓冲区将始终保持 50 - 25 = 25 条消息的长度,并且每条消息将在缓冲区中停留 25 * 4 = 100 毫秒,从而将 Rabbit 将消息发送到客户端到客户端开始处理消息之间的延迟从 50 毫秒增加到 150 毫秒。

因此,我们看到,增加 prefetch 缓冲区以便客户端能够应对网络性能下降,同时保持客户端忙碌,会在网络正常运行时大幅增加延迟。

同样,与其网络性能下降,如果客户端开始花费 40 毫秒而不是 4 毫秒来处理每条消息,会发生什么?如果 Rabbit 中的队列之前处于稳定长度(即入口和出口速率相同),那么它现在将开始快速增长,因为出口速率已降至原来的十分之一。你可能会决定尝试通过添加更多消费者来处理这个不断增长的积压,但现在现有客户端正在缓冲消息。假设原始缓冲区大小为 26 条消息,客户端将花费 40 毫秒处理第一条消息,然后将 ack 发送回 Rabbit 并继续处理下一条消息。ack 仍然需要 50 毫秒才能到达 Rabbit,并且 Rabbit 发送新消息还需要 50 毫秒,但在那 100 毫秒内,客户端只处理了 100 / 40 = 2.5 条消息,而不是剩余的 25 条消息。因此,此时缓冲区长度为 25 - 3 = 22 条消息。从 Rabbit 到达的新消息,不再立即被处理,现在排在第 23 位,在 22 条其他仍在等待处理的消息之后,并且在接下来的 22 * 40 = 880 毫秒内不会被客户端触及。鉴于从 Rabbit 到客户端的网络延迟仅为 50 毫秒,这额外的 880 毫秒延迟现在占延迟的 95%(880 / (880 + 50) = 0.946)。

更糟糕的是,如果我们为了应对网络性能下降而将缓冲区大小加倍到 51 条消息会发生什么?在第一条消息被处理后,客户端中将缓冲另外 50 条消息。100 毫秒后(假设网络运行正常),一条新消息将从 Rabbit 到达,客户端将处理这 50 条消息中的第 3 条消息的一半(缓冲区现在将是 47 条消息长),因此新消息将排在缓冲区中的第 48 位,并且在接下来的 47 * 40 = 1880 毫秒内不会被触及。同样,鉴于将消息发送到客户端的网络延迟仅为 50 毫秒,这额外的 1880 毫秒延迟现在意味着客户端缓冲占延迟的 97% 以上(1880 / (1880 + 50) = 0.974)。这很可能令人无法接受:数据可能只有在及时处理时才是有效和有用的,而不是在客户端收到它大约 2 秒后!如果其他消费客户端处于空闲状态,他们也无能为力:一旦 Rabbit 将消息发送给客户端,消息就由客户端负责,直到它确认或拒绝该消息。一旦消息被发送给客户端,客户端就无法相互窃取消息。你想要的是保持客户端忙碌,但让客户端缓冲尽可能少的消息,以便消息不会因客户端缓冲区而延迟,从而可以快速地用 Rabbit 队列中的消息来喂饱新的消费客户端。

因此,太小的缓冲区会导致客户端在网络变慢时空闲,但太大的缓冲区会在网络正常运行时导致大量额外的延迟,并且如果客户端突然开始花费比正常时间更长的时间来处理每条消息,则会导致大量的额外延迟。显然,你真正想要的是一个可变的缓冲区大小。这些问题在网络设备中很常见,并且一直是许多研究的主题。主动队列管理算法试图尝试丢弃或拒绝消息,以便避免消息在缓冲区中长时间停留。当缓冲区保持为空时,延迟最低(每条消息仅遭受网络延迟,并且根本不会在缓冲区中停留),并且缓冲区用于吸收峰值。Jim Gettys 一直在从网络路由器的角度研究这个问题:LAN 和 WAN 性能之间的差异也存在完全相同类型的问题。实际上,无论何时你在生产者(在我们的例子中是 Rabbit)和消费者(客户端应用程序逻辑)之间有一个缓冲区,并且双方的性能都可以动态变化,你都会遇到这些类型的问题。最近,一种名为 Controlled Delay 的新算法已经发布,它似乎在解决这些问题方面效果良好

作者声称他们的 CoDel ("coddle") 算法是一种“免旋钮”算法。这实际上有点谎言:有两个旋钮,它们确实需要适当设置。但它们不需要每次性能变化时都进行更改,这是一个巨大的好处。我已经为我们的 AMQP Java 客户端实现了这个算法,作为 QueueingConsumer 的变体。虽然原始算法的目标是 TCP 层,在那里丢弃数据包是有效的(TCP 本身会负责丢失数据包的重传),但在 AMQP 中,这不太礼貌!因此,我的实现使用 Rabbit 的 basic.nack 扩展来显式地将消息返回到队列,以便它们可以被其他人处理。

使用它与正常的 QueueingConsumer 非常相似,只是你应该为构造函数提供三个额外的参数以获得最佳性能。

  1. 第一个是 requeue,它表示当消息被 nack 时,应该将它们重新排队还是丢弃。如果为 false,它们将被丢弃,这可能会触发死信交换机制(如果已设置)。
  2. 第二个是 targetDelay,它是消息在客户端 QoS prefetch 缓冲区中等待的可接受时间,以毫秒为单位。
  3. 第三个是 interval,它是单条消息的预期最坏情况处理时间,以毫秒为单位。这不必非常准确,但在一个数量级内肯定会有所帮助。

你仍然应该适当地设置 QoS prefetch 大小。如果你不这样做,很可能客户端会被发送大量消息,并且如果消息在缓冲区中停留时间过长,该算法将不得不将它们返回给 Rabbit。很容易最终产生大量额外的网络流量,因为消息被返回给 Rabbit。CoDel 算法旨在仅在性能偏离正常值时才开始丢弃(或拒绝)消息,因此一个工作示例可能会有所帮助。

同样,假设每个方向的网络遍历时间为 50 毫秒,我们预计客户端平均花费 4 毫秒处理每条消息,但这可能会飙升至 20 毫秒。因此,我们将 CoDel 的 interval 参数设置为 20。有时网络速度减半,因此遍历时间在每个方向上可能为 100 毫秒。为了适应这种情况,我们将 basic.qos prefetch 设置为 204 / 4 = 51。是的,这意味着当网络正常运行时,缓冲区大部分时间将保持 25 条消息的长度(参见之前的计算),但我们认为这没问题。因此,每条消息预计将在缓冲区中停留 25 * 4 = 100 毫秒,因此我们将 CoDel 的 targetDelay 设置为 100。

当一切运行正常时,CoDel 不应妨碍,并且应该很少或根本没有消息被 nack。但是,如果客户端开始以比正常速度更慢的速度处理消息,CoDel 将发现消息在客户端缓冲区中缓冲的时间过长,并将这些消息返回到队列。如果这些消息被重新排队,那么它们将可用于传递给其他客户端。

这在目前是非常实验性的,并且有可能看到 CoDel 不像处理普通 IP 那样适合处理 AMQP 消息的原因。还值得记住的是,通过 nack 重新排队消息是一个相当昂贵的操作,因此最好设置 CoDel 的参数,以确保在正常操作中很少或根本没有消息被 nack。管理插件是检查有多少消息被 nack 的一种简便方法。与往常一样,欢迎提出意见、反馈和改进!

© . All rights reserved.