我们正在考虑为我们的微服务基础架构(编排)引入基于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的队列,但是我是否应该为每个单独的微服务中的每个队列执行此操作,因为到目前为止总是会发生队列创建?
答案 0 :(得分:36)
我通常发现最好按对象类型/交换类型组合进行交换。
在您的用户事件示例中,您可以根据系统需要执行许多不同的操作。
在一个场景中,如您所列,每个事件进行一次交换可能是有意义的。你可以创建以下交换
| exchange | type | |-----------------------| | user.deleted | fanout | | user.created | fanout | | user.updated | fanout |
这适合" pub/sub"向任何听众广播事件的模式,而不关心什么是听力。
使用此设置,绑定到任何这些交换的任何队列都将接收发布到交换的所有消息。这对于pub / sub和其他一些场景来说非常棒,但它可能不是你想要的,因为你无法在不创建新的交换,队列和绑定的情况下为特定的消费者过滤消息。
在另一种情况下,您可能会发现由于事件太多而创建的交换太多。您可能还希望将用户事件和用户命令的交换组合在一起。这可以通过直接或主题交换完成:
| exchange | type | |-----------------------| | user | topic |
使用这样的设置,您可以使用路由键将特定消息发布到特定队列。例如,您可以将user.event.created
发布为路由密钥,并使其具有针对特定使用者的特定队列的路由。
| exchange | type | routing key | queue | |-----------------------------------------------------------------| | user | topic | user.event.created | user-created-queue | | user | topic | user.event.updated | user-updated-queue | | user | topic | user.event.deleted | user-deleted-queue | | user | topic | user.cmd.create | user-create-queue |
在这种情况下,您最终会使用单个交换和路由密钥将消息分发到适当的队列。请注意,我还包括了一个"创建命令"路由密钥和队列在这里。这说明了如何组合模式。
我仍然希望只将侦听器注册到所有事件的子集中,那么如何解决?
通过使用扇出交换,您可以为要侦听的特定事件创建队列和绑定。每个消费者都会创建自己的队列和绑定。
通过使用主题交换,您可以设置路由键以将特定消息发送到所需的队列,包括带有user.events.#
之类绑定的所有事件。
如果您需要特定消息转发给特定消费者you do this through the routing and bindings。
最终,在不知道每个系统需求的具体情况下,使用哪种交换类型和配置没有正确或错误的答案。您可以将任何交换类型用于任何目的。每个应用都有权衡,这就是为什么每个应用都需要仔细检查才能理解哪一个是正确的。
声明你的队列。每个消息使用者都应该在尝试附加到它之前声明它需要的队列和绑定。这可以在应用程序实例启动时完成,也可以等到需要队列。再次,这取决于您的应用程序需要什么。
我知道我提供的答案是相当模糊和充满选择,而不是真正的答案。但是,没有具体的答案。它是所有模糊逻辑,特定场景和查看系统需求。
FWIW,我从一个相当独特的讲故事的角度写了a small eBook that covers these topics。它解决了你的许多问题,虽然有时是间接的。
答案 1 :(得分:26)
队列名称应以附加到队列的消费者的名称命名。这个队列的操作意图是什么。假设您想在创建帐户时向用户发送电子邮件(当使用Derick的上述答案发送带有路由键user.event.created的消息时)。您可以使用您认为合适的样式创建队列名称sendNewUserEmail(或沿着这些行的某些内容)。这意味着它很容易查看并确切知道该队列的作用。
为什么这很重要?好吧,现在你有了另一个路由密钥user.cmd.create。让我们说当另一个用户为其他人(例如,团队成员)创建一个帐户时,会发送此事件。您仍然希望向该用户发送电子邮件,因此您创建绑定以将这些消息发送到sendNewUserEmail队列。
如果队列是在绑定后命名的,则可能会导致混淆,尤其是在路由键发生更改时。保持队列名称分离并自我描述。
答案 2 :(得分:15)
在回答"一次交换之前,还是多次交换?"题。我其实想问另一个问题:我们真的需要为这种情况进行自定义交换吗?
不同类型的对象事件非常自然地匹配要发布的不同类型的消息,但有时并不是必需的。如果我们将所有3种类型的事件抽象为“写”事件,其子类型是“创建”,“更新”和“删除”,该怎么办?
| object | event | sub-type |
|-----------------------------|
| user | write | created |
| user | write | updated |
| user | write | deleted |
解决方案1
支持这一点的最简单的解决方案是我们只能设计一个“user.write”队列,并通过全局默认交换将所有用户写事件消息直接发布到此队列。直接发布到队列时,最大的限制是假设只有一个应用程序订阅此类消息。订阅此队列的一个应用程序的多个实例也没问题。
| queue | app |
|-------------------|
| user.write | app1 |
解决方案2
当有第二个应用程序(具有不同的处理逻辑)想要订阅发布到队列的任何消息时,最简单的解决方案无法工作。当有多个应用订阅时,我们至少需要一个“扇出”类型的交换,绑定到多个队列。因此,消息被发布到excahnge,并且交换将消息复制到每个队列。每个队列代表每个不同应用程序的处理工作。
| 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
“主题”交换类型更灵活,以防可能会有更多的事件子类型。但如果您清楚地知道事件的确切数量,您也可以使用“直接”交换类型来获得更好的性能。
| 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
我们可能自然会考虑多个交换的一个案例是针对事件的不同组或维度。例如,除了上面记得的创建,更新和删除事件之外,如果我们有另一组事件:登录和注销 - 一组描述“用户行为”而不是“数据写入”的事件。 Coz不同的事件组可能需要完全不同的路由策略和路由密钥&队列命名约定,它是天生的,有一个单独的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.* |
其他解决方案
还有其他情况我们可能需要为一种对象类型进行多次交换。例如,如果要在交换机上设置不同的权限(例如,只允许将一种对象类型的选定事件从外部应用程序发布到一个交换机,而另一个交换机接受来自内部应用程序的任何事件)。对于另一个实例,如果要使用后缀为版本号的不同交换,则支持同一组事件的不同版本的路由策略。对于另一个实例,您可能希望为交换到交换绑定定义一些“内部交换”,这可以以分层方式管理路由规则。
总而言之,“最终的解决方案取决于您的系统需求”,但是上面的所有解决方案示例以及背景考虑因素,我希望它至少可以在正确的方向上进行思考。
我还创建了a blog post,将此问题背景,解决方案和其他相关注意事项汇总在一起。