最近在写一个基于RabbitMQ的消息总线。虽然RabbitMQ提供了plugin的机制可以实现对其进行扩展,但是由于对erlang语言不熟,考虑到上手成本高,因此放弃实现plugin,转而基于Smart client + 树形拓扑路由的模型。当然这也大大降低了我们实现功能的灵活性,后面我会找个时间开篇新文章,谈谈Smart Client的限制。
预备知识
RabbitMQ对于消息的通信只提供了几个非常简单的API:Channel#basicPublish;Channel#basicConsume(Channel#basicGet)。API非常简单,几乎可以看作只提供了produce/consume这一种通信模型。但RabbitMQ的灵活性在于其多种exchange的组合。如果你不了解exchange的种类,请移步官网的说明文档。
有几个原则:RabbitMQ一切消息都发送给exchange,一切消息都从queue中获取。至于消息怎样从exchange路由到queue,这取决于exchange的类型+exchange与exchange或exchange与queue共同组合的结果(但前提是queue只能作为叶子节点,queue之间无法组合)。
树形拓扑
消息总线的整个架构采用的是SmartClient + Server端树形拓扑形式。这是非常典型的代理式拓扑结构,这种拓扑的好处是:方便隐藏内部细节、方便统一管控、方便实现拦截等。虽然是树形拓扑结构,但对于其细节的实现上还是有很大的讲究,下面谈谈在实现过程中的一些个人想法。
App在拓扑结构中的权衡
最初的实现很简单,只支持了点对点的produce/consume模型,这也是rabbitmq的原生模型。当时,一部分关注点放在它可以以非常小的成本记录所有通过总线的日志上。其树形拓扑是这样的:
(备注:图中长方形表示exchange,圆形表示queue,下同)
在实现完成之后,发现这些队列的粒度太粗,几乎是app级别的。这无法满足一个app需要有多个队列的需求,而这种需求在一个app被拆分成分布式组件之后是很常见的。因此,这里必须将队列的粒度进一步细化,几乎细化成一种队列”服务”(没错,你可以这么理解,每个队列无论在生产消息,还是在消费消息,就好像在提供一种服务)。而app是这些“服务队列”的上级。因此这些app被上提为最后一个层级的exchange,成为这些服务队列的组织者,而队列都处于同一高度上,这样也便于管控。于是拓扑图变成这样:
继续思考过后发现,将App从queue上提为最后一级的exchange,唯一好处就是能够在管控台上一目了然得看出某个queue的归属,以及对一个app下的所有的queue进行管控。其实要做到上面说的这些,只需要维护好app跟queue的逻辑关系(通过管控台界面操纵数据库)即可,没必要维护让app成为真实的exchange还使得整个路由多了一层,反而影响性能。因此决定把这个app exchange层去掉。
request/response实现的权衡
消息总线目前提供四种通信模型:
- produce/consume
- request/response
- publish/subscribe
- broadcast
- produce/consume -> business
- request/response -> business
- publish/subscribe -> pubsub
- broadcast ->pubsub
- 队列的职责不清晰:produce消息跟request消息混杂于同一个队列中而消费消息的API却是区分的,这会导致一个消费API消费它“不想”消费的消息(比如response遇到produce的消息)。这时这些它"不想"消费的消息,没有好的处理方式。
- 无限等待:这个主要跟request/response的实现机制相关。它是一种阻塞式同步等待响应的通信方式。如果它跟produce/consume模式复用队列,那么如果队列中有produce发送的大量消息未曾消费,那么request发送过来的消息将不可能得到及时消费,导致response也几乎不可能迅速给出应答,也就是request/response模式有可能根本就无法真正实现。
上面的症结主要在于不同类型的消息复用了相同的队列,但却区分了消费不同类型消息的API;而且队列里不同类型消息的顺序是不可测的。
这里就存在两种解决思路:- 复用队列,复用消费API,针对不同的消息指定不同的处理逻辑;
- 不复用队列,区分消费API。
Publish/subscribe实现的权衡
- pubsub exchange为fanout,客户端筛选
- pubsub exchange为 topic,服务端精准推送
- 第一种方式:发送次数少,服务端效率高(fanout几乎无需特定的路由),但每个pubsub下的队列都会受到消息,而如果某些队列的消费者是离线的情况下,这些消息会占据服务器资源,并且增加了客户端消费的速度,因为需要筛选
第二种方式:发送次数多,多少个订阅者就发送多少次,服务端效率较第一种低,但这种按序推送的方式可以有效节省消息服务器的资源,并且这种模式客户端的消费效率高
broadcast实现的权衡
从之前的讨论来看,如果想队列数减少,就必须复用队列,而如果复用队列,我们就必须合并消费该队列里不同类型消息的API并且承担接收同类型消息不及时的风险。因此我尝试了下面这样的拓扑图结构,这也是目前而言,最新的拓扑结构图:
它让每个队列都得到了复用,除了接收跟自己职责相关的消息,还会接收到广播消息。这种设计的好处就是节省了服务器的资源。它跟普通业务消息复用了队列,这导致它只能在消费API被调用时“跟随”调用。而且上面谈到的某些纯粹的消息发送方比如produce,publish,它们通常是没有队列的,因此无法接收到广播消息,这也是一种权衡设计。