目录
前言
前面我们讲到了生产者的启动以及发送消息到Broker中,然后再到Broker存储消息都进行了源码分析。有了Broekr这个中间“媒人”,那么光有生产者怎么能行呢,我们的消费者是不是也该出场了呢。接下来我们就对消费者的启动进行流程源码走读吧!
准备
源码地址:GitHub - rocketmq/rocketmq: Mirror of Apache RocketMQ
目前最新版本为:5.2.0
那么我们在idea上切换分支为 release-5.2.0
订阅类型
Consumer有两种从Broker中获取message的模式
- 拉(pull)模式: 消费者主动从 Broker 拉取消息,适用于需要控制消息拉取频率和批量处理的场景
- 推(push)模式: 消息由 Broker 主动推送给消费者,消费者在接收到消息后进行处理。推模式的特点是实时性较好,适用于对延迟敏感的场景
Conmuser有两种从Broker中消费Message的模式
- 广播模式:每条消息会被消费者组中的买一个消费者实例消费一次
- 集群模式:每条消息只会被消费者组中的一个消费者实例消费
启动消费者
通过上面我们知道消费者有两种获取消息的方式,所以源码中官方提供了两个启动类:
- PushConsumer
- PullConsumer
本篇选择的是比较常用的PushConsumer推模式启动,源码实例位置和生产者在同一个包下
//源码位置
//包路径:org.apache.rocketmq.example.simple
//文件名:PushConsumer
//行数:32
public static void main(String[] args) throws InterruptedException, MQClientException {
//实例话消费者对象并设置消费者组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(CONSUMER_GROUP);
//设置NameServer地址
consumer.setNamesrvAddr(NAMESRV_ADDR);
//设置订阅的Topic
consumer.subscribe(TOPIC, "*");
//设置消息偏移量类型
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
//注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//启动
consumer.start();
System.out.printf("Consumer Started.%n");
}
- CONSUMER_GROUP:消费者组必须先在控制台创建完成再设置,如果消费者组不存在启动会报错
控制台创建消费者参数说明
- clusterName:MQ集群名称
- brokerName:Broker名称
- groupName:消费者组名称
- consumeEnable:消费者开关
- consumeBroadcastEnable:集群模式开关,不开启则为广播模式
- retryQueueNums:消费失败重试次数
- brokerId
- whichBrokerWhenConsumeSlowly: 当消费者组中的某些消费者实例消费速度较慢,无法及时处理消息时,RocketMQ 可以通过配置该参数来指定从哪个 Broker 拉取消息,从而避免因消费速度不一致导致的消息堆积问题
- TOPIC:topic后面设置的“*”表示获取全部tag标签不过滤,该参数是一个表达式,可以设置指定的tag标签(后面源码解析中会单独解析)
查看控制台可知,Broker中已经堆积了一定数量的消息,本篇我们订阅的是xxf-group消费者组
启动消费者实例,如果顺利打印日志,则表示消费成功了
打开控制台,查询消费者实例消息以及消息数量情况
源码解析
订阅消息
上面消费者启动的时候说到了topic以及相关tag标签的设置,是否好奇具体是咋个解析的呢?
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:1251
SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(topic, subExpression);
this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData);
首先创建了一个SubscriptionData对象,用于存储消费者的订阅信息
SubscriptionData对象有四个关键参数
- topic:就是指定订阅的topic名称
- subString:启动时设置的tag表达式
- Set<String> tagsSet:如果subString不为"*",则会解析表达式后设置tag,与下面code一一对应
- Set<Integer> codeSet:值为tagsSet中tag的hashCode,和上面一一对应
最后把封装好的SubscriptionData保存到一个map中,ConcurrentMap<String /* topic */, SubscriptionData> subscriptionInner,key为topic名称,value则为该对象
消费者初始化
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumer
//行数:761
setConsumerGroup(NamespaceUtil.wrapNamespace(this.getNamespace(), this.consumerGroup));
this.defaultMQPushConsumerImpl.start();
进入启动源码,你会发现基本和第一篇讲到的生产者启动流程一样
- 首先对消费者组的名称进行非空判断以及名称长度校验等
- 最后进入消费者启动核心方法
订阅消费重试Topic
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:1211
switch (this.defaultMQPushConsumer.getMessageModel()) {
case BROADCASTING:
break;
case CLUSTERING:
final String retryTopic = MixAll.getRetryTopic(this.defaultMQPushConsumer.getConsumerGroup());
SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(retryTopic, SubscriptionData.SUB_ALL);
this.rebalanceImpl.getSubscriptionInner().put(retryTopic, subscriptionData);
break;
default:
break;
}
消息重试是RocketMq的高级特性。根据源码可知,消费重试功能只支持集群模式,前面我们控制台创建消费者时就设置了重试次数retryQueueNums
- 消费重试会单独订阅一个retryTopic,专门用于消费重试,名称组成为%RETRY% + consumeGroupName
- tag标签设置为“*”表示重试全部的消息
- 最终将重试的订阅对象SubscriptionData也同样设置到subscriptionInner map中
下面会讲到消息重试的逻辑
初始化rebalanceImpl
rebalanceImpl该对象主要用于MessageQueue资源的重平衡,rebalance负载均衡可以这样理解
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:929
//设置消费者组名称
this.rebalanceImpl.setConsumerGroup(this.defaultMQPushConsumer.getConsumerGroup());
//设置消费模式
this.rebalanceImpl.setMessageModel(this.defaultMQPushConsumer.getMessageModel());
//设置分配消息队列策略
this.rebalanceImpl.setAllocateMessageQueueStrategy(this.defaultMQPushConsumer.getAllocateMessageQueueStrategy());
this.rebalanceImpl.setmQClientFactory(this.mQClientFactory);
如果消费者数量或者MessageQueue的数量发生变更时就会触发资源重平衡保证资源的合理分配
负载均衡策略通过AllocateMessageQueueStrategy设置
- 轮询策略(AllocateMessageQueueAveragely):平均分配消息队列给消费者实例。
- 一致性哈希策略(AllocateMessageQueueConsistentHash):基于一致性哈希算法分配消息队列,适用于消息处理需要顺序消费的场景。
当前只是初始化重平衡对象,后续逻辑中才会有相关操作,本篇不再深入解析
初始化pullAPIWrapper
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:934
if (this.pullAPIWrapper == null) {
this.pullAPIWrapper = new PullAPIWrapper(
mQClientFactory,
this.defaultMQPushConsumer.getConsumerGroup(), isUnitMode());
}
this.pullAPIWrapper.registerFilterMessageHook(filterMessageHookList);
- PullAPIWrapper 是一个封装类,用于处理消息拉取相关的逻辑
- 最后就是注册消息过滤钩子,用于在消息拉取后对消息进行过滤处理
这里也只是初始化,具体拉取逻辑逻辑本篇不再深入
初始化偏移量offsetStore
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:945
case BROADCASTING:
this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
break;
case CLUSTERING:
this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
由源码我们知道,根据消费模式分为两种实例化方案
- 广播模式:构建本地文件偏移量对象LocalFileOffsetStore, 消费进度会存储在本地文件系统中 。因为每个消费者实例都会消费所有的消息队列,所以需要每个消费者实例自己单独保存消费进度,互不干扰
- 集群模式:构建远程Broker偏移量对象RemoteBrokerOffsetStore, 消费进度会存储在Broker端 。因为 每个消息队列只会被消费者组中的一个消费者实例消费 , 消费进度在 Broker 端统一存储,消费者实例共享消费进度
确定消费模式
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:958
if (this.getMessageListenerInner() instanceof MessageListenerOrderly) {
this.consumeOrderly = true;
this.consumeMessageService =
new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
//POPTODO reuse Executor ?
this.consumeMessagePopService = new ConsumeMessagePopOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
} else if (this.getMessageListenerInner() instanceof MessageListenerConcurrently) {
this.consumeOrderly = false;
this.consumeMessageService =
new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
//POPTODO reuse Executor ?
this.consumeMessagePopService =
new ConsumeMessagePopConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
}
根据上面源码我们可得,消费模式也有两种:
- 顺序消费-MessageListenerOrderly:按照消息发送的顺序进行消费
- 并发消费-MessageListenerConcurrently:可以同时对多条数据进行消费,不考虑顺序
是否记得我们在前面启动消费者案例PushConsumer注册消息监听器时,已经重写了方法MessageListenerConcurrently 的 consumeMessage 函数,选择的是并发消费模式
是否看到还实例化了一个POPService对象,POP是什么?
POP 服务(POP Service)指的是一种优化的消息消费模式,通常用来提升消息消费的性能和效率。这里作个简单了解就行
启动消费模式服务
开启清除超时消息定时任务
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:973
this.consumeMessageService.start();
深入启动源码中,会返现里面维护了一个定时任务,清除过期的消息
第一次启动停顿时间:15m
每次间隔时间:15m
深入定时任务中的清除逻辑实现
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:ConsumeMessageConcurrentlyService
//行数:973
Iterator<Map.Entry<MessageQueue, ProcessQueue>> it =
this.defaultMQPushConsumerImpl.getRebalanceImpl().getProcessQueueTable().entrySet().iterator();
首先可以看到我们前面提到的重平衡出现了getRebalanceImpl(),这里只是获取了内部维护的一个Map数据。然后循环map Values值进行处理
ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable;
- MessageQueue:为消息队列
- ProcessQueue:消费者处理队列,相当于是MessageQueue对象的快照(本篇先简单了解,后面会单独一篇解析消费消息过程)
processQueueTable是一个并发映射,用于存储消息队列(MessageQueue)与处理队列(ProcessQueue)的对应关系 MessageQueue与ProcessQueue是一一对应的
高级特性-消息重试
判断消息类型
进入ProcessQueue对象方法中
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:ProcessQueue
//行数:75
if (pushConsumer.isConsumeOrderly()) {
return;
}
- 顺序消息不支持消息重试,这里进行了校验
判断过期时间
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:ProcessQueue
//行数:87
//获取消息开始时间
String consumeStartTimeStamp = MessageAccessor.getConsumeStartTimeStamp(msgTreeMap.firstEntry().getValue());
//判断是否超时
if (StringUtils.isNotEmpty(consumeStartTimeStamp) && System.currentTimeMillis() - Long.parseLong(consumeStartTimeStamp) > pushConsumer.getConsumeTimeout() * 60 * 1000) {
msg = msgTreeMap.firstEntry().getValue();
}
这里主要是在操作一个Map,TreeMap<Long, MessageExt> msgTreeMap他的作用是用于存储还没有消费的消息。
这里为什么使用的是TreeMap呢,而不是之前使用的CurrentMap?
- TreeMap是一个有序队列,保证了键的自然顺序
- TreeMap 中的取出第一条消息是一个高效的操作,因为 firstEntry() 是一个 O(1) 操作,直接返回第一个元素。
- 不需要遍历整个队列,只需检查第一个元素,减少了时间复杂度和性能开销
- 这里调用msgTreeMap.firstEntry().getValue(),表示取出第一条消息,因此第一条消息也是最早到达消费者的消息,后面消息以此类推。
- 然后再判断当前消息中的开始时间与当前时间做差,如果时间差超过默认的超时时间(15m),这说明消息已经超时了,需要进行后续的处理。
选择重试策略
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:755
if (brokerName != null && brokerName.startsWith(MixAll.LOGICAL_QUEUE_MOCK_BROKER_PREFIX)
|| mq != null && mq.getBrokerName().startsWith(MixAll.LOGICAL_QUEUE_MOCK_BROKER_PREFIX)) {
needRetry = false;
sendMessageBackAsNormalMessage(msg);
} else {
String brokerAddr = (null != brokerName) ? this.mQClientFactory.findBrokerAddressInPublish(brokerName)
: RemotingHelper.parseSocketAddressAddr(msg.getStoreHost());
this.mQClientFactory.getMQClientAPIImpl().consumerSendMessageBack(brokerAddr, brokerName, msg,
this.defaultMQPushConsumer.getConsumerGroup(), delayLevel, 5000, getMaxReconsumeTimes());
}
提供了两种重试策略,根据Broker名称的前缀来进行判断的
- 第一种如果Broker名称前缀加了 "syslo",不会进行重试,而是用生产者的方式重新发送一条新的消息给Broker。深入sendMessageBackAsNormalMessage()方法会看到调用了this.mQClientFactory.getDefaultMQProducer().send(newMsg)
- 第二种则是正常的重试方式,通过消费者远程调用broker,并设置相关的参数包括如消费者组、延迟级别(3)、超时时间(5s)和最大重试次数(16),最后会设置到一个请求头ConsumerSendMsgBackRequestHeader中进行发送。
默认情况下是使用第二种方式,第一种需要特殊场景中,还需要设置一些特殊配置,正常情况下不会使用。
最后重试完成后,会将当前的超时消息从msgTreeMap中移除,然后开始下一轮处理,继续调用firstEntry() ,直到存在不满足条件的消息则直接结束本次定时任务,因为前面也说到过使用TreeMap,最前面的就是最先进来的消息,那么如果第一个消息都没有超时,说明后面的所有消息更不可能超时了,就没有必要所有消息都遍历一次,这样大大提高了mq的性能。
注册消费者实例
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:977
boolean registerOK = mQClientFactory.registerConsumer(this.defaultMQPushConsumer.getConsumerGroup(), this);
这里的注册其实将消费者实例对象存储到一个消费者Map中ConcurrentMap<String, MQConsumerInner> consumerTable;key是消费者组名称,vlaue就是实例对象。这个在之前生产者启动时也提到过,生产者也有一个同样的Map用于存储生产者的实例producerTable
启动相关服务组件
//源码位置
//包路径:org.apache.rocketmq.client.impl.consumer
//文件名:DefaultMQPushConsumerImpl
//行数:986
mQClientFactory.start();
这里的服务启动以及启动定时器和生产者一模一样,本篇就不在重复概述了
总结
本篇深入源码对消费者的启动流程有了一个更深层的理解,里面还讲到了对于TreeMap的使用,在MQ中大大提高了程序的性能。那么消费者启动成功了,就需要获取消息进行消费,这个操作又是怎么样实现的呢?下一篇我们继续深入源码对消费者消息的获取逻辑进行分析。