本文基于RocketMQ 4.7.1版本
在上一篇文章介绍了如何创建消费者,本文来分析一下DefaultMQPushConsumer是如何消费消息的。
本文首先介绍DefaultMQPushConsumer的启动流程,之后介绍它是如何从broker拉取消息,最后介绍broker如何根据消费者的请求从消息文件中取出消息并发送。
文章目录
一、启动DefaultMQPushConsumer
启动消费者是通过调用start()方法完成的。下面首先看一下整个流程。
1、检查配置
在start方法开始时,首先要校验DefaultMQPushConsumer配置是否合法,这里校验的内容非常多,比如:
- 消费组不能为null,组名不能为DEFAULT_CONSUMER,、;
- 消费模式不能为空,可选BROADCASTING或者CLUSTERING,默认是CLUSTERING;
- 需要设置消费者开始消费时,从哪个位置开始消费消息,默认从最新的位移开始;
- 是否设置监听器;
- 是否设置订阅信息;
- 检查消费者线程数是否符合要求;
- 检查一次拉取最多拉取消息个数是否符合要求,范围是[1,1024]。
除了上面这些检查,还有别的一些检查,这些检查中任意一项不符合要求,就会报错,停止消费者启动。
2、PullAPIWrapper
PullAPIWrapper从名字上可以看出该类与拉取消息有关。该类的作用有两个:
- 构建PullMessageRequest对象,负责从broker拉取消息;
- 成功拉取回消息后,对拉取到的消息做回调处理。
3、ConsumeMessageConcurrentlyService
在DefaultMQPushConsumer里面设置的监听器会封装到ConsumeMessageConcurrentlyService中,该类内部含有一个线程池,通过线程池回调监听器,如果一次拉取了多个消息,可以并发回调多个消息的监听器。线程池线程数默认是20。
二、消费消息
上图中MQClientInstance.start()方法里面会启动拉取消息服务PullMessageService,这是一个定时任务,作用是从broker拉取消息。下面先来看一下它的run()方法。
@Override
public void run() {
log.info(this.getServiceName() + " service started");
while (!this.isStopped()) {
try {
PullRequest pullRequest = this.pullRequestQueue.take();//取出拉取请求
this.pullMessage(pullRequest);//访问broker拉取消息
} catch (InterruptedException ignored) {
} catch (Exception e) {
log.error("Pull Message Service Run Method exception", e);
}
}
log.info(this.getServiceName() + " service end");
}
public void executePullRequestImmediately(final PullRequest pullRequest) {
try {
this.pullRequestQueue.put(pullRequest);
} catch (InterruptedException e) {
log.error("executePullRequestImmediately pullRequestQueue.put", e);
}
}
run()方法不断的循环从pullRequestQueue队列中获得拉取消息的请求,然后根据请求内容从broker获取消息。下面是PullRequest类的属性:
public class PullRequest {
private String consumerGroup;
private MessageQueue messageQueue;
private ProcessQueue processQueue;//起着多种作用,比如记录队列统计信息,锁,队列状态,临时存储拉取的消息
private long nextOffset;
private boolean lockedFirst = false;
}
PullRequest提供了拉取消息的位移、消费组、队列等信息。
那么pullRequestQueue队列中的数据从哪来?在该类中还有一个方法:
public void executePullRequestImmediately(final PullRequest pullRequest) {
try {
this.pullRequestQueue.put(pullRequest);
} catch (InterruptedException e) {
log.error("executePullRequestImmediately pullRequestQueue.put", e);
}
}
可以看到该方法将请求信息放入pullRequestQueue中。executePullRequestImmediately方法有两个地方会调用:再平衡服务和消费消息回调。
当消费者启动或者有新的队列分配给消费者时,再平衡服务会将该队列的消费位移等信息组装成PullRequest对象,然后放入pullRequestQueue中,这样PullMessageService就可以消费新队列的消息了。
每次消费者从broker拉取消息后,broker都会返回下一个消息的位移,在消费消息回调中,会根据下一个消息位移、队列等信息组装PullRequest对象,然后放入pullRequestQueue中,这样对于已经开始消费的队列可以不停的消费下去。
下面我们回到PullMessageService的run方法里面,继续看this.pullMessage()方法。
private void pullMessage(final PullRequest pullRequest) {
//得到内部代表消费者的对象,MQConsumerInner是真正的消费者,
//DefaultMQPushConsumer相当于配置对象,记录配置信息
//这里得到的MQConsumerInner实现类是DefaultMQPushConsumerImpl
final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());
if (consumer != null) {
DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
impl.pullMessage(pullRequest);//拉取消息
} else {
log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
}
}
DefaultMQPushConsumerImpl.pullMessage()方法逻辑比较复杂,这里简单介绍一下它的处理流程:
- 检查DefaultMQPushConsumerImpl的状态是否已经停止;
- 检查是否需要做流控,将processQueue对象中临时存储的未消费消息个数与DefaultMQPushConsumer.pullThresholdForQueue做比较,如果前者大,则创建定时任务延迟50ms拉取消息,默认DefaultMQPushConsumer.pullThresholdForQueue=1000;
- 检查已经拉取的未消费消息的字节数,如果超过100M(可以通过DefaultMQPushConsumer.pullThresholdSizeForQueue配置),则需要做流控,与上一步一样,也是创建定时任务延迟50ms拉取消息;
- 检查已经拉取的未消费消息的最大位移与最小位移之间的差值是否超过2000(可以通过DefaultMQPushConsumer.consumeConcurrentlyMaxSpan配置),如果超过,则需要做流控,与上一步一样,也是创建定时任务延迟50ms拉取消息;
- 构建PullMessageRequestHeader对象,该对象中包含了队列号、主题、要拉取消息的位移、提交位移、最大拉取消息个数等;
- 获取broker的地址信息,向该broker发送请求,请求发送是异步处理的,当前线程发送完就继续处理下一个拉取请求,待broker返回后自动调用回调逻辑。
最后来看一下拉取回消息后的回调逻辑。
//方法里面的PullResult是broker返回的,里面包含了本次拉取到的所有消息和它们的位移
PullCallback pullCallback = new PullCallback() {
//拉取消息成功的回调方法
@Override
public void onSuccess(PullResult pullResult) {
if (pullResult != null) {
//调用pullAPIWrapper.processPullResult的回调方法,该方法完成两项工作:
//1、根据tag标签过滤消息,不符合tag的消息直接丢弃;
//2、向每个消息中设置三个属性:本次拉取消息的最大位移、最小位移和broker name
pullResult = DefaultMQPushConsumerImpl.this.pullAPIWrapper.processPullResult(pullRequest.getMessageQueue(), pullResult,
subscriptionData);
switch (pullResult.getPullStatus()) {
case FOUND:
//表示获得了消息
long prevRequestOffset = pullRequest.getNextOffset();
//设置下一次拉取的位移,nextBeginOffset是broker返回的
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
long pullRT = System.currentTimeMillis() - beginTimestamp;
DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullRT(pullRequest.getConsumerGroup(),
pullRequest.getMessageQueue().getTopic(), pullRT);//做统计
long firstMsgOffset = Long.MAX_VALUE;
if (pullResult.getMsgFoundList() == null || pullResult.getMsgFoundList().isEmpty()) {
//如果没有拉取到任何消息,则调用PullMessageService.executePullRequestImmediately方法新增一个拉取请求
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
} else {
firstMsgOffset = pullResult.getMsgFoundList().get(0).getQueueOffset();
DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullTPS(pullRequest.getConsumerGroup(),
pullRequest.getMessageQueue().getTopic(), pullResult.getMsgFoundList().size());
//将拉取到的消息缓存到processQueue中
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());//回调我们自定义的监听器
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
pullResult.getMsgFoundList(),
processQueue,
pullRequest.getMessageQueue(),
dispatchToConsume);
//检查是否设置了拉取间隔,默认是0,如果是0,则则调用PullMessageService.executePullRequestImmediately方法新增一个拉取请求,
// 否则,创建一个定时任务,延迟this.defaultMQPushConsumer.getPullInterval()毫秒后再新增一个拉取请求
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}
}
//检查拉取参数是否有问题
if (pullResult.getNextBeginOffset() < prevRequestOffset
|| firstMsgOffset < prevRequestOffset) {
log.warn(
"[BUG] pull message result maybe data wrong, nextBeginOffset: {} firstMsgOffset: {} prevRequestOffset: {}",
pullResult.getNextBeginOffset(),
firstMsgOffset,
prevRequestOffset);
}
break;
case NO_NEW_MSG:
//如果每个新消息,则新增一个拉取请求
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
break;
case NO_MATCHED_MSG:
//同上
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
break;
case OFFSET_ILLEGAL:
//如果位移非法,则停止拉取该队列消息,后面由再平衡服务重新处理
log.warn("the pull request offset illegal, {} {}",
pullRequest.toString(), pullResult.toString());
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
pullRequest.getProcessQueue().setDropped(true);
DefaultMQPushConsumerImpl.this.executeTaskLater(new Runnable() {
@Override
public void run() {
try {
DefaultMQPushConsumerImpl.this.offsetStore.updateOffset(pullRequest.getMessageQueue(),
pullRequest.getNextOffset(), false);
DefaultMQPushConsumerImpl.this.offsetStore.persist(pullRequest.getMessageQueue());
DefaultMQPushConsumerImpl.this.rebalanceImpl.removeProcessQueue(pullRequest.getMessageQueue());
log.warn("fix the pull request offset, {}", pullRequest);
} catch (Throwable e) {
log.error("executeTaskLater Exception", e);
}
}
}, 10000);
break;
default:
break;
}
}
}
//拉取消息异常的回调方法
@Override
public void onException(Throwable e) {
if (!pullRequest.getMessageQueue().getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
log.warn("execute the pull request exception", e);
}
//延迟3s后重新拉取
DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
}
};
到此我们分析完了消费者拉取消息并处理的整个过程,下面再分析一下broker收到拉取请求后是如何处理的。
三、broker处理消息拉取请求
broker收到消费者的拉取请求后,会将请求转发给PullMessageProcessor.processRequest()方法处理。下图是该方法的处理流程。
处理流程的第一步是检查配置,这里的检查项非常多,举几个例子:
- 检查broker是否允许读消息;
- 查找订阅组配置信息SubscriptionGroupConfig,如果不存在,则创建一个,并将信息持久化到subscriptionGroup.json文件中,订阅组配置也可以通过mqadmin工具设置,订阅组配置对象记录的配置主要有重试队列有几个,最大重试次数,消费组是否允许消费;
- 检查消费组是否允许读消息;
- 检查主题是否存在,检查主题是否允许读消息;
- 检查拉取消息的队列号是否合法;
- 检查是否存在消费组信息ConsumerGroupInfo,这里说明一下消费组与订阅组,消费组和订阅组其实是一个意思,只不过叫了两个不同的名字而已,消费组信息对象ConsumerGroupInfo主要记录的是该组消费的主题有哪些,消息分发模式(是广播还是集群),新接入的消费者从哪里开始消费;
- 检查订阅组对象与消费组配置对象的消息消费模式配置是否一致;
接下来重点介绍从消息文件中读取消息的原理。
broker启动的时候会创建服务ReputMessageService,同时创建一个线程执行该服务的doReput()方法。该方法不断的读取消息文件(commitlog目录下的文件)获得刚写入文件的消息,然后根据该消息创建对象DispatchRequest,DispatchRequest对象里面包含了主题、队列、在消息文件中的位移、消息大小、存储时间、过滤规则、在队列中的逻辑位移等信息。之后将DispatchRequest对象转发给CommitLogDispatcherBuildConsumeQueue,CommitLogDispatcherBuildConsumeQueue根据DispatchRequest中记录的队列找到对应的ConsumerQueue对象,之后调用ConsumerQueue.putMessagePositionInfo()方法,putMessagePositionInfo()根据DispatchRequest对象向消费文件(消费文件位于consumequeue目录下,ConsumerQueue对象中记录当前活动的消费文件)里面写入三个信息:消息在消息文件里面的偏移量(long型)、消息大小(字节数,int型)、过滤条件(long型),这三个信息一共占20个字节,每个消息都会在消费文件里面写入这20个字节数据,所以消息文件里面的消息与这20个字节的数据是一一对应的,返回来根据这20个字节的数据可以从消息文件里面找到对应的消息。为了表述方便,在本文后面这20个字节的数据称为索引记录。
这里还要说明一点,消费文件指的是每个主题的读队列对应的文件,比如topicTest主题的1号读队列对应的消费文件地址是consumequeue/topicTest/1/00000000000000000000,消费文件的文件名是由20个数字组成的,表示该文件里面第一个索引记录在本队列中的物理位移。消费端每次拉取消息时,会告知broker请求该队列里面的第几个消息,这个在rocketmq中称为逻辑位移,由于每个索引记录占20个字节,所以逻辑位移*20便可以得出该索引记录的物理位移,然后使用物理位移与消费文件名比较,便可以确认该物理位移在哪个消费文件中,包括在消费文件中的物理位置也可以确定。
找到了消息的索引记录,便也找到了该消息在消息文件里面的偏移量,也就是消息的物理位移,消息文件的命名规则与消费文件一样,所以通过比较文件名也就可以确定消息在哪个消息文件中,将物理位移减去文件名的值,便可以得出该消息在该文件里面的相对位移,进而找到消息内容。下图是消费文件与消息文件的一个关系图。
不知道大家是否有一个疑问,ReputMessageService服务如何找到最新写入的消息,而且准确无误的读取到每一个写入的消息。其实在ReputMessageService对象中有一个属性reputFromOffset,该属性记录下一个读取的消息位移,每读取一个消息,该属性加上消息的长度便可以得出下一个消息的位移,这样随着不断读取消息,reputFromOffset不断增大,使得每个写入消息文件的消息都会被遍历到。
在创建主题的时候,需要指定读队列和写队列的个数,生产者在生产消息时,指定了写队列的队列号,broker收到消息后,便将该消息的索引记录写入到consumequeue/主题目录下对应队列的消费文件中,当写入消费文件完毕,便可以认为消息已经投递到了队列中,接下来消费者发送拉取消息的请求消费指定的读队列,broker收到拉取请求后,根据请求中的读队列号,找到consumequeue/主题目录下对应队列的消费文件,从里面找到索引记录进而找到消息。从上面的描述可以看到,读队列和写队列是一一对应的,如果两者的队列号一样,那么它们操作同一个消费文件。如果创建主题的时候,指定的读队列个数和写队列个数不一致会怎么样?首先rocketmq允许我们这么做,但是这会造成一些写队列的消息永远无法消费,或者读队列里面永远没有消息。
接下里回到本小节的第一个图中,图中有一些流程放到了方框中,方框的意思是这些流程会被循环执行。在消费者发送的拉取请求中,有一个属性maxMsgNums,默认是32,该属性的含义是指定本次拉取的最大消息数量是32个,但是这对broker来说只是一个建议,broker会将maxMsgNums与800比较,取两者的最大值作为返回消费者消息的最大数量。接下来根据这个最大值循环图中方框的流程,直到没有消息可读,或者读到了最大数量。
在图中还有一个流程是建议消费者从salve读取消息,这个是通过判断未读取的消息比rocketmq可用的内存大,便会给该建议,建议从salve拉取消息,以此来减轻broker的内存压力。
消费者拉取消息的时候会携带一个属性commitOffset,表示需要提交的逻辑位移,DefaultMQPushConsumer是自动管理提交位移的,每次发送拉取请求时,都会将已经处理过的消息的最大位移赋给commitOffset,broker收到拉取请求后,将commitOffset记录到ConsumerOffsetManager.offsetTable属性中,之后会有一个定时线程将ConsumerOffsetManager.offsetTable的值写入文件consumerOffset.json文件中。这样即使消费者或者broker重启,消费者还是可以从上次读取的位置继续。