RabbitMQ/AMQP - 微服务架构中最佳实践的队列/主题设计

52

我们考虑采用基于AMQP的方式来构建我们的微服务架构(编排)。我们有多个服务,例如:客户服务,用户服务,文章服务等。我们计划将RabbitMQ引入作为我们的中央消息系统。

我正在寻找有关主题/队列等方面的最佳实践,以设计该系统。其中一种选项是为系统中每个可能发生的事件创建一个消息队列,例如:

user-service.user.deleted
user-service.user.updated
user-service.user.created
...

我认为创建数百个消息队列并不是正确的方式,不是吗?

我想使用Spring和这些漂亮的注解,例如:

  @RabbitListener(queues="user-service.user.deleted")
  public void handleEvent(UserDeletedEvent event){...

不仅仅有"用户服务通知"一条队列会更好吗?然后将所有通知发送到该队列?我仍然想将监听器注册到所有事件的子集中,那么如何解决这个问题?

我的第二个问题是:如果我想侦听以前未创建的队列,我将在RabbitMQ中收到异常。我知道我可以使用AmqpAdmin“声明”队列,但是我应该为每个微服务的每个队列都这样做吗?因为总会出现迄今为止尚未创建队列的情况。

3个回答

36

通常,按对象类型/交换类型组合分组是最好的做法。

在您的用户事件示例中,根据系统需求,您可以执行多种不同的操作。

在一种情况下,按您列出的每个事件创建一个交换可能是有意义的。您可以创建以下交换:

| exchange     | type   |
|-----------------------|
| user.deleted | fanout |
| user.created | fanout |
| user.updated | fanout |

这将符合广播事件到任何侦听器的 "发布/订阅" 模式,而不必考虑正在侦听什么。

使用此设置,绑定到任何这些交换的队列都将接收发布到交换的所有消息。这对于发布/订阅和某些其他场景非常有用,但它可能并不总是您想要的,因为您无法为特定的消费者过滤消息,而不创建新的交换、队列和绑定。

在另一种情况下,您可能会发现创建了太多的交换,因为有太多的事件。您可能还希望将用户事件和用户命令的交换组合在一起。这可以使用直接或主题交换来完成:

| 交换机       | 类型   |
|-----------------------|
| 用户        | 主题   |

有了这样的设置,您可以使用路由键将特定消息发布到特定队列。例如,您可以将user.event.created作为路由键发布,并将其路由到特定消费者的特定队列。

| 交换机       | 类型   | 路由键             | 队列                |
|-----------------------------------------------------------------|
| 用户        | 主题   | user.event.created | user-created-queue |
| 用户        | 主题   | user.event.updated | user-updated-queue |
| 用户        | 主题   | user.event.deleted | user-deleted-queue |
| 用户        | 主题   | user.cmd.create    | user-create-queue  |

在此场景中,您最终会得到一个单一的交换机,而路由键用于将消息分发到适当的队列。请注意,我还在此处包括了“创建命令”路由键和队列。这说明了如何通过组合模式来实现。

我仍然希望仅向所有事件的子集注册侦听器,那么该怎么办?

通过使用扇出交换机,您将为要侦听的特定事件创建队列和绑定。每个消费者都将创建自己的队列和绑定。

通过使用主题交换机,您可以设置路由键将特定消息发送到您想要的队列,包括带有绑定 user.events.#所有事件。
如果您需要特定的消息发送到特定的消费者,您可以通过路由和绑定来实现这一点
最终,没有正确或错误的答案来确定使用哪种交换类型和配置,除非了解每个系统的具体需求。您可以为几乎任何目的使用任何交换类型。每种类型都有权衡,这就是为什么每个应用程序都需要仔细检查以了解哪种交换类型是正确的原因。
至于声明队列。每个消息消费者在尝试连接到队列之前都应该声明其需要的队列和绑定。这可以在应用程序实例启动时完成,也可以等到需要队列时再进行。同样,这取决于您的应用程序需要什么。
我知道我提供的答案相当模糊,并且充满了选项,而不是真正的答案。虽然没有具体的坚实答案。这是所有模糊逻辑、特定场景和考虑系统需求的结果。

顺便说一句,我从一个独特的讲故事的角度写了一本涵盖这些主题的小电子书。它回答了你提出的很多问题,尽管有时是间接地。


26

德里克的建议很好,除了他如何命名队列。队列不应该仅仅模仿路由键的名称。路由键是消息元素,队列不应该关心这个。这就是绑定的作用。

队列名称应该根据附加到队列的消费者所做的内容来命名。此队列操作的意图是什么?比如你想在用户创建帐户时向用户发送电子邮件(使用德里克上面的答案发送具有路由键user.event.created的消息)。你可以创建一个名为sendNewUserEmail(或类似的样式)的队列名称。这意味着很容易审查和准确定位队列功能。

为什么这很重要?现在你有另一个路由键user.cmd.create。假设当其他用户(例如团队成员)为某人创建帐户时发送此事件。你仍然想向那个用户发送电子邮件,因此你创建绑定将这些消息发送到sendNewUserEmail队列。

如果队列以绑定的名称命名,可能会引起混淆,特别是如果路由键发生更改。保持队列名称解耦合且自我描述。


14
好观点!回顾我上面的回答,我喜欢你把队列名称视为执行操作或者规定队列中消息应该发生什么意图的方式。 - Derick Bailey
1
嗯,我不知道。将消费者的预期操作与特定队列耦合似乎是不好的耦合。为什么队列要关心其消费者的意图?当创建新用户时,您是否需要为每个想要发生的操作都要求一个新队列?您建议的方法将需要根据需求的微小变化进行架构更改。(即每个“操作”一个新队列,而不是现有事件队列的新订阅者) - contactmatt
我认为你混淆了交换机和队列。两个不同的消费者从同一个队列中消费将导致一半的消息发送到一个消费者,另一半发送到另一个消费者。我真的认为你混淆了交换机和队列。 - Jason Lotito

15
在回答“一个交换机还是多个交换机”的问题之前,我实际上想问另一个问题:我们是否真的需要为这种情况创建自定义交换机?
不同类型的对象事件自然而然地匹配不同类型的要发布的消息,但有时并非真正必要。如果我们将所有3种类型的事件抽象为“写入”事件,其子类型为“已创建”,“已更新”和“已删除”,会怎样?
| object | event   | sub-type |
|-----------------------------|
| user   | write   | created  |
| user   | write   | updated  |
| user   | write   | deleted  |

解决方案1

支持这一点最简单的解决方案是只设计一个“user.write”队列,并通过全局默认交换机将所有用户写入事件消息直接发布到该队列。直接发布到队列的最大限制是它假定只有一个应用程序订阅了这种类型的消息。同一应用程序的多个实例订阅此队列也可以。

| queue      | app  |
|-------------------|
| user.write | app1 |

解决方案2

当有第二个应用程序(具有不同处理逻辑)想要订阅发布到队列的任何消息时,最简单的解决方案可能无法工作。当有多个应用程序订阅时,我们至少需要一个“fanout”类型的交换机,其绑定到多个队列。这样,消息将被发布到交换机,并且交换机将消息复制到每个队列。每个队列代表每个不同应用程序的处理作业。

| queue           | subscriber  |
|-------------------------------|
| user.write.app1 | app1        |
| user.write.app2 | app2        |

| exchange   | type   | binding_queue   |
|---------------------------------------|
| user.write | fanout | user.write.app1 |
| user.write | fanout | user.write.app2 |

如果每个订阅者都关心并希望处理“user.write”的所有子类型事件,或者至少将所有这些子类型事件公开给每个订阅者不是问题,则第二种解决方案可以正常工作。例如,如果订阅应用程序仅用于保留交易日志;或者尽管订阅者仅处理“user.created”,但让它知道何时发生了“user.updated”或“user.deleted”也可以。当一些订阅者来自您组织的外部,并且您只想通知他们某些特定的子类型事件时,它变得不太优雅。例如,如果app2只想处理“user.created”,那么它根本不应该知道“user.updated”或“user.deleted”的信息。

解决方案3

为了解决上述问题,我们必须从“user.write”中提取“user.created”概念。使用“主题”类型的交换机可以帮助解决问题。在发布消息时,让我们使用"user.created/user.updated/user.deleted"作为路由键,这样我们就可以将“user.write.app1”队列的绑定键设置为"user.*",将“user.created.app2”队列的绑定键设置为"user.created"。

| queue             | subscriber  |
|---------------------------------|
| user.write.app1   | app1        |
| user.created.app2 | app2        |

| exchange   | type  | binding_queue     | binding_key  |
|-------------------------------------------------------|
| user.write | topic | user.write.app1   | user.*       |
| user.write | topic | user.created.app2 | user.created |

解决方案4

如果可能会有更多的事件子类型,那么“topic”交换机类型更加灵活。但是,如果您清楚地知道事件的确切数量,则可以改用“direct”交换机类型以获得更好的性能。

| queue             | subscriber  |
|---------------------------------|
| user.write.app1   | app1        |
| user.created.app2 | app2        |

| exchange   | type   | binding_queue    | binding_key   |
|--------------------------------------------------------|
| user.write | direct | user.write.app1   | user.created |
| user.write | direct | user.write.app1   | user.updated |
| user.write | direct | user.write.app1   | user.deleted |
| user.write | direct | user.created.app2 | user.created |

回到“一个交换机还是多个?”的问题。目前为止,所有的解决方案都只使用了一个交换机。这很好,没有任何问题。那么,什么时候我们需要多个交换机呢?如果一个“主题”交换机有太多绑定,就会出现轻微的性能下降。如果“主题”交换机上的太多绑定导致性能差异真正成为问题,当然你可以使用更多的“直接”交换机来减少“主题”交换机绑定数量以获得更好的性能。但是,在这里我更想关注“一个交换机”解决方案的功能限制。

解决方案5

我们可能自然而然地考虑使用多个交换机来处理不同的事件组或维度。例如,除了上面提到的创建、更新和删除事件之外,如果我们有另一组事件:登录和注销 - 一组描述“用户行为”而不是“数据写入”的事件组。由于不同的事件组可能需要完全不同的路由策略和路由键和队列命名约定,因此拥有一个单独的user.behavior交换机是很自然的。

| queue              | subscriber  |
|----------------------------------|
| user.write.app1    | app1        |
| user.created.app2  | app2        |
| user.behavior.app3 | app3        |

| exchange      | type  | binding_queue      | binding_key     |
|--------------------------------------------------------------|
| user.write    | topic | user.write.app1    | user.*          |
| user.write    | topic | user.created.app2  | user.created    |
| user.behavior | topic | user.behavior.app3 | user.*          |

其他解决方案

在某些情况下,我们可能需要为一个对象类型进行多个交换。例如,如果您想在不同的交换上设置不同的权限(例如,只允许选定的一个对象类型事件从外部应用程序发布到一个交换中,而另一个交换接受任何来自内部应用程序的事件)。或者,如果您想使用带有版本号后缀的不同交换来支持相同事件组的不同版本的路由策略。或者,您可能想要为交换到交换绑定定义一些“内部交换”,这些内部交换可以以分层方式管理路由规则。

总之,“最终解决方案取决于您的系统需求”,但是通过上述所有解决方案示例和背景考虑,我希望至少能引导您朝正确的方向思考。

我还创建了一篇博客文章,结合了本问题的背景、解决方案和其他相关考虑因素。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接