MQ 概念

1.消息(Message)

消息是MQ中最小的概念,本质上就是一段数据,它能被一个或者多个应用程序所理解,是应用程序之间传递的信息载体。

2.队列(Queue)

2.1 本地队列

本地队列按照功能可划分为初始化队列,传输队列,目标队列和死信队列。

初始化队列用作消息触发功能。

传输队列只是暂存待传的消息,条件许可的情况下,通过管道将消息传送到其他的队列管理器。

目标队列是消息的目的地,可以长期存放消息。

如果消息不能送达目标队列,也不能再路由出去,则被自动放入死信队列保存。

2.2 别名队列&远程队列

只是一个队列定义,用来指定远端队列管理器的队列。使用了远程队列,程序就不需要知道目标队列的位置。

2.3 模型队列

模型队列定义了一套本地队列的属性结合,一旦打开模型队列,队列管理器会按照这些属性动态地创建出一个本地队列。

3.队列管理器(Queue Manager)

队列管理器是一个负责向应用程序提供消息服务的机构,如果把队列管理器比作数据库,那么队列就是其中一张表。

4.通道(Channel)

通道是两个管理器之间的一种单向点对点的的通信连接,如果需要双向交流,可以建立一对通道。

5.监听器(listener)

MQ产品的特性

可靠性传输

这个特点可以说是消息中间件的立足之本,对于应用来说,只要成功把数据提交给消息中间件,那么关于数据可靠传输的问题就由消息中间件来负责。

不重复传输

不重复传播也就是断点续传的功能,特别适合网络不稳定的环境,节约网络资源。

异步性传输

异步性传输是指,接受信息双方不必同时在线,具有脱机能力和安全性。

消息驱动

接到消息后主动通知消息接收方。

支持事务

应用程序可以把一些数据更新组合成一个工作单元,这些更新通常是逻辑相关的,为了保障数据完整性,所有的更新必须同时成功或者同时失败)。





如何设计一个消息中间件? 消息中间件的总体架构_数据


MQ适用场景介绍

MQ消息队列是应运松偶合的概念而产生的,主要以队列和发布订阅为消息传输机制,以异步的方式将消息可靠的传输到消费端的一种基础产品。

它被广泛的应用与跨平台、跨系统的分布式系统之间,为它们提供高效可靠的异步传输机制。

消息通道(Message Channel)

使用MQ将彼此协作的客户端和服务端连接起来,使他们可以交换消息。



如何设计一个消息中间件? 消息中间件的总体架构_kafka_02


如客户端与服务端需要安全可靠的交互,可以将一个MQ的队列作为安全通道,是客户端与服务端能够安全高效的进行异步通讯。

消息总线(Message Bus)

对于由许多独立开发的服务组成的分布式系统,倘若要将它们组成一个完整的系统,这些服务必须能够可靠地交互,同时,为了系统的健壮性,

每个服务之间又不能产生过分紧密的依赖关系,这样就可以通过消息总线将不同的服务连接起来,允许它们异步的传递数据。




如何设计一个消息中间件? 消息中间件的总体架构_高可用_03


消息路由(Message Router)

通过消息路由,可以将发送到MQ指定队列的消息根据规则路由到不同的队列。



如何设计一个消息中间件? 消息中间件的总体架构_数据_04


此外,JMS规范还支持通过selector条件,对消息进行过滤,可以用多个消费者消费同一个队列的消息,每个消费者只消费自己感兴趣的消息。

发布/订阅(Publicsher/Subscriber)

发布/订阅模式用于一对多的通讯,当消息发布者向一个主题(Topic)发送一条消息后,该主题的所有订阅者都会收到这条消息。

一个最简单的消息中间件

你肯定想到啦,就是队列!Queue.

ArrayBlockingQueue 就可以作为一个简单的MQ使用

发送消息用 put()
消费消息用 take()

我们封装下ArrayBlockingQueue 就可以支持多个topic了

Map  msg = new HashMap<String, ArrayBlockingQueue<Message>>()

这样一个最简单的MQ就实现了, 如下图




如何设计一个消息中间件? 消息中间件的总体架构_高可用_05


但这个MQ太简单了,缺点如下:

消息会丢失,jvm若挂了 消息全丢了

不支持高可用

不支持分布式,无法水平扩容

我们一条一条的解决

要高可用 要分布式

先解决分布式 架构如演化如下

现在我们的mq系统被拆分到多台机器上,这就支持分布式了,

任何一台机器只要连上我们的MQ就能发送消息

同样消费端也是一样




如何设计一个消息中间件? 消息中间件的总体架构_数据_06


分布式解决了,接下来解决高可用

目前的问题是MQ是单点,一旦MQ挂掉整个消息系统彻底垮掉,这在生产环境是绝不允许的

下面设计我们的高可用架构

分布式系统高可用常用的两种种思路

kafka思路, 每个节点先划分多个分区(partition),然后多节点间互相备份分区

rocketmq思路,每个节点配置一个热备

我们采用第二种,架构图演变成了下图

MQ 增加一个热备实现高可用,备机实时同步主机的数据,这样当主mq挂掉后,生产者可自动切换到备机继续运行

但是如果备机也挂掉了呢?机器中的消息是不是都会丢失呢

这里引出一个新的概念----刷盘

为了防止宕机导致消息丢失,需要对数据做刷盘处理

常用的刷盘策略有:

实时(同步)刷盘,发来一条消息写完磁盘后返回,可确保消息100%写成功,缺点是发送消息性能差

定时(异步)刷盘,没隔单位时间写一次磁盘,性能高

buffer(异步)刷盘,开辟一块buffer空间,比如32k,写满后刷盘,性能高

不刷盘,写内存后就返回 这是性能最高的策略了 。****其缺点也很明显




如何设计一个消息中间件? 消息中间件的总体架构_高可用_07


我们对上图的工作步骤做一下梳理

1.1 主 备MQ启动,并注册到注册中心

1.2 生产者启动,从注册中心拉去 所有MQ列表

1.3 选择一个主MQ发送消息(水平扩展后会有多个主)

1.4如果主挂掉 注册中心会立即感知并通知生产者不要想主发送了,改向备机发送

要高并发,还有不能丢数据

要高并发还不能丢数据,其实就MQ来说 这两点是矛盾的,就向CAP理论一样

我们只能权衡利弊找一个平衡点,也要根据场景选择策略

多数情况下 定时刷盘或buffer刷盘 + 热备机能够达到99%的数据不丢失

同时能保持一个不错的性能

如果你的数据不是很重要,如日志文件,则完全可以不刷盘 性能肯定刚刚滴

总结

我们回顾下上面讲的

从最简单的队列到分布式再到高可用, 一个基本的mq框架搭建起来了

但是发送消息, 消费消息是怎么实现,消息的重复消费, 消息的顺序消费等等问题我们都没有讲.

Kafka

首先还是来看Kafka的系统架构(做消息中间件逃不开要去了解Kafka)。




如何设计一个消息中间件? 消息中间件的总体架构_高可用_08


Kafka ecosystem包含以下几块内容:

  • Producer
  • Consumer
  • Kafka cluster
  • ZooKeeper

其中ZooKeeper承当了NameServer的角色,同时用于保存系统的元数据,提供选主、协调等功能。

Broker是真正的服务端,用于存储消息。

可用性

首先看外部依赖的可用性。如果你的系统“强依赖”了外部的其他服务,那么你的系统的可用性必然和外部服务的可用性相关。 (强依赖表示不可脱离依赖的服务保持正常运行)

从上面的架构可以看出Kafka只是依赖了ZooKeeper,而ZooKeeper本身是高可用的(2N+1个节点的ZK集群可以容忍N个节点故障),所以不会对整个集群的可用性造成影响。

接着看Kafka自身的可用性。谈可用性必然就会涉及到备份问题,没有备份就意味着存在单点问题,也就没有高可用可言了。所以我们具体来看一下Kafka的备份策略。




如何设计一个消息中间件? 消息中间件的总体架构_数据_09


(InfoQ一篇讨论Kafka可用性的文章的配套)

Kafka Replication的数据流如上图所示,从图中可以得到的一些信息:

  1. 分区是有备份的,如topic1-part1上图中有3个
  2. 分区的备份分布在不同的Broker上,上图中topic1-part1分布在broker1、broker2、broker3上,其中broker1上的为Leader
  3. 分区的Leader是随机分布的,上图中topic1-part1的Leader在broker1,topic2-part1的Leader在Broker上,topic3-part1的Leader的Broker4上
  4. 消息写入到Leader分区,之后通过Leader分区复制到Follower分区

更详细的Kafka的Replication实现可以去看官网(后续也可能单独写一篇),这里不展开。

Kafak这样的Replication策略,保证了任何一个Broker出现故障时,系统依旧是可用的。如broker1出现故障,此时会重新选举topic1-part1的Leader,之后可能是broker2或者broker3上的topic1-part1成为Leader然后负责消息的写入。

所以系统的可用性取决于分区备份的数量,这个备份数据是可配置的。

Kafka自身通过Replication实现了高可用,结合依赖的ZooKeeper也是高可用,所以整个系统的可用性得到了较好的保障。

可靠性

在消息中间件中,可靠性主要就是写入的消息一定会被消费到,条消息不会丢失。

在分布式环境中消息不丢失有两点:

  1. 消息在成功写入一个节点后,消息会做持久化
  2. 消息会被备份到其他物理节点

只要做到上面两点就可以保证除所有节点都发生永久性故障的情况下数据不会丢失。

Kafka Broker上写入的消息都会刷盘(可以是异步刷盘也可以是同步刷盘),也会备份到其他物理节点,所以满足以上两点。

异步刷盘结合多节点的备份策略也能提供比较好的可靠性,除非是机房掉电之类的情况导致所有节点未刷盘的数据丢失。

当然,消息丢失不一定指消息真的从磁盘上被销毁或者没被存储下来,如果消息被存储下来了,但是没办法被消费,对客户端来说也是消息丢失。比如Consumer收到消息后进行ACK之后再消费,如果在消费之前Crash了,那么下一次也不会拿到这条消息,也可以理解成消息丢了,但是这这篇文章中我们不讨论这种情况。

评价

优点

  1. 部分功能托管给了ZK,自身只需要关注消息相关的内容,从这个角度上说是简化了部分内容
  2. 机器利用率高。从上面备份的策略可以看出不同Broker之间数据是互为备份的,这样的结构相对于主从模式提高了机器利用率(大部分主从模式,从都是无用状态的)

缺点

  1. 引入了ZK,增加了外部依赖,增加了运维的复杂性

备份的方式从系统架构上说,互为主备是较好的方式,但是实现上会比较复杂,如果是自己去实现一个MQ,还是从主从的模式入手比较容易。

(Kafka的备份策略及基层WAL的实现就比较复杂了,这个以后有机会说)

RocketMQ




如何设计一个消息中间件? 消息中间件的总体架构_数据_10


(图片取自RocketMQ_design文档)

RocketMQ中包含以下几块内容:

  • Producer
  • Consumer
  • NameServer
  • Broker

Producer及Consumer和Kafka相同(所有的MQ都会提供Producer和Consumer),Rocket也是有Broker集群,和Kafka最大的区别是RocketMQ自己实现了一个集群模式的NameServer服务。

可用性

RocketMQ的可用性也分为NameServer和Broker两块讨论。

NameServer是集群模式的,且“几乎”是无状态的,可以集群部署,所以不会存在可用性的问题。(无状态意味着每个节点是独立提供服务的,只需要部署多个节点就可以解决可用性的问题)

Broker的可用性又可以分为两块,对一个Topic而言,它可以分布在多个Master Broker上,这样在其中一个Broker不可用之后,其他的Broker依旧可以提供服务,不影响写入服务。在一个Master Broker挂掉之后虽然可以通过其他Master来保证写入的可用性,但是已经写入到故障Broker的部分数据可能会无法消费。RocketMQ通过Master-Slave的模式来解决这个问题。

Master永久故障后可以将Master上的读取请求转移到Slave上,这样可以保证系统的可用性(Master-Slave之间是异步复制的,意味着可能少量数据还没有从Master复制到Slave,这个在可靠性部分讨论)。

综合上面的两点,RocketMQ也提供了高可用的特性,且可用性只取决于自身的服务,没有像Kafka一样引入额外的,像ZK这样的服务。

可靠性

可靠性从单个Broker写入消息的可靠性和消息备份两个角度去考虑。

RocketMQ采用了同步刷盘的方式来持久化写入的消息。




如何设计一个消息中间件? 消息中间件的总体架构_数据_11


同步刷盘和异步刷盘的唯一差别是异步刷盘写完pagecache直接返回,而同步刷盘需要等待刷盘完成之后才返回,写入流程如下:

  1. 写入pagecache,线程等待,通知刷盘线程进行刷盘
  2. 刷盘线程刷盘后,唤醒前端等待线程,可能是一批线程
  3. 前端等待线程想用户返回写入结果

(同步刷盘必然耗时要比异步刷盘要大,如何解决同步刷盘带来的性能的损耗后面在谈)

采用同步刷盘的方式,从单个节点的角度出发可靠性要比异步刷盘的方式要高,因为只要Producer收到消息写入成功的反馈,那么这条消息必然刷盘了,不会应为掉电等原因导致消息丢失。

单个节点必然会面对单点问题,当一个节点永久故障无法恢复时,哪怕这条消息已经持久化了也是没有意义的。相对于Kafka的互为备份的方式,RocketMQ采用的M-S的方式。

M-S方式就遇到了主从复制延迟的问题(异步复制永远是延迟的),那么在Master不可用后可能会导致部分数据丢失。RocketMQ针对这种场景,提供了同步双写的模式。

评价

优点

  1. 无外部依赖(这个以为着你的系统不需要额外的服务,无论从运维或者可用性出发,这确实是一个优点)

缺点

  1. M-S结构带来的机器利用率问题(大部分时候Slave可能是空闲的)

受限于M-S的机器利用率,实际上不会采用一主多从的模式,绝大部分是一主一从,部分可靠性要求没那么高的业务甚至都是没有挂载Slave的。这点得到了阿里内部开发同学的确认,这也是M-S模式的缺陷。

MQ的一些其他架构

Kafka引入了外部的ZK,而RocketMQ的主从模式又不够“好”,那能不能结合一下两种模式呢?

接着来讨论几种笔者考虑的架构。

结合Kafka和RocketMQ




如何设计一个消息中间件? 消息中间件的总体架构_kafka_12


这种架构主要就是在Kafka的基础上移除对ZK的依赖。引入ZK主要是为了解决分布式系统的协调问题,另外Kafka会将元数据(Topic的配置、消费进度等信息存在ZK上)保存在ZK上,同时提供NameServer的服务。

在这点上比较赞同RocketMQ的做法。元数据其实都可以存储在Broker上,因为Broker是有状态的,所以在它上面的消费进度等信息其实和其他Broker是无关的(如果是有相互备份的需要同步这块数据),所以NameServer可以很轻量级,做成无状态的。RocketMQ确实也这样去做了,NameServer的代码大约1000行,还是比较简单的。

这种架构在实现上有一个最大的问题就是移除了ZK之后,内部采用互为主备的方式需要对每个Topic的Partition选出Leader。在无中心节点的架构中自己来实现选主是一件非常困难的事情,包括要去处理网络分区等问题。当我们在以上架构中取解决这个问题其实可以通过一些妥协,比如可以先选出中心节点,然后由中心节点来负责剩余的选主相关的问题。

中心节点可以简单的通过人工指定的方式,中心节点本身的可用性其实并不是非常重要,因为脱离中心节点系统是可以正常运行的,只是无法进行选主。系统的可用性取决与是否中心节点故障同时有其他节点发生故障(牺牲了一些自动化运维,因为没有考虑中心节点的高可用,但是除去了外部依赖,系统设计总是会有tradeoff)。

移除NameServer

在深入考虑一下:

  • 元数据信息无非Topic配置、消费进度,数据量不会很大,完全可以存储直接存储在Broker上
  • 且Broker本身已经是多节点的,天然的就可以实现元数据的备份

将元数据存储在Broker上之后会面临一个问题:每一个Broker必须拥有所有的元数据,那么所有Broker之间需要通信来获取Topic数据(如果只是数据可用新,只是几个Broker之间备份)。

这个问题可以引入Gossip之类的协议来实现,所以架构可以去掉上面的NameServer,演变成如下结构:




如何设计一个消息中间件? 消息中间件的总体架构_高可用_13


到这里,架构其实就剩下一个Broker集群,Broker之间的数据采用Kafka的备份策略,Broker之间的元数据通过Gossip协议来完成复制。

到这里其实系统架构非常简单了,感觉也没有可以移除和变更的内容了(笔者的信仰——简单即美)。

但是其实一直忽略了一个问题就是上面的tradeoff,最终对于一个系统,我们肯定希望足够自动化,所以我们还是要去解决中心节点的高可用问题。

如何在Broker中选出一个唯一的Leader,这个其实就是分布式系统的一致性问题,只要引入一个可以解决分布式系统一致性问题的协议即可,比如Raft、Paxos之类。

所以这个架构理论上是可行的:

  • 无NameServer;
  • Broker之间采用互为主备的方式来保证系统的可用性和可靠性;
  • 引入Gossip协议来复制元数据;
  • 引入一致性协议来解决选主的问题;
  • 简单点可以用一致性协议来选中心节点,由中心节点负责协调其他问题
  • 本身也可以通过一致性协议直接来进行每一个Topic的Partition的选主问题

如果我们自己去写一个MQ

之前说过公众号希望写一个类似《从入门到XXX》的系列文章,所以并不希望一上来就将系统实现设计的太复杂,以致于自己都无法实现。还是选择一个更简单的架构来便于我们探讨实现MQ的核心问题以及真的利用业务时间去做一些尝试。

所以后续的文章会在以下的架构的基础上去展开(这个架构之上的内容讲完后再开始引入各种协议来简化架构或提升系统的可用性、可靠性)。




如何设计一个消息中间件? 消息中间件的总体架构_高可用_14


类似RocketMQ的架构,并做简化:

  • 单节点的NameServer(NameServer自身的服务发现可以通过DNS去做)
  • Broker之间采用主从的模式
  • 元数据存储在Broker上,汇报到NameServer(每个Broker只存储部分元数据,在NameServer上聚合)

这个架构实现上会比较简单,但是依旧有较高的可用性和可靠性。因为本身NameServer是无状态的,且NameServer的故障不会影响系统核心的服务(消息的发送和消息),所以可以容忍单节点。Broker类似RocketMQ的实现,同步刷盘加上主备的模式也是能提供比较好的可用性和可靠性,只是利用率不够(基于写这一系列文章的初衷,就先不考虑使用率的问题了)。

结语

本篇主要是介绍Kafka和RocketMQ的架构以及讨论可用性和可靠性的实现,综合两者给出笔者思考的MQ的架构。

而文末给出了之后一系列讨论的内容的架构基础,即选择最容易实现的模式来探讨后续的问题,这是需要在写之后的文章前达成的一个共同约定。

参考资料

​https://www.jianshu.com/p/ffa950d18f52​


Kotlin 开发者社区



如何设计一个消息中间件? 消息中间件的总体架构_kafka_15


国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。

越是喧嚣的世界,越需要宁静的思考。