跳到主要内容

仲裁队列如何在本地交付,同时仍然提供排序保证

·17 分钟阅读
Jack Vanlightly

最近团队被问及,考虑到仲裁队列在可能的情况下会从本地队列副本(领导者或追随者)交付消息,它们是否以及如何提供与经典队列相同的消息排序保证。镜像队列始终从主节点(领导者)交付,因此从任何队列副本交付听起来可能会影响这些保证。

这是本文的主题。 请注意,本文是针对好奇者和分布式系统爱好者的技术深入探讨。 我们将了解仲裁队列如何在没有额外协调(Raft 之外的额外协调)的情况下从任何队列副本(领导者或追随者)交付消息,同时保持消息排序保证。

TLDR

所有队列,包括仲裁队列,都为非重新交付的消息提供每个通道的排序保证。 最简单的理解方式是,如果一个队列只有一个消费者,那么该消费者将按 FIFO 顺序接收交付的消息。 一旦您在一个队列上有两个消费者,那么这些保证就会更改为非重新交付消息的单调排序 - 也就是说,可能存在差距(因为消费者现在竞争),但消费者永远不会在较早的消息之前收到较晚的消息(即非重新交付的消息)。

如果您需要所有消息(包括重新交付的消息)的绝对可靠的 FIFO 排序保证,那么您需要使用单活动消费者功能并将预取设置为 1。 任何重新交付的消息都会在下一次交付发生之前添加回队列 - 从而保持 FIFO 顺序。

仲裁队列提供与经典队列相同的排序保证。 它恰好也能够从任何本地副本(即,对于消费者通道是本地的)交付。 如果您想了解仲裁队列如何管理这一点,请继续阅读! 如果不想了解,那么就此止步,但请放心,通常的排序保证仍然得到维护。

代理流量的成本

RabbitMQ 试图通过允许任何客户端连接到集群中的任何节点来简化操作。 如果消费者连接到 Broker 1,但队列存在于 Broker 2 上,则流量将从 Broker 2 代理到 Broker 1 再返回。 消费者不知道队列托管在不同的节点上。

但是,这种灵活性和易用性是有代价的。 在一个三节点集群上,最坏的情况是发布者连接到 Broker 1,其消息被路由到 Broker 2 上的经典非复制队列,而该队列的消费者连接到 Broker 3。 为了处理这些消息,所有三个 Broker 都被卷入其中,这当然效率较低。

如果该队列被复制,则消息必须在 Broker 之间传输一次用于代理,然后再额外传输一次用于复制。 最重要的是,我们介绍了镜像队列算法在每次消息多次发送时的效率有多低。

Fig 1 shows the mirrored queue replication traffic in blue, with additional traffic for proxying publisher and consumer traffic.
图 1 显示了镜像队列复制流量(蓝色),以及用于代理发布者和消费者流量的额外流量。

如果消费者可以从他们连接到的位置而不是从位于不同 Broker 上的领导者那里接收消息,那就太好了 - 这将节省网络利用率,并减轻队列领导者的一些压力。

Fig 2 shows a quorum queue with replication traffic in blue and only the publisher traffic being proxied as the consumer consumes directly from a follower.
图 2 显示了仲裁队列,其中复制流量为蓝色,只有发布者流量被代理,因为消费者直接从追随者消费。

协调的成本

使用镜像队列,所有消息都由队列主节点交付(并可能通过另一个 Broker 发送到消费者)。 这很简单,并且不需要主节点及其镜像之间的协调。

在仲裁队列中,我们可以在领导者和追随者之间添加协调以实现本地交付。 领导者和追随者之间的通信将协调谁将交付哪个消息 - 因为我们不能发生消息被交付两次或根本不交付的情况。 不幸的事情可能会发生,消费者可能会失败,Broker 可能会失败,网络分区等等,协调需要处理所有这些情况。

但是协调对性能不利。 使本地交付工作的协调类型可能会对性能产生极大的影响,并且非常复杂。 我们需要另一种方法,幸运的是,我们所需的一切都已内置到协议中。

无需协调,但需要确定性

分布式系统中避免协调的一种常用方法是使用确定性。 如果集群中的每个节点都以相同的顺序获取相同的数据,并且仅根据该数据做出决策,那么每个节点都会在该日志点做出相同的决策。

确定性决策制定要求每个节点以相同的顺序馈送相同的数据。 仲裁队列构建在 Raft 之上,Raft 是一个复制的提交日志 - 操作的有序序列。 因此,只要执行本地交付所需的所有信息都写入此操作的有序日志中,那么每个副本(领导者或追随者)都会知道谁应该交付每条消息,而无需彼此交谈。

事实证明,即使对于仅领导者交付,我们仍然需要将消费者的来来往往添加到日志中。 如果 Broker 发生故障,并且另一个追随者被提升为领导者,则它需要了解集群中存在的幸存消费者通道,以便它可以向它们交付消息。 此信息还支持无需协调的本地交付。

效果和本地标志

仲裁队列构建在名为 Ra 的 Raft 实现之上(也由 RabbitMQ 团队开发)。 Ra 是一个可编程状态机,它复制操作日志。 它区分所有副本都应执行的操作(命令)(为了保持一致性)和只有领导者应执行的外部操作(效果)。 这些命令、状态和效果由开发人员编程。 仲裁队列有自己的命令、状态和效果。

键值存储是命令和效果的一个很好的例子。 添加、更新和删除数据应由所有副本执行。 每个副本都需要具有相同的数据,因此当领导者发生故障时,追随者可以接管,并具有相同的数据。 因此,数据修改是命令。 但是,通知客户端应用程序键已更改应仅发生一次。 如果客户端应用请求在键更新时收到通知,则它不希望收到领导者和所有辅助副本的通知! 因此,只有领导者应执行效果。

Ra 支持“本地”效果。 在仲裁队列的情况下,只有 send_msg 效果是本地的。 其工作方式是,所有副本都知道哪些消费者通道存在以及在哪些节点上。 当消费者注册时,该信息将添加到日志中,同样,当消费者失败或取消注册时,该信息也会添加到日志中。

每个副本按顺序“应用”日志中每个已提交(多数复制)的命令。 应用 enqueue 命令会将消息添加到队列中,应用 consumer down 命令会从服务队列 (SQ) 中删除该消费者(稍后会详细介绍),并将所有未处理的消息返回到队列以进行重新交付。

消费者被添加到服务队列 (SQ) 中,服务队列是确定性维护的 - 这意味着所有副本在日志中的任何给定点都具有相同的 SQ。 每个消费者将评估任何尚未交付的消息,并具有与所有其他副本完全相同的 SQ,并将从 SQ 中出列一个消费者。 如果该消费者是本地的(意味着其通道进程与副本托管在同一 Broker 上),则副本会将消息发送到该本地通道。 然后,该通道会将消息发送给消费者。 如果消费者通道不是本地的,则副本将不会交付它,但会跟踪其状态(交付给谁,是否已确认等)。 一个需要注意的是,如果没有副本对于消费者通道是本地的,那么领导者会将消息发送到该通道(代理方法)。

如果您仍然觉得这很有趣,但发现很难概念化,那么我不会责怪您。 我们需要的是图表和事件序列来演示这一点。

带图表的示例

我将把事件集分组到每个图表中,以便尽可能减少图表的数量。

每个图表由三个队列副本组成,一个领导者和两个追随者。 我们看到了日志、服务队列、队列表示和“应用”操作的状态。 每个操作的格式为“命令 术语:偏移量数据”。 例如,E 1:1 m1 是入队命令,该命令在第一个术语中添加,具有第一个偏移量,并且是消息 m1。 术语和偏移量是 Raft 算法术语,对于理解本地交付不是非常重要(但如果您对此感兴趣,我建议您阅读有关 Raft 算法的内容)。

图表指南

组 1(事件序列 1)

  • 发布者通道为消息 m1 添加一个 *enqueue m1* 命令。

组 2 (事件序列 2-3)

  • 领导者将 enqueue m1 命令复制到追随者 A
  • 领导者将 enqueue m1 命令复制到追随者 C

组 3(事件序列 4-5)

  • 连接到追随者 A 的 Broker 的消费者 1 的通道添加一个 subscribe c1 命令
  • 命令 enqueue m1 由领导者 B 应用(因为它现在已提交)。
    1. 领导者将其添加到其队列中
    2. 领导者通知发布者通道它已提交。

组 4(事件序列 6-9)

  • 领导者将 subscribe c1 命令复制到追随者 A
  • 领导者将 subscribe c1 命令复制到追随者 C
  • 追随者 C 应用 enqueue m1 命令
    1. 将消息添加到其队列中
    2. 看不到消费者,因此无需交付
  • 追随者 A 应用 enqueue m1 命令
    1. 将消息添加到其队列中
    2. 看不到消费者,因此无需交付

消费者当然存在,但副本只有在应用日志中的订阅命令时才会了解消费者。 它们确实在日志中包含这些命令,但它们尚未应用这些命令。

组 5(事件序列 10-12)

  • 领导者 B 应用 subscribe c1 命令
    1. C1 被添加到其服务队列 (SQ) 中
    2. 评估尚未交付的消息 m1。 它从 SQ 中出列 C1,但看到它不是本地的,因此不将消息发送到 C1,而是仅跟踪 m1 已由 C1 处理。 将 C1 重新排队到 SQ 中。
  • C2 向领导者添加一个 subscribe c2 命令。
  • 发布者通道为消息 m2 添加一个 enqueue m2 命令。

组 6(事件序列 13-16)

  • 领导者将 subscribe c2 命令复制到追随者 A
  • 领导者将 subscribe c2 命令复制到追随者 C
  • 追随者 C 应用 subscribe c1 命令
    1. C1 被添加到其服务队列 (SQ) 中
    2. 根据其 SQ 中的第一个消费者评估消息 m1,但该通道不是本地的,将 C1 重新排队到 SQ 中。
  • 追随者 A 应用 subscribe c1 命令
    1. C1 被添加到其服务队列 (SQ) 中
    2. 根据其 SQ 中的第一个消费者评估消息 m1,并看到该通道是本地的,因此将消息发送到该本地通道。 将 C1 重新排队到 SQ 中。

组 7(事件序列 17-20)

  • 领导者 B 应用 subscribe c2 命令
    1. 将 C2 排队到其 SQ 中。
  • 领导者将 enqueue m2 命令复制到追随者 A
  • 消费者 1 的通道为 m1 添加一个 acknowledge m1 命令。
  • 追随者 A 应用 subscribe c2 命令
    1. 将 C2 排队到其 SQ 中。

请注意,追随者 A 和领导者 B 在其日志中处于相同的点,并且具有相同的服务队列。

组 8(事件序列 21-23)

  • 领导者 B 将 enqueue m2 命令复制到追随者 C
  • 追随者 C 应用 subscribe c2 命令
    1. 将 C2 排队到其 SQ 中
  • 领导者 B 应用 enqueue m2 命令
    1. 将消息 m2 添加到其队列中
    2. 从其 SQ 中出列 C1。 看到此通道不是本地的。 将 C1 重新排队到 SQ 中。 跟踪状态消息 m1(C1 将处理它)。
    3. 通知发布者通道此消息已提交。

此时,领导者 B 的 SQ 与追随者的 SQ 不同,但这仅仅是因为它在其日志中领先一个命令。

组 9(事件序列 24-26)

  • 领导者 B 应用 acknowledge m1 命令
    1. 从其队列中删除消息 *追随者 C 应用 enqueue m2 命令
    2. 将消息 m2 添加到其队列中
    3. 从 SQ 中出列 C1,但 C1 不是本地的。
  • 追随者 A 应用 enqueue m2 命令
    1. 将消息 m2 添加到其队列中
    2. 从 SQ 中出列 C1,并看到 C1 是本地的,因此将消息 m2 发送到 C1。 将 C1 重新排队到 SQ 中。

看到服务队列彼此匹配 - 追随者处于相同的偏移量,而领导者领先一个偏移量,但确认不会影响服务队列。

组 10(事件序列 27)

  • 托管领导者 B 的 Broker 发生故障或关闭。

组 11(事件序列 28)

  • 发生领导者选举,追随者 A 获胜,因为它在其日志中具有最高的纪元:偏移量操作。

组 12(事件序列 29-30)

  • 发布者通道添加一个 enqueue m3 命令。
  • 领导者 A 将 acknowledge m1 命令复制到追随者 C

组 13(事件序列 31-33)

  • 领导者 A 将 enqueue m3 命令复制到追随者 C
  • 领导者 A 应用 acknowledge m1 命令
    1. 从其队列中删除 m1
  • 追随者 C 应用 acknowledge m1 命令
    1. 从其队列中删除 m1

组 14(事件序列 34-35)

  • 领导者 A 应用 enqueue m3 命令
    1. 将 m3 添加到其队列中
    2. 从其 SQ 中出列 C2。 C2 不是本地的,因此将 C2 重新排队,并跟踪消息 m3 状态。
  • 追随者 C 应用 enqueue m3 命令
    1. 将 m3 添加到其队列中
    2. 从其 SQ 中出列 C2。 C2 是本地的,因此将消息 m3 发送到该本地通道。 将 C2 重新排队到其 SQ 中。

因此,我们看到在没有副本之间额外协调的情况下,我们实现了本地交付,同时保持了 FIFO 顺序,即使在领导者故障转移期间也是如此。

但是,如果消费者在被追随者交付消息后发生故障怎么办? 这会被检测到,并且消息会重新交付到不同 Broker 上的另一个消费者通道吗?

备选方案 - 消费者故障

我们将从组 6 继续 - m1 已交付给 C1 但未确认。

组 7 - 备选方案(事件序列 17-20)

  • C1 的通道消失了(无论出于何种原因)
  • 领导者 B 应用 subscribe c2 命令
    1. 将 C2 排队到 SQ
  • 追随者 A 应用 subscribe c2 命令
    1. 将 C2 排队到 SQ
  • 领导者 B 的监视器看到 C1 消失了。 将 down c1 命令添加到其日志中。

组 8 - 备选方案(事件序列 21-25)

  • 领导者 A 将 down c1 命令复制到追随者 A
  • 领导者 A 将 enqueue m2 命令复制到追随者 C
  • 追随者 C 应用 subscribe c2 命令
    1. 将 C2 排队到其 SQ 中
  • 领导者 B 应用 enqueue m2 命令
    1. 从其 SQ 中出列 C1,但 C1 不是本地的,因此将其重新排队。
  • 追随者 A 应用 enqueue m2 命令
    1. 从其 SQ 中出列 C1。 C1 是本地的。 尝试将消息 m2 发送给它(但无法发送,因为它已不再存在)。 将 C1 重新排队到其 SQ 中。

组 9 - 备选方案(事件序列 26-28)

  • 领导者 B 应用 down c1 命令
    1. 从其 SQ 中删除 C1
    2. 返回到队列先前交付给 C1 但未确认的消息 m1 和 m2
    3. 为了重新交付消息 m1,从 SQ 中出列 C2,但看到它不是本地的。 将 C2 重新排队。
  • 追随者 C 应用 enqueue m2 命令
    1. 从 SQ 中出列 C1,但 C1 不是本地的,因此将其重新排队,跟踪 m2 已由 C1 处理。
  • 追随者 A 应用 down c1 命令
    1. 从其 SQ 中删除 C1
    2. 返回到队列先前交付给 C1 但未确认的消息 m1 和 m2
    3. 为了重新交付消息 m1,从 SQ 中出列 C2,但看到它不是本地的。 跟踪为已由 C2 处理。

组 10 - 备选方案(事件序列 29-31)

  • 追随者 A 获取下一个未交付的消息 m2。 从其 SQ 中出列 C2,但 C2 不是本地的。 跟踪 m2 已由 C2 处理,将 C2 重新排队到 SQ 中。
  • 领导者 B 获取下一个未交付的消息 m2。 从其 SQ 中出列 C2,但 C2 不是本地的。 跟踪 m2 已由 C2 处理,将 C2 重新排队到 SQ 中。
  • 追随者 C 应用 down c1 命令
    1. 从其 SQ 中删除 C1
    2. 追随者 C 获取下一个未交付的消息 m1。 从其 SQ 中出列 C2,并看到 C2 是本地的。 将 m1 发送到此本地通道,将 C2 重新排队到 SQ 中。

组 11 - 备选方案(事件序列 32)

  • 追随者 C 获取下一个未交付的消息 m2。 从其 SQ 中出列 C2,并看到 C2 是本地的。 将 m2 发送到此本地通道。 将 C2 重新排队到 SQ 中。

仲裁队列处理了消费者 1 故障,没有任何问题,同时仍然从本地副本交付,而无需额外的协调。 关键是确定性决策制定,这要求每个节点仅使用日志中的数据来通知其决策,并且其日志中已提交的条目没有分歧(所有这些都由 Raft 处理)。

最终想法

仲裁队列具有与任何队列相同的排序保证,但也能够从本地副本交付消息。 它们如何实现这一点很有趣,但与开发人员或管理员无关。 有用的是了解这是选择仲裁队列而不是镜像队列的另一个原因。 我们之前描述了镜像队列背后网络效率非常低的算法,现在您已经看到,使用仲裁队列,我们大大优化了网络利用率。

从追随者副本消费不仅可以提高网络利用率,还可以更好地隔离发布者和消费者负载。 发布者可能会影响消费者,反之亦然,因为它们将争用相同的资源 - 队列。 通过允许消费者从不同的 Broker 消费,我们可以获得更好的隔离。 只需查看最近的容量规划案例研究,该研究表明,即使面对巨大的队列积压和来自消费者的额外压力,仲裁队列也可以维持高发布速率。 镜像队列更容易受到影响。

所以... 考虑仲裁队列!

© . All rights reserved.