Quorum队列如何在提供顺序保证的同时进行本地投递
团队最近被问到,鉴于Quorum队列会在可能的情况下从本地队列副本(leader或follower)投递消息,它是否以及如何能够提供与经典队列相同的顺序保证。镜像队列始终从主节点(leader)投递,因此从任何队列副本投递似乎可能会影响这些保证。
这就是本文的主题。请注意,本文是对好奇者和分布式系统爱好者的技术深入探讨。我们将探讨Quorum队列如何能够在无需额外协调(Raft之外)的情况下,从任何队列副本(leader或follower)投递消息,同时保持消息顺序保证。
TLDR
所有队列,包括Quorum队列,都为不是重新投递的消息提供每个通道的顺序保证。最简单的理解方式是,如果一个队列只有一个消费者,那么该消费者将按FIFO顺序接收投递的消息。一旦在一个队列上有多个消费者,那么这些保证就会变为未重新投递消息的单调顺序 - 也就是说,可能会出现间隙(因为消费者现在在竞争),但消费者永远不会在更早的消息(不是重新投递的消息)之前收到更晚的消息。
如果您需要所有消息(包括重新投递的消息)的防错FIFO顺序保证,则需要使用具有预取值为1的单活动消费者功能。任何重新投递的消息都会在进行下一次投递之前添加回队列中 - 保持FIFO顺序。
Quorum队列提供与经典队列相同的顺序保证。它碰巧也能够从任何本地副本投递,即消费者通道的本地副本。如果您想了解Quorum队列是如何管理这一点的,请继续阅读!否则,请在此处停止,但请放心,通常的顺序保证仍然得到维护。
代理流量的成本
RabbitMQ试图简化操作,允许任何客户端连接到集群中的任何节点。如果一个消费者连接到代理1,但队列存在于代理2上,那么流量将从代理2代理到代理1,然后再返回。消费者不知道队列托管在不同的节点上。
但是,这种灵活性和易用性是有代价的。在一个三节点集群中,最坏的情况是发布者连接到代理1,其消息被路由到代理2上的一个经典的非复制队列,并且该队列的消费者连接到代理3。为了处理这些消息,所有三个代理都被牵扯进来,这当然效率较低。
如果该队列被复制,则消息必须在代理之间传输一次以进行代理,然后另外再进行一次以进行复制。最重要的是,我们介绍了镜像队列算法在多发送每条消息方面的效率低下。
如果消费者可以从其连接到的位置接收消息,而不是从存在于不同代理上的leader接收消息,那就太好了 - 这将节省网络利用率并减轻队列leader的压力。
协调的成本
对于镜像队列,所有消息都由队列主节点(以及可能通过另一个代理发送到消费者)投递。这很简单,不需要在主节点及其镜像之间进行协调。
在Quorum队列中,我们可以添加leader和follower之间的协调来实现本地投递。leader和follower之间的通信将协调谁投递哪条消息 - 因为我们不能让一条消息被投递两次或根本不投递。可能会发生不幸的事情,消费者可能会失败,代理可能会失败,网络分区等,协调需要处理所有这些情况。
但是协调不利于性能。使本地投递工作所需的协调可能会极大地影响性能,并且也非常复杂。我们需要另一种方法,幸运的是,我们所需的一切都已内置到协议中。
无需协调,但需确定性
在分布式系统中避免协调的一种常见方法是使用确定性。如果集群中的每个节点都以相同的顺序获取相同的数据,并仅根据该数据做出决策,那么每个节点将在日志中的那个点做出相同的决策。
确定性决策需要每个节点以相同的顺序获取相同的数据。Quorum队列构建在Raft之上,Raft是一个复制的提交日志 - 一系列有序的操作。因此,只要执行本地投递所需的所有信息都写入此有序的操作日志,那么每个副本(leader或follower)都将知道谁应该投递每条消息,而无需相互通信。
事实证明,即使对于仅leader投递,我们仍然需要将消费者进出添加到日志中。如果一个代理失败,另一个follower被提升为leader,它需要了解跨集群存在的存活消费者通道,以便可以将消息投递给它们。此信息还支持无协调的本地投递。
效果和本地标志
Quorum队列构建在称为Ra的Raft实现之上(也由RabbitMQ团队开发)。Ra是一个可编程的状态机,复制操作日志。它区分所有副本都应执行的操作(命令,用于一致性)和仅leader应执行的外部操作(效果)。这些命令、状态和效果由开发人员编程。Quorum队列有自己的命令、状态和效果。
命令和效果的一个很好的例子是键值存储。添加、更新和删除数据应由所有副本执行。每个副本都需要具有相同的数据,因此当leader失败时,follower可以使用相同的数据接管。因此,数据修改是命令。但是,通知客户端应用程序密钥已更改应仅发生一次。如果客户端应用程序请求在密钥更新时收到通知,则它不希望从leader和所有辅助副本收到通知!因此,只有leader应该执行效果。
Ra支持“本地”效果。在Quorum队列的情况下,只有send_msg效果是本地的。它的工作原理是,所有副本都知道哪些消费者通道存在以及存在于哪些节点上。当消费者注册时,该信息将添加到日志中,同样,当它失败或取消时,该信息也将添加到日志中。
每个副本按顺序“应用”日志中的每个已提交(多数复制)命令。应用enqueue命令会将消息添加到队列中,应用consumer down命令会从服务队列(稍后详细介绍)中删除该消费者,并将所有待处理的消息返回到队列以重新投递。
消费者被添加到服务队列(SQ)中,该队列是确定性维护的 - 这意味着所有副本在日志中的任何给定点都具有相同的SQ。每个消费者将评估任何尚未投递的消息,其SQ与所有其他副本完全相同,并将从SQ中出列一个消费者。如果该消费者是本地的(意味着其通道进程托管在与副本相同的代理上),则副本将消息发送到该本地通道。然后,该通道将消息发送到消费者。如果消费者通道不是本地的,则副本不会投递它,但会跟踪其状态(它被投递到谁,是否已确认等)。一个需要注意的是,如果没有一个副本是消费者通道的本地副本,则leader将消息发送到该通道(代理方法)。
如果您仍然觉得这很有趣,但难以理解,我并不怪您。我们需要图表和一系列事件来演示这一点。
带图表的示例
我将把事件集分组到每个图表中,以便尽可能减少图表的数量。
每个图表包含三个队列副本,一个leader和两个follower。我们看到了日志的状态、服务队列、队列表示和“应用”操作。每个操作的格式为“命令项:偏移量数据”。例如,E 1:1 m1是enqueue命令,它在第一项中添加,具有第一个偏移量,并且是消息m1。项和偏移量是Raft算法项,对于理解本地投递并不重要(但如果您觉得有趣,我建议您了解Raft算法)。
图表指南
组1(事件序列1)
- 发布者通道添加了用于消息m1的*enqueue m1 *命令。
组2(事件序列2-3)
- leader将enqueue m1命令复制到Follower A
- leader将enqueue m1命令复制到Follower C
组 3(事件序列 4-5)
- 连接到 Follower A 代理的消费者 1 的通道添加了一个 subscribe c1 命令
- Leader B 应用了命令 enqueue m1(因为它现在已提交)。
- Leader 将其添加到自己的队列中
- Leader 通知发布者通道它已提交。
组 4(事件序列 6-9)
- Leader 将 subscribe c1 命令复制到 Follower A
- Leader 将 subscribe c1 命令复制到 Follower C
- Follower C 应用了 enqueue m1 命令
- 将消息添加到其队列中
- 未发现消费者,因此无需进行投递
- Follower A 应用了 enqueue m1 命令
- 将消息添加到其队列中
- 未发现消费者,因此无需进行投递
当然,消费者确实存在,但副本只有在应用其日志中的 subscribe 命令时才会了解消费者。它们的日志中确实有这些命令,但尚未应用它们。
组 5(事件序列 10-12)
- Leader B 应用了 subscribe c1 命令
- C1 被添加到其服务队列 (SQ) 中
- 评估尚未投递的消息 m1。它将 C1 从 SQ 中出队,但发现它不是本地的,因此不将消息发送到 C1,而是仅跟踪 m1 由 C1 处理。将 C1 重新排队到 SQ 上。
- C2 向 Leader 添加了一个 subscribe c2 命令。
- 发布者通道为消息 m2 添加了一个 enqueue m2 命令。
组 6(事件序列 13-16)
- Leader 将 subscribe c2 命令复制到 Follower A
- Leader 将 subscribe c2 命令复制到 Follower C
- Follower C 应用了 subscribe c1 命令
- C1 被添加到其服务队列 (SQ) 中
- 针对其 SQ 中的第一个消费者评估消息 m1,但该通道不是本地的,将 C1 重新排队到 SQ 上。
- Follower A 应用了 subscribe c1 命令
- C1 被添加到其服务队列 (SQ) 中
- 针对其 SQ 中的第一个消费者评估消息 m1,并发现该通道是本地的,因此将消息发送到该本地通道。将 C1 重新排队到 SQ 上。
组 7(事件序列 17-20)
- Leader B 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2。
- Leader 将 enqueue m2 命令复制到 Follower A
- 消费者 1 的通道为 m1 添加了一个 acknowledge m1 命令。
- Follower A 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2。
请注意,Follower A 和 Leader B 的日志处于相同的点,并且具有相同的服务队列。
组 8(事件序列 21-23)
- Leader B 将 enqueue m2 命令复制到 Follower C
- Follower C 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2
- Leader B 应用了 enqueue m2 命令
- 将消息 m2 添加到其队列中
- 将其 SQ 上出队 C1。发现此通道不是本地的。将 C1 重新排队到 SQ 上。跟踪消息 m1 的状态(C1 将处理它)。
- 通知发布者通道此消息已提交。
此时,Leader B 的 SQ 与 Follower 不同,但这仅仅是因为它的日志领先一个命令。
组 9(事件序列 24-26)
- Leader B 应用了 acknowledge m1 命令
- 将其队列中的消息删除 *Follower C 应用了 enqueue m2 命令
- 将消息 m2 添加到其队列中
- 将其 SQ 上出队 C1,但 C1 不是本地的。
- Follower A 应用了 enqueue m2 命令
- 将消息 m2 添加到其队列中
- 将其 SQ 上出队 C1,并发现 C1 是本地的,因此将消息 m2 发送给 C1。将 C1 重新排队到 SQ 上。
发现服务队列彼此匹配 - Follower 的偏移量相同,Leader 领先一个,但确认不会影响服务队列。
组 10(事件序列 27)
- 承载 Leader B 的代理发生故障或关闭。
组 11(事件序列 28)
- 发生领导者选举,Follower A 胜出,因为它拥有最高的纪元:偏移量操作在其日志中。
组 12(事件序列 29-30)
- 发布者通道添加了一个 enqueue m3 命令。
- Leader A 将 acknowledge m1 命令复制到 Follower C
组 13(事件序列 31-33)
- Leader A 将 enqueue m3 命令复制到 Follower C
- Leader A 应用了 acknowledge m1 命令
- 将其队列中的 m1 删除
- Follower C 应用了 acknowledge m1 命令
- 将其队列中的 m1 删除
组 14(事件序列 34-35)
- Leader A 应用了 enqueue m3 命令
- 将 m3 添加到其队列中
- 将其 SQ 上出队 C2。C2 不是本地的,因此将 C2 重新排队并跟踪消息 m3 的状态。
- Follower C 应用了 enqueue m3 命令
- 将 m3 添加到其队列中
- 将其 SQ 上出队 C2。C2 是本地的,因此将消息 m3 发送到该本地通道。将 C2 重新排队到其 SQ 上。
因此,我们看到,在副本之间无需额外的协调,我们就可以实现本地投递,同时保持 FIFO 顺序,即使跨领导者故障转移也是如此。
但是,如果消费者在由 Follower 投递消息后发生故障会怎样?这是否会被检测到,并将消息重新投递到不同代理上的另一个消费者通道?
备选方案 - 消费者故障
我们将从组 6 继续 - m1 已经投递到 C1 但未确认。
组 7 - 备选方案(事件序列 17-20)
- C1 的通道消失(无论出于何种原因)
- Leader B 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2
- Follower A 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2
- Leader B 的监视器发现 C1 不见了。在其日志中添加了一个 down c1 命令。
组 8 - 备选方案(事件序列 21-25)
- Leader A 将 down c1 命令复制到 Follower A
- Leader A 将 enqueue m2 命令复制到 Follower C
- Follower C 应用了 subscribe c2 命令
- 将其 SQ 上排队 C2
- Leader B 应用了 enqueue m2 命令
- 将其 SQ 上出队 C1,但 C1 不是本地的,因此将其重新排队。
- Follower A 应用 enqueue m2 命令
- 将其 SQ 上出队 C1。C1 是本地的。尝试将消息 m2 发送给它(但无法发送,因为它已不存在)。将 C1 重新排队到其 SQ 上。
组 9 - 备选方案(事件序列 26-28)
- Leader B 应用了 down c1 命令
- 将其 SQ 上删除 C1
- 将先前已投递到 C1 但未确认的消息 m1 和 m2 返回到队列
- 为了重新投递消息 m1,它将 C2 从 SQ 中出队,但发现它不是本地的。将 C2 重新排队。
- Follower C 应用了 enqueue m2 命令
- 将其 SQ 上出队 C1,但 C1 不是本地的,因此将其重新排队,跟踪 m2 由 C1 处理。
- Follower A 应用了 down c1 命令
- 将其 SQ 上删除 C1
- 将先前已投递到 C1 但未确认的消息 m1 和 m2 返回到队列
- 为了重新投递消息 m1,它将 C2 从 SQ 中出队,但发现它不是本地的。跟踪为由 C2 处理。
组 10 - 备选方案(事件序列 29-31)
- Follower A 获取下一个未投递的消息 m2。将其 SQ 上出队 C2,但 C2 不是本地的。跟踪 m2 由 C2 处理,将 C2 重新排队到 SQ 上。
- Leader B 获取下一个未投递的消息 m2。将其 SQ 上出队 C2,但 C2 不是本地的。跟踪 m2 由 C2 处理,将 C2 重新排队到 SQ 上。
- Follower C 应用 down c1 命令
- 将其 SQ 上删除 C1
- Follower C 获取下一个未投递的消息 m1。将其 SQ 上出队 C2,并发现 C2 是本地的。将 m1 发送到此本地通道,将 C2 重新排队到 SQ 上。
组 11 - 备选方案(事件序列 32)
- Follower C 获取下一个未投递的消息 m2。将其 SQ 上出队 C2,并发现 C2 是本地的。将 m2 发送到此本地通道。将 C2 重新排队到 SQ 上。
仲裁队列处理了消费者 1 故障,没有任何问题,同时仍在从本地副本投递,而无需额外的协调。关键在于确定性决策,这要求每个节点仅使用日志中的数据来告知其决策,并且其日志中没有已提交条目的差异(这全部由 Raft 处理)。
最终思考
仲裁队列具有与任何队列相同的排序保证,但还能够从本地副本投递消息。它们如何实现这一点很有趣,但与开发人员或管理员无关。有用的在于了解这是选择仲裁队列而不是镜像队列的另一个原因。我们 之前描述过 镜像队列背后的网络效率极低的算法,现在您已经看到,使用仲裁队列,我们已经大大优化了网络利用率。
从 Follower 副本消费不仅会导致更好的网络利用率,而且还会在发布者和消费者负载之间获得更好的隔离。发布者会影响消费者,反之亦然,因为它们对同一资源(队列)造成争用。通过允许消费者从不同的代理消费,我们获得了更好的隔离。只需查看 最近的规模案例研究,该研究表明,即使在面对巨大的队列积压和消费者额外压力的情况下,仲裁队列也能维持高发布率。镜像队列更容易受到影响。
所以……考虑使用仲裁队列!