写点什么

什么是 MQ ?

用户头像
itfinally
关注
发布于: 2020 年 05 月 02 日

前不久部门内组织了一次茶话会, 由于毫无准备结果讲话时逐渐成了想到什么说什么的形式, 恰逢五一假期就安安静静总结一下, 很多时候工具用的6也并不代表能清晰表达这玩意是什么. 更何况是面向部分基础稍微薄弱的同学, 平时没时刻总结, 面对这种情况难免束手无策.



接下来让我们抛开各种传输协议, 各种实现细节等多余的东西, 来讨论下 MQ 本身的概念.



MQ 背后的设计模式

回想个人经历上, 最初接触到比较接近 MQ 的概念是在 《JavaScript模式》一书上描述的观察者模式. 其概念比较单纯, 站在事件的角度来说, 就是在以往 "生产者 -> 消费者" 的调用链上添加一层代理, 即形成 "生产者 -> 代理 -> 消费者" 的调用链. 虽然看起来代码多了, 相对于直接调用多了一层代理, 但是这种方式却初步实现了生产者与消费者的解耦. 如前端某一变量出现变化牵涉到一个或多个 UI 组件的更新时, 直接调用的代码复杂度明显高于使用了观察者模式的代码.



观察者模式简单来说就是将一或多个消费者打上标签, 相同标签的消费者为一组. 而生产者调用时只需要指定特定的标签, 并且提供预先约定的数据后即可. 生产者并不关心标签下是否有消费者, 或真实的消费者是谁. 前一刻可以是张三消费这个数据, 下一刻也可以是李四消费这个数据, 也可以不存在消费者, 而这些变动并不影响生产者. 下图完整描述了在观察者模式下生产者与消费者的关系:



观察者模式的四种情况

可以看出, 生产者实际上与消费者并没有任何强依赖. 整个过程中生产者仅与代理交互, 消息发布成功即代表完成了工作, 生产者并不怎么关心标签下是否有消费者, 消息是否成功投递, 消息究竟给谁消费等问题. 而消费者的行为也仅仅是关注其订阅的标签, 而不关心是谁发出这个标签. 因此对于这种设计也有人称之为发布-订阅模型, 而标签也称为事件或者消息, 而这种模型的初衷是为了解决在复杂系统下对系统拓展性的需求, 通俗来讲就是解耦.



异步的订阅发布模型

根据上文的订阅发布模型设计出的工具已经可以实现如 Vue 这类提供复杂的双向绑定操作的框架, 但这个模型仍存在一些问题:

  1. 所有操作均为同步调用, 对于生产者而言仅仅是实现了设计上的分离, 实际上消费者的调用仍会纳入生产者的调用栈内. 这意味着只要有一个消费者崩溃便会影响其他消费者以及生产者自身的流程.

  2. 同步调用意味着消费者实际上是顺序执行, 如果某事件存在过多的消费者, 假设每个消费者执行任务的时间复杂度为 O(n), 某一事件有 m 个消费者订阅, 那意味着处理该事件的时间复杂度为 O(n*m).



随着 CPU 与软件规模的发展, 这种同步调用方式的瓶颈在服务端也越发明显, 使用异步调用实现订阅发布模型也就显得顺理成章. 对于生产者一侧而言, 即发即弃(Fire-And-Forget)永远是第一准则, 而消费者一侧则像辛勤的园丁一样不断处理事件, 而两者是鸡犬之声相闻, 老死不相往来. 但任何解决方案都必然存在其副作用, 既然生产者与消费者都是异步执行, 也就意味着生产者实际上无法感知消费者的消费速率, 一旦消费者跟不上生产者, 便无法保证该模式能够正常工作. 简单来说就是生产者的速率与消费者的速率不匹配, 导致多余的事件要么被迫丢弃, 要么生产者等待出现空闲的消费者.



异步的订阅发布模型



如上图, 三个消费者均在不同线程内处理事件, 如果生产者此时发出第四个事件, 由于所有消费者均处于繁忙状态( 仍在处理之前接收到的事件 ), 分发器能做的也只是丢弃事件或阻塞生产者, 这就是生产者生产的速率远高于消费者消费的速率所导致的情况. (若消费速率高于生产速率时并不存在任何问题, 这是理想情况)



对于这种速率不匹配的情况, 如果开辟一段内存空间并使用队列(FIFO)这种数据结构为消费者提供缓冲(这段内存俗称队列), 那么生产者多余的事件便可缓存在这段内存上而彻底与消费者隔离. 同时, 消费者在这个模型下并不会直接与分发器交互, 而是与缓冲区交互, 分发器接收到的任何事件都仅仅是发送到缓冲区内.



引入队列的订阅发布模型

图中为消费者与队列的关系, 由于队列本身只作为缓冲区而存在, 本身并不包含任何额外的能力, 因此默认情况下消费者需要主动从队列拉取(Pull)事件进行消费. 就像刷朋友圈, 想看新消息总是要自己主动下拉刷新.



需要注意的是, 一旦订阅发布模型引入队列, 即代表该模型是在异步环境下工作. 因为队列存在的意义就是为了解决在异步情况下生产者与消费者的速率不一致的问题, 也有人根据其实际功效起了个名叫削峰填谷.



多频道的订阅发布模型

在实际场景下消息总是多样的. 就像我们既会刷 InfoQ, 也会刷 B 站, 睡前还要看下朋友圈. 从渠道来说, 刷 InfoQ 需要浏览器 App 提供 Web 服务, 刷 B 站需要 B 站的 App, 看朋友圈需要去微信 App. 虽然都是 App, 但不同渠道的 App 其名称也不一样. 因此对于系统而言, 多频道订阅也需要使用不同的队列. 而为了保证队列消息的单一, 队列本身就要声明其接受的消息的类型, 下图是该模型最基础的关系图.



多频道下的队列模型

当然, 根据实际情况也可以设定队列可接收多种类型的消息, 但是这么做便需要消费者一侧做内部消息分发. 所以总的来说, 基于这个基础模型能衍生出的哪些玩法就完全是个人选择问题了.



订阅发布模型下的单播与广播

实际上只要谈到单播与广播, 那么多数情况下都是基于消费者一侧聊这个事. 如某一类型的事件是否需要通知所有相关的消费者, 还是只需要通知其中一个消费者即可. 这个概念其实是很直白的, 在理解这个概念时并不需要引入任何具体的中间件实现.



在讨论这个概念前, 首先要引入分组这个概念对各个消费者进行隔离, 否则直接基于所有消费者之上谈单播并没有什么实际意义.



分组概念下的模型

以上图为例, 三个消费者分为两组, 其中消费者 C 同时订阅了事件 1 & 事件 2, 基于这个分组场景描述一下单播与广播的概念:

  1. 假设当前所有消费组均设定为单播模式, 那么生产者发出事件 1 后, 接收到事件 1 的消费者应该是 (消费者A, 消费者 C) 或者 (消费者 B, 消费者 C) 两种组合之一.

  2. 假设当前所有消费组均设定为广播模式, 那么生产者发出事件 1 后, 接收到事件 1 的消费者应该是 (消费者 A, 消费者 B, 消费者 C), 即全部消费者.



准确来说, 单播的概念是同一分组下某一信息仅能被其中一个组员接收, 因此必须完成分组再来谈单播概念才有意义. 如订单系统与物流系统均关心支付消息, 如果两个不同职责的系统均纳入同一组内便失去了实际意义. 因此单播更多时候是针对同一组下的不同成员, 如 "同一家庭内只需要有一个人倒垃圾", "某一单外卖只需要随机挑选一名配送员" 等.



而广播的概念是某一消息需要派送到多个不同的对象上, 这个对象可以是消费者, 但实际情况下更多是消费组. 如 "物业通告小区内所有家庭要实行垃圾分类", 物业就是生产者, 不同的家庭就是不同的消费组, 而实行垃圾分类这个信号就是生产者对所有分组发出的消息.



需要注意的是, 这里讨论的单播跟广播是根据实际情况描述这两种消息分发, 而不是说单播就一定只能在组员之间实现或者广播一定要在分组之间实现. 概念都很单一, 但根据不同的实际情况便能衍生出不同的玩法.



所以到底什么是 MQ ?

文章写到这里, 虽然对 MQ 只字未提, 但以上所描述的所有模型却是 MQ 模型的演变过程. 总的来说, 结合目前 Java 系流程的 MQ 中间件总结, 一个 MQ 的基础要素( 或者称为概念 )如下:

  1. 分发器 - 存储频道与队列之间的关系, 并且实现不同频道下消息的分发流程.

  2. 队列 - 即内存缓冲区, 用于消除生产者与消费者速率不一致产生的副作用.

  3. 分组 - 概念性的行为, 不同的 MQ 会以不同的表现形式表达这个概念.

  4. 生产者 - 发出消息的一侧, 由业务方实现, MQ 仅会提供分发器入口.

  5. 消费者 - 消费消息的一侧, 由业务方实现, MQ 仅会提供回调接口.

  6. 消息 - 概念性的对象, 至少对于 MQ 本身而言仅仅是个概念, 由业务方提供.



无论是何种 MQ 产品, 最终都是结合所有基础的概念以及其产品的偏好场景, 以不同表现形式实现出来. 如 RabbitMQ 本身偏向于消息可靠性而牺牲了部分性能, 而 RocketMQ & Kafka 偏向于高可用与性能而牺牲了部分消息可靠性. 不同的产品都会根据其自身的特性选择其架构模型, 因此在理解 MQ 概念时任何实际的 MQ 产品的架构都仅仅是提供参考价值, 而不能将其认为就是 MQ 本身的模型就是概念本身, 甚至拿某一 MQ 产品的模型套在其他 MQ 产品上进行理解.



接下来以具体的 MQ 产品描述下同一模型的不同变种实现.



RabbitMQ 模型

通常 RabbitMQ 称为兔子, 由 Erlang 这类自带并发模型的语言实现. 其 MQ 模型如下:

RabbitMQ 官网展示的架构图

在 RabbitMQ 的设计里分发器被独立成组件, 称为交换机, 而且在整个设计里该组件似乎比较突出. 此外 Queue 本身带有路由标记, 官方称为 BingdingKey. 同时消息本身也带有路由标记, 官方称为 RoutingKey. 当且仅当 RoutingKey 符合 BingdinKey 的匹配规则时, 消息才会分发到相应的队列上, 而这些动作均由交换机完成, 因此也可以把交换机的职责当作 Nginx 一样去理解.



在这个架构设计上, 交换机与 Queue 之间的消息派发属于广播模式, 而对于 Queue 与订阅自身的所有消费者之间的消息派发则是单播模式, 而且 RabbitMQ 也彻底将这种分组的方式彻底固化下来, 因此相比起其他 MQ 产品而言其架构模型会多一层抽象.



RocketMQ 模型

RocketMQ 是阿里基于 C 端产品需求自研的消息队列, 架构与 Kafka 类似, 其 MQ 模型如下:

RocketMQ 官网展示的架构图



与 RabbitMQ 不同, RocketMQ 本身并没有交换机这个概念, 但并不代表其不存在分发器这种职责的组件. 实际上由于自带 Nameserver 这种类似 Zookeeper 的注册服务, 也就能存储分发消息所需的队列与频道的关系表数据. 所有生产者启动时从 Nameserver 拉取关系表即可感知不同的队列及其绑定关系, 在这个设计下可以说生产者本身就自带分发器.



此外, 由于需要保证磁盘的顺序读, RocketMQ 的 Queue 相比起 RabbitMQ 其灵活度也没那么高. RabbitMQ 的 Queue 可以自由绑定不同的 Topic, 而 RocketMQ 的 Queue 跟 Topic 只能是一对一绑定关系, 因此给人的第一感觉便是这个 MQ 的 Queue 的存在感不如 Topic 这个概念强( 因为跟开发者接触最多的是 Topic 这个概念 ). 同时也由于这个一对一关系, 分组这个概念在 RocketMQ 下必须开放出来. 也就是说定义消费者与 Topic 的绑定关系时, 可以自定义消息在分组内的消息派发方式到底是广播还是单播.



万变不离其宗

咋一看, 好像不同的 MQ 产品其架构内的要素各有千秋, 其实背后都是一套理论. 如 RabbitMQ 把 Topic 拆分为 RoutingKey 与 BindingKey 以实现路由匹配功能, RocketMQ 为了实现高效的磁盘顺序读写而将 Topic 与 Queue 固化为一对一的绑定关系等. 实际上是不同产品团队对同一套理论与不同现实结合衍生出的不同的实现, 抛开具体实现从发展历史上理解反而会容易很多.



以上均为个人对 MQ 的理解, 仅为读者理解 MQ 起抛砖引玉的作用, 如有错误欢迎交流.

发布于: 2020 年 05 月 02 日阅读数: 129
用户头像

itfinally

关注

Step back, look at the bigger picture. 2019.07.15 加入

共产主义接班人, 感动中国2008年度人物特别奖获奖人, 百布搬砖小能手

评论

发布
暂无评论
什么是 MQ ?