且就洞庭赊月色,将船买酒白云边
一、概述
上篇文章为大家简单分析了下RocketMQ生产者相关实现,今天就带着大家一起看看RocketMQ消费者相关设计。RocketMQ消费者和Kafka消费者类似,都是以组的形式去定义,一个消费者组里可能包含多个消费者。每个消费者者组可以订阅多个主题.
消费组之间有集群和广播两种消费模式:集群模式 and 广播模式
消费者和服务器之间的消息传送也有两种方式:推模式 and 拉模式
RocketMQ和Kakfa一样,只支持局部消息有序,也就是队列维度或者partition维度,不支持全局维度消费有序,除非牺牲高可用把队列数量设置为1。
二、消息队列负载
RocketMQ提供了多种队列负载算法,比较常用的就是AVG、AVG_BY_CIRCLE。如果消息在队列上分布均匀,就推荐用第一种,否则用第二种;对于消费过程中碰到消费者队列增加或者减少、消费者增加或者减少,RocketMQ和Kafka一样,都触发消费队列的再平衡,对消费者队列进行重新分配
三、并发消费模型
RockerMQ的MessageListener回调函数提供了两种消费模式,有序消费模式MessageListenerOrderly和并发消费模式MessageListenerConcurrently。MessageListenerConcurrently是拉取到新消息之后就提交到线程池去消费,而MessageListenerOrderly则是通过加分布式锁和本地锁保证同时只有一条线程去消费一个队列上的数据。
要想实现顺序消费需要加三把锁
- broker端的分布式锁
在负载均衡的处理新分配队列的updateProcessQueueTableInRebalance方法,以及ConsumeMessageOrderlyService服务启动时的start方法中,都会尝试向broker申请当前消费者客户端分配到的messageQueue的分布式锁。
broker端的分布式锁存储结构为ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>>,该分布式锁保证同一个consumerGroup下同一个messageQueue只会被分配给一个consumerClient。
获取到的broker端的分布式锁,在client端的表现形式为processQueue. locked属性为true,且该分布式锁在broker端默认60s过期,而在client端默认30s过期,因此ConsumeMessageOrderlyService#start会启动一个定时任务,每过20s向broker申请分布式锁,刷新过期时间。
而负载均衡服务也是每20s进行一次负载均衡。
broker端的分布式锁最先被获取到,如果没有获取到,那么在负载均衡的时候就不会创建processQueue了也不会提交对应的消费请求了。
2. messageQueue的本地synchronized锁
在执行消费任务的开头,便会获取该messageQueue的本地锁对象objLock,它是一个Object对象,然后通过synchronized实现锁定。
这个锁的锁对象存储在MessageQueueLock.mqLockTable属性中,结构为ConcurrentMap<MessageQueue, Object>,所以说,一个MessageQueue对应一个锁,不同的MessageQueue有不同的锁。
因为顺序消费也是通过线程池消费的,所以这个synchronized锁用来保证同一时刻对于同一个队列只有一个线程去消费它。
3. ProcessQueue的本地consumeLock
假设该消息队列因为负载均衡而被分配给其他客户端B,但是由于客户端A正在对于拉取的一批消费消息进行消费,还没有提交消费点位,如果此时客户端A能够直接请求broker对该messageQueue解锁,这将导致客户端B获取该messageQueue的分布式锁,进而消费消息,而这些没有commit的消息将会发送重复消费。
说这把锁的作用,就是防止在消费消息的过程中,该消息队列因为发生负载均衡而被分配给其他客户端,进而导致的两个客户端重复消费消息的行为
四、消息消费进度反馈
RocketMQ客户端消费一批数据后,需要像broker同步消费进度,broker会记录消费进度,这样在客户端重启或者队列重平衡的时候能根据消费进度重新向broker获取信息。核心逻辑如下:
- 消费者线程在处理完一批数据后,会将消息消费进度存储在本地内存中。
- 客户端会启动一个定时线程,每5s将存储在本地内存中所有队列消息消费偏移量提交到broker当中。
- broker将收到的消息消费进度存储在内存当中,每隔5s将消息消费偏移量存储到磁盘文件中。
五、消费者启动流程
消费者的默认实现是 org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl类,启动方法为start方法,流程如下:
5.1 构造主题订阅构造信息
5.2 初始化client信息
5.3 初始化消费进度
- 如果采用的集群消费模式,则消费进度保存在broker上
- 如果采用的广播模式,则消费进度保存在客户端上
5.4 创建消费线程
这里根据是否是顺序消费来决定到底创建哪种消费线程。
5.5 注册消费者
这里mQClientFactory类型为MQClientInstance,同一个JVM中所有消费者、生产者持有同一个MQClientInstance对象,并且只会启动一次。
六、消息拉取线程启动
前文也介绍过,消息消费有两种模式:广播模式和集群模式。广播模式比较简单,每一个消费者需要拉取订阅主题下所有消费队列的消息。本节主要以集群模式为例,在集群模式下,同一个消费组内有多个消费者,同一个主题存在多个队列,那么RocketMQ是如何实现消费者与队列之间的负载的呢?集群模式下负载实现的基本原理是一个消费队列同时只能被一个消费者消费,一个消息消费者可以同时消费多个消息队列。 RocketMQ负责消息拉取的类为org.apache.rocketmq.client.impl.consumer.PullMessageService,继承了ServiceThread,同时ServiceThread继承了Runnable,所以其本质上就是一个线程,通过run方法启动:
核心逻辑是从pullReqeustQueue中获取一个PullRequest消息拉取任务,那么PullRequest是从哪里被添加的呢?查看其调用可以看到,其实PullMessageService提供了两个方法
分别为延迟添加和立刻添加两种方式将PullRequest放入pullRequestQueue中,查看调用栈如下:
一处是在Rebalance里调创建的,一处是在pullRequest执行完任务后,又讲该对象放入了pullReqeustQueue。下面会分析Rebalance的实现,此处暂时忽略。
查看PullRequest核心属性如下:
- ProcessQueue
ProcessQueue是MessageQueue在消费端的快照。PullMessageService从服务器默认每次拉取32条消息,按照队列中偏移量的顺序存放在ProcessQueue中,PullMessageService将消息提交到消费者消费线程,消费成功后在从ProcessQueue中移除。
七、消息拉取基本流程
消息拉取主要分为3个步骤:
- 客户端封装消息拉取请求
- 消息服务器查找消息并返回
- 客户端处理返回的消息
7.1 第一步:客户端端拉取
消息拉取的入口是org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl#pullMessage方法
- 获取ProcessQueue
- 消息流控
可以看到消息流控主要消费数量和消费间隔两个维度限制。 如果当前队列的的消息条数或者消息大小超过阈值以后,放弃本次拉取,50ms在重新拉取; 针对顺序消费者和并发消费者,会进行消费间隔的判断,如果超过消费间隔阈值,也会放弃本次拉取,50ms后重新拉取
- 获取主题的订阅信息
- 构建消息拉取标记
- 调用pullKernelImpl与服务端交互
7.2 第二步:服务端处理拉取请求
消息拉取命令的类型是PULL_MESSAGE
根据reqeustCode类型找到对应的broker端处理消息拉取的入口是org.apache.rocketmq.broker.processor.PullMessageProcessor#processRequest
- 根据参数获取相关主题信息并构造相关消息过滤器
- 调用MessageStore.getMessage()查找消息
- 根据PullResult填充responseHeader
- 转换GetMessageResult与 Response进行状态编码转换
- 如果CommitLog标记为可用并且当前节点为主节点,则更新消费进度
7.3 第三步:客户端处理响应
回调消息拉取客户端调用入口org.apache.rocketmq.client.impl.MQClientAPIImpl#pullMessageAsync, Netty在收到服务端响应后,会回调PullCallback的onSuceess或者onException方法。
processPullResponse代码实现如下:
onSuccess方法实现如下:
首先调用pullAPIWrapper的processPullResult方法,将消息字节数组解码成消息列表并填充msgFoundList,对消息进行过滤,然后调用pullCallBack.onSucees处理消息列表,其实现如下:
FOUND状态对应找到了消息,还有一些其他状态比如:NO_NEW_MSG(没有新消息)、NO_MATCHED_MSG(没有匹配消息),这种状态直接使用服务端校正的偏移量进行下一次消息的拉取。
7.4 总结
以上就是消息拉取的主要流程,涉及到PullMessageService线程、DefaultMQPushConsumerImpl、MQClientAPIImpl、PullMessageProcessor等类。
八、消息队列负载与重分配
8.1 流程分析
RocketMQ消息队列重平衡是由RebalanceService线程实现的,一个MQClientInstance持有一个RebalanceService,查看RebalanceService#run方法实现如下:
doRebalance实现如下:
最终调用的是RebalanceImpl#doRebalance方法
rebalanceByTopic方法主要是便利订阅信息,对每个主题的队列进行重新负载,根据广播模式还是集群模式,分别走不同的处理,这里以集群模式为例:
最后调用RebalancePushImpl#dispatchPullRequest将PullReqeust放入PullMessageService中
8.2 小结
- RebalanceService每20s对消费者订阅的主题进行一次队列重新分配
- 每一次分配都会从broker实时查询获取主题的所有队列
- 对于新分配的消费队列会创建PullReqeust对象