Message Queue消息队列

一. MQ基本概念
1. 队列模型(Rabbit MQ)
生产者往某个队列中发送消息,一个队列中可以存储多个生产者的消息,一个队列也可以有多个消费者,消费者之间是竞争关系(一条消息只能被一个消费者消费)

 

 

 


2. 发布/订阅模型(Rocket MQ和kafka)
为了解决一条消息能被多个消费者消费的问题,该模型是将消息发往一个Topic主题中,所有订阅了这个主题的订阅者都可以消费这条消息,此模型兼容队列模型,即只有一个消费者的情况下

 

 

 

二. 常用术语
1. 生产者(Producer)
2. 消费者(Consumer)
3. 消息队列服务端(Broker)
4. 消息消费: producer推送消息到Broker,broker将消息存储到本地,consumer从broker拉去消息或者是broker推送消息到consumer,最后消费

 

 


5. 发布/订阅模型->
主题下的队列(rocket MQ)/分区(kafka):

此举是为了提高并发度,若一个主题下的队列有10个,并发度就是10,可以有10个消费者并行消费该主题下的消息,一般采用轮询或key hash趋于策略来将同一个主题下的消息分配到不同的队列中

主题下的消费组(consumer group): 即消费者都是属于某个消费组的,一条消息会发往多个订阅了这个主题的消费组,每个消费组都会有一个offset(消息点位)来标识自己消费的位置,消息点位之前的表示已经消费过了,offset是队列级别的,即每个消费组对应的队列

 

 

三. 消息处理
1. 消息不丢失
1.1 生产消息
发送消息到broker,需对broker的响应处理,不论是异步还是同步,都需要做好try-catch,若写入broker失败等错误,需要重试,多次失败则需报警、记录日志,保证消息不会丢失
1.2 存储消息
broker存储阶段需要在消息刷盘后给生产者响应,假设消息写入缓存就返回响应,若机器异常消息就丢失了,生产者还以为发送成功了,若是集群部署broker,则需要将消息写到至少2个副本机后,再给出响应(一个挂了->异地多活,全挂->GG)
1.3 消费消息
在消费者真正的执行完消费此条消息的业务逻辑之后,在返回broker消费成功了,保证消费的消息不丢失
2. 处理重复消息(幂等)
将条件前置判断,数据库的约束唯一键、记录关键的key,具体看业务细节
3. 保证消息的有序性(全局有序和部分有序)
3.1 全局有序

 

 


保证全局有序,必须只能由一个生产者往topic里发送消息,且topic内部只能有一个队列(分区),消费者也必须是单线程去消费这个队列的数据
3.2 部分有序

 

 


其实绝大部分有序都是部分有序,部分有序就可以将topic内部划分出想要的队列数,通过特定的消息策略将消息发送到固定的队列中去,然后每个队列对应一个单线程处理的消费者,这样就完成了部分有序且提高了并发率
4. 处理消息堆积
4.1 产生该现象的原因是生产者的生产速度与消费者的消费速度不匹配,可能是因为消费者反复消费失败重试导致的,也有可能就是消费方消费能力弱

4.2 因此需要先定位原因,如果是bug则处理bug,如果是消费能力弱则处理优化消费逻辑

4.3 如果逻辑优化完还不行,则需要进行水平扩容了,将topic中的队列数量和消费者数量进行扩容,队列数是一定要增加的,一个topic中一个队列只会分配给一个消费者

四. 消息中间件
1. 包含的重要角色
1.1 生产者
1.2 消费者
1.3 Broker
1.4 注册中心
包括broker的发现,生产者的发现,消费者的发现,以及下线

通信方面: 各个模块之间可以通过netty自定义协议来实现通信,也可以利用zookeeper、consul、eureka、nacos等,也可以像rocketMQ自己实现简单的nameServer

扩容与整体性能方面: 采用分布式的思想,像kafka那样采用分区理念,一个topic可以分为多个partition。保证数据的可靠性,采用多副本存储,即leader和follower,根据性能和数据可靠的权衡提供异步和同步的刷盘存储,

利用选举算法保证leader宕机follower可上,高可用

提高可靠性利用本地文件存储消息,采用顺序写的方式提高性能

可根据消息队列的特性利用内存映射,零拷贝进一步提升性能,还可像kafka一样批处理提升整体吞吐量
内存映射: 将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件

零拷贝: 避免用户态和内核态之间来回切换拷贝数据和cpu拷贝次数的技术

传统的IO通信方式:
1. 用户进程通过read向操作系统发起系统调用,指示上下文从用户态转向内核态;
2. DMA控制器把数据从硬盘读取到内核缓冲区
3. cpu把内核读缓冲区的数据拷贝到应用缓冲区,上下文切换为用户态,read返回
4. 用户进程发起write操作,上下文从用户态切换为内核态
5. cpu将用户/应用缓冲区的数据拷贝到socket缓冲区(写缓冲)
6. DMA控制器把数据从socket缓冲区拷贝到网卡(写入网卡设备),上下文从内核态切换为用户态,write返回

 

 


mmap+write的IO方式:

1. 用户通过mmap方法向操作系统发起调用,上下文从用户态切换为内核态
2. DMA控制器把数据从硬盘拷贝到读缓存区
3. 上下文从内核态切换为用户态,mmap返回
4. 用户通过write方法发起调用,上下文从用户态切换为内核态
5. cpu将内核读缓冲区中的数据拷贝到socket缓冲区
6. DMA控制器将数据从socket缓冲区拷贝到网卡,上下文从内核态切换为用户态,write返回

总的来说mmap+write的方式减少了一次cpu拷贝

 

 


sendfile():

1.相比于mmap+write的方式,sendfile同样减少了一个cpu拷贝次数,同时还减少了两次上下文切换

2. 通过使用sendfile是可以将数据直接在内核空间进行传输,避免了用户空间和内核空间之间的拷贝

 

 


2. 推拉模式(broker<->consumer)

rocketMQ和kafka都是拉模式
ActiveMQ是推模式
2.1 推模式
是指消息从broker推向cunsumer,即consumer被动的接受消息,broker主导消息的发送

好处: 实时性高,对消费者来说使用简单

缺点: 推送速率难以适应消费速率,推送速度肯定大于消费速度,毕竟消费要进行判断幂等,业务处理等操作,会导致消费者爆仓,仿佛消费方被ddos了一样
2.2 拉模式
是指consumer主动向broker主动拉取消息,即broker被动的发送消息给consumer

好处: 消费者可以根据自身的情况来发起拉取消息的请求,若是消费不过来可以根据一定策略停止拉取,或者间接性拉取消息

而且对于broker来说也更轻松了,只需要存储producer的消息即可,拉模式更适合消息的批量处理,因为消费者在拉取的时候已经知道自己的处理能力,而推模式批量处理可能导致爆仓

缺点: 消息延迟,消费者不清楚消息是否接收到了,所以只能频繁拉取,但是太频繁就会导致broker像是被攻击了,因此需要制定拉取间隔策略

消息忙请求: 比如消息过了几小时才有,在此期间消费者一直都在做无用的请求
子主题 1
3. 长轮询(rocketMQ和kafka都通过长轮询降低拉模式的缺点)
3.1 rocketMQ中的长轮询
rocketMQ中的pushConsumer其实是披着拉模式的方法,看着像是推模式而已,因为rocketMQ在背后偷偷的去broker中拉取数据了

在rocketMQ后台会有一个RebalanceService的线程,这个线程会根据topic中的队列数和当前消费者的消费者数量做负载均衡,每个队列产生的pullRequest都会被放到一个pullRequestQueue的阻塞队列中,然后又会有一个pullMessageService的线程不断的从阻塞队列pullRequestQueue中获取pullRequest,然后像broker请求,获取实时的拉消息,然后broker的pullMessageProcessor里面的ProcessorRequest方法是用来处理拉消息请求的,有消息就直接返回,而PullRequestHoldSerivce这个线程会每5秒从pullRequestTable中取pullRequest请求,然后看待拉取消息请求的偏移量是否小于当前消费队列的最大偏移量,如果条件成立,说明有新消息,则会调用notifyMessageArriving,最终调用PullMessageProcessor的exceuteRequestWhenWakeUp()方法重新尝试处理这个消息,也就是再来一次消息拉取,整个长轮询的时间默认是30秒。因为长轮询5秒一次,所以有补偿机制,有reputMessageService线程,这个线程不断的从commitLog中解析数据并分发请求,构建ConsumeQueue和IndexFile两种类型的数据,也会唤醒请求的操作,来处理新消息

 

 



3.2 kafka中的长轮询
kafka在拉请求中有参数,可以使得消费者在长轮询中"阻塞",可以理解为消费者去broker拉取消息时,定义了一个超时时间,若在时间内有消息,则返回给消费者消息,若没有则会让消费者等到超时,然后再次发起拉请求,并且broker也很配合,如果有消费者过来请求,有消息立马返回,没有则建立一个延迟操作,等条件满足再返回

 

 



4. 事务处理(消息队列用到的是事务消息)
2PC
二阶段提交,分别有协调者和参与者两个角色,二阶段分别是准备阶段和提交阶段

准备阶段就是协调者向参与者发送准备命令,这个阶段参与者除了事务的提交啥都别做了,提交阶段就是协调者看各个参与者都准备ok不,有ok的参与者就像其发送提交命令,不ok就发送回滚命令


2PC是强一致性的分布式事务,是同步阻塞的,效率低,存在协调者这个单点,容易出现单点故障,极端条件下存在数据不一致的风险,只适用于数据库层面的事务
TCC
是保证业务层面的事务

tcc是三阶段提交,try-confirm-cancel,try阶段是不会做真正的业务操作,只是占个坑,如果都try成功了,则会执行confirm方法,都来操作业务,有一个try失败了可以执行cancel方法,撤回修改

TCC对业务的耦合性很大,并且confirm和cancel需要注意幂等性
事务消息
主要适用于异步更新的场景,并且对数据的实时性要求不高的地方

他的目的是为了解决消息生产者和消息消费者数据一致性的问题
RocketMQ事务消息
RocketMQ也可以认为是二阶段提交,简单的说就是在事务开始时会先发送一个 半消息给broker,半消息的意思就是这个消息此时对于consumer是不可见的,也不是真正要存放到队列中的,而是放到一个特殊的队列中,发送完半消息在执行生产者的本地事务,根据本地事务执行结果再决定向broker发送提交消息还是回滚消息,但是发送提交或者是回滚消息失败怎么办,broker会定时的向producer反查这个事务的成功,具体就是producer会暴露一个接口给broker,来查询事务是否成功,若成功,就会将这个半消息恢复到正常的队列中,供消费者消费

 

 

 

 



kafka事务消息
kafka是要解决一次事务中发送多个消息的情况,保证多个消息之间的事务约束,即多条消息要么都发送成功,要么都失败

kafka的事务基本上是配合幂等机制来实现Exactly Once语义的

Exactly Once: 消息可靠性有3种 最少一次,恰好一次,最多一次,基本上消息队列都是通过最少一次配合幂等来实现恰好一次

kafka的事务有事务协调者角色,事务协调者是broker的一部分,开始事务时,生产者会向事务协调者发起请求表示事物开始,事务协调者会将这个消息记录到特殊的日志-事务日志中,然后生产者再发送真正的消息,,这里kafka和rocketMQ不一样,kafka会像对待正常消息那样让消费端来过滤这个消息,发送完后生产者会像事务协调者发送提交或者回滚请求,然后由事务协调者进行两阶段提交
5. kafka的索引设计
5.1 索引在kafka中的实践
kafka的索引是稀疏索引,这样可以避免索引文件占用过多内存,从而在内存中保存更多的索引。对应的broker端参数log.index.interval,bytes值,默认4kb,即4kb消息建一条索引

kafka中有三大类索引,位移索引,时间戳索引,和已中止事务索引,分别对应.index文件 .timeIndex .txnindex文件

与之对应的源码如下:

1.AbstractIndex.scala:抽象类,封装了所有的索引公共操作
2. OffsetIndex.scala:位移索引,保存了位移值和 对应磁盘物理位置的关系
3.TimeIndex.scala :时间戳索引,保存了时间戳和对应位移值的关系
4. TransactionIndex.scala:事务索引,启用kafka事务之后才会出现这个索引
AbstractIndex的定义已经在代码里注释了,其成员变量有两个,entrySize和_warmEntries
entrySize: 在OffsetIndex中是override def entrysize =8 ,8个字节,在TimeIndex中是override def entrysize = 12,12字节

 

 

在OffsetIndex中每个索引项都存储的是位移值和物理磁盘位置因此是4+4=8 ,但是位移值其实存储的是相对位移值,是真实位移值baseOffset的值,

为什么这么麻烦存储差值?

1.节省空间,一个索引节省4字节, 数据量庞大的系统就会节省很多
2. 内存宝贵,索引越短,可用资源越多,可存储的索引项越多
_warmEntries: kafka对索引查询的二分查找法进行了改造,将所有的索引项分为热区和冷区,这个可以让查询热数据时,遍历的page页都是固定的(操作系统一般都是用页来管理内存和单位缓存的,但是内存又是有限的,会通过LUR机制来淘汰内存),一般在固定为8192,二分查找是查找页面都能命中
6. kafka日志段读写解析
6.1 kafka的存储结构
kafka的topic可以有多个分区,分区就是最小的读取和存储结构,conusmer看似订阅的topic,实则是从分区中获取消息,producer发消息也是如此

每个分区包含一个log对象,在磁盘中就是一个子目录,子目录下会有多组日志段即多log Segment,每组日志段包含:消息日志文件(log结尾)、位移索引文件(以index结尾)、时间戳索引文件(以timeIndex结尾),还有其他的后缀文件,.txnIndex .deleted等等

 

 


7.kafka控制器事件处理全流程解析
7.1 controller核心组件(协调和管理整个kafka集群)
主题的管理、创建和删除主题
分区管理、增加和重分配分区
分区leader选举
监听broker相关变化,broker新增关闭等
元数据管理,向其他broker提供元数据服务

通过zookeeper临时节点和watcher机制来监控集群的变化,更新集群的元数据,并通知集群其他的broker进行相关操作,并发问题是通过将并发操作抽象成事件,通过阻塞队列和单线程处理来替换之前的多线程处理
7.2 controller的请求发送

controller事件处理线程会把事件封装成对应的请求,将请求放到broker的请求阻塞队列中,然后requestSendThread不断从阻塞队列中获取请求

 

 


7.2.1 leaderAndIsrRequest 告知broker主题相关分区leader和Isr副本都在哪些broker上
7.2.2 告知broker停止相关副本操作,用于删除主题场景或分区副本迁移场景
7.2.3 updateMetadataRequest 更新broker上的元数据
7.3 kafka通信模型(Reactor模式)

 

 


broker中有一个acceptor(mainReactor)监听新连接的到来,与新连接建连后轮询选择一个Processor(subReactor)管理这个连接

而processor会监听其管理的连接,事件到达后,将其封装成request放到共享队列中,然后IO线程不断的从队列中取出request进行处理,处理完成将响应放到对应的processor响应队列中,然后processor将response返回给客户端

acceptor只有一个,没有什么逻辑很轻量
processor默认是3个,对应参数是num.network.threads
IO线程池默认是8 参数num.io.threads
8.kafka为什么抛弃zookeeper
1. kafka本身就是中间件,zookeeper也是,一个中间件依赖另一个中间件不合理
2. zookeeper具有强一致性,如果某个节点上数据变更,会通知其他节点同步更新,半数以上节点完成同步才行,性能较差
3.zookeeper是小量的分布式协调注册中心(存储系统),如果写入的数据量过大,zookeeper性能就会下降,导致watch的延迟和丢失,并且zookeeper也是分布式的,节点也需要选举,选举期间服务丢失,对kafka很不友好
9. 抛弃zookeeper后kafka的设计
将元数据存储到自己内部,利用log存储机制来保存元数据,有一个元数据主题来管理元数据,和对消息的处理一样,并且为了解决controller选举新增了KRaft协议,是基于raft的
10.RocketMQ之Broker存储
rocketMQ存储采用的是本地文件存储系统,效率高且可靠,主要设计三种类型的文件,分别是commitLog,ConsumeQueue,IndexFile

commitLog存储了所有主题的消息,单个commitLog默认1G,并且以文件名以起始偏移量命名,固定20位,不足补0,消息顺序写入,超过文件大小则会开启下一个文件

consumeQueue是消息消费队列,可以认为是commitLog中的消息索引,consumeQueue只会存储8字节的commitLog物理偏移量,4字节的消息长度和8字节tag的哈希值,固定20字节,实际上consumeQueue对应的是某个topic下的queue,每个文件大概5.72M,由30W消息组成

posted @ 2023-04-03 10:12  MasterOfLife  阅读(134)  评论(0)    收藏  举报