前言
消息队列带来的好处
-
通过异步处理提高系统性能(减少响应所需时间)
-
削峰/限流
-
降低系统耦合性
消息队列带来的问题
-
系统可用性降低: 在加入 MQ 之前,你不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后你就需要去考虑了!
-
系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题!
-
一致性问题:(消费失败、重复消费、顺序消费) 消息队列是异步的,当消息没有被正确消费,就会导致数据不一致的情况了!
-
其它:事务消息、消息堆积、回溯消费
常见的消息队列对比
吞吐量 | 万级的 ActiveMQ 和 RabbitMQ 的吞吐量(ActiveMQ 的性能最差)要比 十万级、数十万级的 RocketMQ 和 Kafka 低一个数量级。 |
可用性 | 都可以实现高可用。ActiveMQ 和 RabbitMQ 都是基于主从架构实现高可用性。RocketMQ和kafka 是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 |
时效性 | RabbitMQ 基于 erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。其他三个都是 ms 级。 |
功能支持 | 除了 Kafka,其他三个功能都较为完备。 Kafka 功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用,是事实上的标准 |
消息丢失 | ActiveMQ 和 RabbitMQ 丢失的可能性非常低, RocketMQ 和 Kafka 理论上不会丢失。 |
- ActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用。
- RabbitMQ 在吞吐量方面虽然稍逊于 Kafka 和 RocketMQ ,但是由于它基于 erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 erlang 开发,所以国内很少有公司有实力做 erlang 源码级别的研究和定制。
- RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。
- Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,而且分布式可以任意扩展。同时 kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,社区活跃度很高。
队列模型和主题模型(又叫发布订阅模式)
- 队列模型:就是有一个队列,一端生产,一端消费。这样一个消息生产一次消费一次没问题,但是无法实现广播,所以就有了主题模式。
- 主题模型:每个消息中间件的底层设计都是不一样的,就比如 Kafka 中的 分区 ,RocketMQ 中的 队列 ,RabbitMQ 中的 Exchange 。我们可以理解为 主题模型(发布订阅模型) 就是一个标准,那些中间件只不过照着这个标准去实现而已。
RocketMQ的架构
基础结构
RocketMQ 通过使用在一个 Topic 中配置多个队列,并且每个队列维护每个消费者组的消费位置实现了主题模式/发布订阅模式。
- Broker:消息队列服务器,主要负责消息的存储、投递和查询以及服务高可用保证。生产者生产消息到 Broker ,消费者从 Broker 获取消息并消费。
- NameServer注册中心: 主要提供Broker管理 和 路由信息管理 。Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。
- Producer Group 生产者组: 代表某一类的生产者,向broker中的Topic投递消息。
- Consumer Group 消费者组: 代表某一类的消费者,订阅指定Topic中的消息。(支持以push推pull拉两种模式对消息进行消费,同时也支持集群方式和广播方式的消费)
- Topic 主题: 代表一类消息,一个topic中维护了1-N个队列(为了提高并发能力)。一个 Topic的多个队列 分布在多个 Broker上,一个 Broker 可以配置多个 Topic ,它们是多对多的关系。
- Queue队列:一个主题可以对应多个队列,队列可以分布在多个broker中,多个队列实现Topic的负载均衡。如果某个 Topic 消息量很大,应该给它多配置几个队列(用以提高并发能力),并尽量分布在不同 Broker上,以减轻某个 Broker 的压力
集群部署
- 第一、Broker 做了集群并且还进行了主从部署 ,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该Broker 上的消息读写都会受到影响。所以 Rocketmq 提供了 master/slave 的结构, salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息 (后面我还会提到哦)。
- 第二、为了保证 HA ,NameServer 也做了集群部署,但是请注意它是 去中心化 的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过 单个Broker和所有NameServer保持长连接 ,并且在每隔30秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info 。
- 第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过 轮询 的方法去向每个队列中生产数据以达到 负载均衡 的效果。
- 第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给 同一个消费组中的所有消费者 ,集群模式下消息只会发送给一个消费者。
RocketMQ 不使用 ZooKeeper 作为注册中心的原因,以及自制的 NameServer 优缺点?
- ZooKeeper 作为支持顺序一致性的中间件,在某些情况下,它为了满足一致性,会丢失一定时间内的可用性,RocketMQ 需要注册中心只是为了发现组件地址,在某些情况下,RocketMQ 的注册中心可以出现数据不一致性,这同时也是 NameServer 的缺点,因为 NameServer 集群间互不通信,它们之间的注册信息可能会不一致
- 另外,当有新的服务器加入时,NameServer 并不会立马通知到 Producer,而是由 Producer 定时去请求 NameServer 获取最新的 Broker/Consumer 信息(这种情况是通过 Producer 发送消息时,负载均衡解决)
消息消费
1.一个队列只会被一个消费者消费(同kafka)。[想并发高就用多个队列,而不是多个消费者消费一个队列]
2.一个消费者组共同消费一个 topic 的多个队列,如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。(也可以一个消费者消费多个队列,一般不建议,因为消费者处理能力一般没有队列处理快)
3.可能不同的消费者组会消费同一个Topic,所以每个消费者组会在自己消费的Topic的每个队列上维护一个消费位置。
Rebanlance
Rebalance(再均衡)机制指的是:将一个Topic下的多个队列(或称之为分区),在同一个消费者组(consumer group)下的多个消费者实例(consumer instance)之间进行重新分配。
rebanlance发生时机
-
在启动时,消费者会立即向所有Broker发送一次心跳(HEART_BEAT)请求,Broker则会将消费者添加由ConsumerManager维护的某个消费者组中。然后这个Consumer自己会立即触发一次Rebalance。
-
在运行时,消费者接收到Broker通知会立即触发Rebalance;同时为了避免通知丢失,会周期性触发Rebalance;
-
当停止时,消费者向所有Broker发送取消注册客户端(UNREGISTER_CLIENT)命令,Broker将消费者从ConsumerManager中移除,并通知其他Consumer进行Rebalance。
rebanlance流程
只要任意一个消费者组需要Rebalance,这台机器上启动的所有其他消费者,也都要进行Rebalance。
1.获得Rebalance元数据信息
2.进行队列分配(根据配置的策略:平均、循环、hash等)
3.分配结果处理
-
Kafka:会在消费者组的多个消费者实例中,选出一个作为Group Leader,由这个Group Leader来进行分区分配,分配结果通过Cordinator(特殊角色的broker)同步给其他消费者。相当于Kafka的分区分配只有一个大脑,就是Group Leader。
-
RocketMQ:每个消费者,自己负责给自己分配队列,相当于每个消费者都是一个大脑。
RocketMQ 不使用 ZooKeeper 作为注册中心的原因,以及自制的 NameServer 优缺点?
- ZooKeeper 作为支持顺序一致性的中间件,在某些情况下,它为了满足一致性,会丢失一定时间内的可用性,RocketMQ 需要注册中心只是为了发现组件地址,在某些情况下,RocketMQ 的注册中心可以出现数据不一致性,这同时也是 NameServer 的缺点,因为 NameServer 集群间互不通信,它们之间的注册信息可能会不一致
- 另外,当有新的服务器加入时,NameServer 并不会立马通知到 Producer,而是由 Producer 定时去请求 NameServer 获取最新的 Broker/Consumer 信息(这种情况是通过 Producer 发送消息时,负载均衡解决)
解决方案
顺序消费
-
普通顺序:消费者通过 同一个消费队列收到的消息是有顺序的 ,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性 (短暂时间) 。
-
严格顺序:消费者收到的 所有消息 均是有顺序的。严格顺序消息 即使在异常情况下也会保证消息的顺序性 。
如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。主要场景也就在 binlog 同步。
通常使用普通顺序,将同一语义下的消息放入同一个队列(比如同一个订单的创建/支付/发货要保证顺序),那我们就可以使用 Hash取模法 来保证同一个订单号在同一个队列中就行了。
重复消费
RocketMQ会出现消息重复发送的问题,因为在网络延迟的情况下,这种问题不可避免的发生,如果非要实现消息不可重复发送,那基本太难,因为网络环境无法预知,还会使程序复杂度加大,因此默认允许消息重复发送。
(幂等)应用处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(或者Broker意外重启等),这条回应没有发送成功。如果消息队列配置的是重发这个消息,consumer就会出现重复消费情况。此时需要consumer自己保证消息消费逻辑的幂等性。
(其它领域)在整个互联网领域,幂等不仅仅适用于消息队列的重复消费问题,也同样适用于,在其他场景中来解决重复请求或者重复调用的问题 。比如将HTTP服务设计成幂等的,解决前端或者APP重复提交表单数据的问题 ,也可以将一个微服务设计成幂等的,解决 RPC 框架自动重试导致的 重复调用问题 。
事务消息
常见分布式事务方式:二阶段提交、三阶段提交、异步事务
MQ通常采用的是异步事务:
1.发送半消息(MQ会将消息存到特殊的事务队列);
2.发送者执行自己的事务操作;
3.发送者commit/rollback事务,并通知MQ;若发送者挂了,MQ长时间未收到通知会进行定时回查;
4.MQ根据发送者的事务状态commit/rollback,将特殊事务队列中的消息放到实际的队列或丢弃,并记录该事务消息已完成。
本地事务和存储消息到消息队列才是同一个事务。这样也就产生了事务的最终一致性,因为整个过程是异步的,每个系统只要保证它自己那一部分的事务就行了。(开源版本阉割了事务回查的代码)
消息堆积
削峰 ——如果这个峰值太大了会导致大量消息堆积在队列,原因主要有二:生产者生产太快或者消费者消费太慢。
-
生产者生产太快:可以使用一些 限流降级 的方法。
-
消费者消费过慢:最快速解决消息堆积问题的方法还是增加消费者实例,不过 同时你还需要增加每个主题的队列数量(一个队列只会被一个消费者消费 )。也可以先检查 是否是消费者出现了大量的消费错误 ,或者查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。
回溯消费
回溯消费是指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,在RocketMQ 中, Broker 在向Consumer 投递成功消息后,消息仍然需要保留 。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费1小时前的数据,那么 Broker 要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒。
push/pull
RocketMQ的push模式是基于pull模式实现的,它没有实现真正的push,底层实现采用的是长轮询机制。
为什么要主动拉取消息而不使用事件监听方式?
事件驱动方式是建立好长连接,由事件(发送数据)的方式来实时推送。如果broker主动推送消息的话有可能push速度快,消费速度慢的情况,那么就会造成消息在consumer端堆积过多,同时又不能被其他consumer消费的情况。而pull的方式可以根据当前自身情况来pull,不会造成过多的压力而造成瓶颈。所以采取了pull的方式。
文件存储
1.CommitLog
消息主体的存储文件,存储 Producer 端写入的消息主体内容,消息内容不是定长的。
单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。
为什么 CommitLog 文件要设计成固定大小1G呢?
虽然FileChannel在映射文件时,size的入参是long值,但FileChannel内部会判断size是否超过了`Integer.MAX_VALUE`(2G),如果超了,会扔出`IllegalArgumentException`异常,所以文件映射无法一次性映射一个大于等于2G的文件,再加上减少理解投入及文件过期等因素,故设定commit log文件均为1G
假如第一个文件还没写满1G,但新的消息又写不进去时,怎么处理呢?
其实这种情况一定会存在,解决策略是在每个文件的结尾会写入“尾部空余大小(4byte)”,以及“结束标记魔法值(4byte)”,此魔法值为固定值-875286124
堆外缓冲(异步刷盘才生效)
transientStorePoolEnable=true,启动时开辟5个1G的DirectByteBuffer,消息循环写入缓冲池,通过CommitRealTimeService线程写入pageCache,避免写日志的线程平凡调用write产生内核态切换影戏性能。
2.ConsumeQueueFiles
基于 topic 的 commitlog 索引文件。消息消费队列,引入的目的主要是提高消息消费的性能,由于RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 commitlog 文件中根据 Topic 检索消息是非常低效的。
Consumer 可根据 ConsumeQueue 来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset ,消息大小 size 和消息 Tag 的 HashCode 值。
consumequeue 文件夹的组织方式如下:
topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。
同样 consumequeue 文件采取定长设计,每一个条目共20个字节,分别为8字节的 commitlog 物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个 ConsumeQueue文件大小约5.72M。
3.IndexFile
key索引文件,提供了可以通过key和时间区间来查询消息的方法。文件按照创建时间来命名的,所以根据消息key进行匹配查询的时候都要带上时间参数。
indexFile中的索引数据是包含Key的消息被发送到Broker时写入的。如果消息中没有Key,不会被写入。
结构有3部分(超过2000w个index会重新创建文件):
- indexHeader(40B):开始时间、结束时间、最小物理偏移量、最大物理偏移量、有效hashSlot数量、index索引数量
- slots(500万/每个4B):存储索引的indexNo。计算索引位置:n = hash(KEY); indexNo = slot(n) = 40+(n-1)*4
- indexes(2000万/每个20B):hash值(4B)、消息的log物理地址(8B)、与最早消息的时间差(4B)、相同hash的上一条索引指针/拉链冲突法(4B)
indexFile索引key的插入和查询流程
(公式中的40为indexHeader的字节数,5000000*4 是所有slot占用字节数,slot存储的是indexNo)
- 计算指定消息key的slot号:key的hash % 5000000
- 计算slot号为n的slot在indexFile中的起始位置:slot(n) = 40+(n-1)*4
- 计算indexNo为m的index在indexFile的位置:index(m) = 40 +5000000*4+(m-1)*20
取模结果的重复率是很高的,为了解决该问题,在每个index单元最后4字节是preIndexNo,用于指定该slot中当前index索引单元的前一个index索引单元(拉链冲突法)。slot中始终存放的是其下最新的index索引单元的indexNo,这样的话,只要找到了slot就可以找到其最新的index索引单元,而通过这个index索引单元就可以找到其之前的所有index索引单元。
4.刷盘机制:同步刷盘和异步刷盘
- 同步刷盘:对 MQ 消息可靠性来说是一种不错的保障,但是 性能上会有较大影响 ,一般地适用于金融等特定业务场景。
- 异步刷盘:一般地,只有在 Broker 意外宕机的时候会丢失部分数据,可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。
5.同步机制:同步复制和异步复制
指的是 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。
- 同步复制: 也叫 “同步双写”,也就是说,只有消息同步双写到主从节点上时才返回写入成功 。
- 异步复制: 消息写入主节点之后就直接返回写入成功 。
- Dledger 模式:写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功。(每个Broker至少2个从节点,Dledger 模式还支持主节点挂掉后,自动选举新的主节点)
6.存储与kafka存储对比
Kafka 采用的是每个 Topic 分配一个存储文件。
RocketMQ 采用的是 混合型的存储结构 ,即为 Broker 单个实例下所有的队列共用一个日志数据文件来存储消息。
RocketMQ 为什么要这么做呢?
原因是 提高数据的写入效率 ,不分 Topic 意味着我们有更大的几率获取 成批 的消息进行数据写入,但也会带来一个麻烦就是读取消息的时候需要遍历整个大文件,这是非常耗时的。所以,在 RocketMQ 中又使用了 ConsumeQueue 作为每个队列的索引文件来 提升读取消息的效率。可以直接根据队列的消息序号,计算出索引的全局位置(索引序号*索引固定⻓度20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。