RocketMQ消息消费

本文主要介绍Consumer的启动流程,启动后立即执行的rebalance操作,接着会介绍消息拉取的流程,消息消费的流程,以及进度的管理,最后会简单介绍定时消息和消息重试的实现逻辑。

本文主要分析集群消费,PUSH模式下的并发消费为主要分析。顺序消息可以参考本人的另外一篇文章:RocketMQ之顺序消息

1. 启动流程

以DefaultMQPushConsumer为例,启动一个消费端默认需要以下几个步骤,现在主要分析start方法的操作。

java 复制代码
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group_name");
//设置namesrv地址
consumer.setNamesrvAddr("127.0.0.1:9876");
//设置消费进度从哪开始消费,默认从最新的进度开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
//订阅Topic
consumer.subscribe("TopicTest", "*");
//注册消费监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,ConsumeConcurrentlyContext context){
       //...
    }
});
//启动消费端
consumer.start();

会调用内部类进行启动,设置一些参数,以及启动一些服务。

  • 在集群模式下,会修改默认的instance名称为pid,这个是cid的构成,cid在rebalance中作为客户端标识,目的是为例避免,同一个实例多个消费者的情况下负载问题。

  • 在集群模式下,消费进度存储为RemoteBrokerOffsetStore,也就是存储在broker上。在广播模式下,为本地存储。这是集群消费和广播消费的不同点之一。

  • 如果消费监听器设置为MessageListenerOrderly,则认为是顺序消费,并且创建相应的服务。反之,则认为是广播消费。

  • 会启动MQClientInstance,内部会启动一些定时任务,以及Rebalance服务。

  • 更新订阅关系、向broker发送心跳,以及立马执行一次rebalance。集群模式消费,每个消费端都会分到一些队列进行消费,需要通过rebalance进行分配。

java 复制代码
//DefaultMQPushConsumerImpl#start
public synchronized void start() throws MQClientException {
    switch (this.serviceState) {
        case CREATE_JUST:
            
            //集群模式下,会修改默认的instance名称,为pid
            if (this.defaultMQPushConsumer.getMessageModel() == MessageModel.CLUSTERING) {
                this.defaultMQPushConsumer.changeInstanceNameToPID();
            }

            this.mQClientFactory = MQClientManager.getInstance().getAndCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);

            //...

            //集群消费模式下,进度存储器为:RemoteBrokerOffsetStore
            if (this.defaultMQPushConsumer.getOffsetStore() != null) {
                this.offsetStore = this.defaultMQPushConsumer.getOffsetStore();
            } else {
                switch (this.defaultMQPushConsumer.getMessageModel()) {
                    case BROADCASTING:
                        this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
                        break;
                    case CLUSTERING:
                        this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
                        break;
                    default:
                        break;
                }
                this.defaultMQPushConsumer.setOffsetStore(this.offsetStore);
            }
            this.offsetStore.load();

            //消费监听器为顺序,则会认为是顺序消费,设置ConsumeMessageOrderlyService。否则认为并发消费。
            if (this.getMessageListenerInner() instanceof MessageListenerOrderly) {
                this.consumeOrderly = true;
                this.consumeMessageService =
                    new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
            } else if (this.getMessageListenerInner() instanceof MessageListenerConcurrently) {
                this.consumeOrderly = false;
                this.consumeMessageService =
                    new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
            }

            this.consumeMessageService.start();
			//MQ实例启动,内部主要会启动定时任务,启动Rebalance服务
            mQClientFactory.start();
            log.info("the consumer [{}] start OK.", this.defaultMQPushConsumer.getConsumerGroup());
            this.serviceState = ServiceState.RUNNING;
            break;
        case RUNNING:
        case START_FAILED:
        case SHUTDOWN_ALREADY:
            throw new MQClientException("The PushConsumer service state not OK, maybe started once, "
                + this.serviceState
                + FAQUrl.suggestTodo(FAQUrl.CLIENT_SERVICE_NOT_OK),
                null);
        default:
            break;
    }

    this.updateTopicSubscribeInfoWhenSubscriptionChanged();
    this.mQClientFactory.checkClientInBroker();
    this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
    //唤醒Rebalance服务,立马执行一次rebalance。不然默认要20s才执行一次rebalance
    this.mQClientFactory.rebalanceImmediately();
}

MQClientInstance的start方法也有几个重要的服务会进行启动:

  • 启动客户端API,底层会基于Netty进行构建相应的参数,就等请求的调用(connect后发送数据)。
  • 配置定时任务
    • 默认30s从namesrv中更新topic信息(也可以认为是定时往namesrv发送心跳)。通过pollNameServerInterval修改配置。
    • 默认30s清空无效broker地址,往broker发送心跳。通过heartbeatBrokerInterval修改配置
    • 默认5s存储消费进度到broker。通过persistConsumerOffsetInterval修改配置。
    • 默认1分钟,调整消费线程池。不过在4.4分支代码上,并没有具体实现逻辑。
  • 启动拉消息服务。
  • 启动rebalance服务。
  • 启动Procuer,这是因为在消息重试的时候,可能会触发消息发送的步骤。
java 复制代码
//MQClientInstance#start
// Start request-response channel
this.mQClientAPIImpl.start();
// Start various schedule tasks
this.startScheduledTask();
// Start pull service
this.pullMessageService.start();
// Start rebalance service
this.rebalanceService.start();
// Start push service
this.defaultMQProducer.getDefaultMQProducerImpl().start(false);

从上面流程可以得出,集群模式下要进行消息消费,首要先进行rebalance,分配到topic对应的队列才行,因此会先分析rebalance的流程。

2. rebalance

RebalanceService本质是一个线程,默认20s执行一次,可以通过配置修改,等待过程支持唤醒。会调用MQClientInstance的doRebalance方法,因为使用Push模式,实际会在RebalancePushImpl中实现,RebalancePushImpl继承RebalanceImpl,rebalance的逻辑在RebalanceImpl的doRebalance方法。

java 复制代码
//RebalanceService#run
public void run() {
    while (!this.isStopped()) {
        this.waitForRunning(waitInterval);
        this.mqClientFactory.doRebalance();
    }
}

内部会根据定于关系,便利topic进行rebalance,执行完后,会清空没有订阅关系的消息缓存数据。

java 复制代码
//RebalanceImpl#doRebalance
public void doRebalance(final boolean isOrder) {
    Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
    if (subTable != null) {
        for (final Map.Entry<String, SubscriptionData> entry : subTable.entrySet()) {
            final String topic = entry.getKey();
            try {
                //对某个topic进行rebalance
                this.rebalanceByTopic(topic, isOrder);
            } catch (Throwable e) {
               
            }
        }
    }
	//清空没有订阅关系的消息缓存数据
    this.truncateMessageQueueNotMyTopic();
}

首先会从Topic订阅信息中获取MessageQueue(问题一:这个数据何时设置?),然后随机找一个broker获取所有的cid,如果mqSet和cidAll都不为空,才会继续往下执行。会对mqSet和cidAll进行排序,这个很重要,确保同一个消费队列不会分配给多个消费者,也就是同一消费端重启前后基本消费的队列是一样(问题二:cid的生成规则是什么?)

默认的分配策略为AllocateMessageQueueAveragely,平均分配。还支持其他算法,不过一般来说平均分配即可满足需求。接着会更新ProcessQueue,以及消息队列变更处理逻辑。

在继续往下分析之前,先把前面的问题进行解答。

java 复制代码
Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
//找Broker获取所有的clientId
List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
mqAll.addAll(mqSet);
//进行排序
Collections.sort(mqAll);
Collections.sort(cidAll);
//执行分配策略
AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
List<MessageQueue> allocateResult = strategy.allocate(this.consumerGroup, this.mQClientFactory.getClientId(),mqAll,cidAll);
Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
if (allocateResult != null) {
	allocateResultSet.addAll(allocateResult);
}
//更新PorcessQueue信息
Boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
if (changed) {
    //处理消息队列变更
	this.messageQueueChanged(topic, mqSet, allocateResultSet);
}

2.1 topicSubscribeInfoTable数据何时设置?

在Consumer启动的时候,会调用updateTopicSubscribeInfoWhenSubscriptionChanged方法(可以见前面start方法,在rebalance之前执行),内部会执行MQClientInstance的updateTopicRouteInfoFromNameServer方法,该方法会请求NameServer获取Topic的信息,然后更新订阅信息。

首先会从namesrv中获取的topic信息,转换成MessageQueue,转换方式也很简单,就是根据topic读队列的数量,生成对应的MessageQueue。

java 复制代码
//MQClientInstance#updateTopicRouteInfoFromNameServer
Set<MessageQueue> subscribeInfo = topicRouteData2TopicSubscribeInfo(topic, topicRouteData);
Iterator<Entry<String, MQConsumerInner>> it = this.consumerTable.entrySet().iterator();
while (it.hasNext()) {
    Entry<String, MQConsumerInner> entry = it.next();
    MQConsumerInner impl = entry.getValue();
    if (impl != null) {
        impl.updateTopicSubscribeInfo(topic, subscribeInfo);
    }
}

当消费端有订阅topic时,就会调用rebalanceImpl的topicSubscribeInfoTable属性,进行设置属性。这样topicSubscribeInfoTable在rebalnece之前就有数据了。

java 复制代码
//DefaultMQPushConsumerImpl#updateTopicSubscribeInfo
public void updateTopicSubscribeInfo(String topic, Set<MessageQueue> info) {
    Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
    if (subTable != null) {
        if (subTable.containsKey(topic)) {
            this.rebalanceImpl.topicSubscribeInfoTable.put(topic, info);
        }
    }
}

2.2 cid规则如何?

cid会在ClinetConfig中进行构建,规则如下:在前面start流程中有说明,如果instanceName为默认值的情况下,会设置为pid,所以默认规则为:ip@PID,如果有设置instanceName和unitName,那么规则为:ip@instanceName@unitName。unitName可以通过Consumer方法进行设置。

java 复制代码
//ClinetConfig#buildMQClientId
public String buildMQClientId() {
    StringBuilder sb = new StringBuilder();
    sb.append(this.getClientIP());

    sb.append("@");
    sb.append(this.getInstanceName());
    if (!UtilAll.isBlank(this.unitName)) {
        sb.append("@");
        sb.append(this.unitName);
    }

    return sb.toString();
}

继续往下分析ProcessQueue的逻辑,在updateProcessQueueTableInRebalance中。ProcessQueue是消息队列在消费端消息的缓存,实际消费的消息都是从它获取。

首先会移除不存在于本次分配的MessageQueue列表数据,或者拉取过期,都会移除不需要的MessageQueue。会把ProcessQueue设置为dropped,这样对应的消息队列就不会再消费以及拉取消息了。

java 复制代码
//RebalanceImpl#updateProcessQueueTableInRebalance
if (mq.getTopic().equals(topic)) {
    if (!mqSet.contains(mq)) {
        pq.setDropped(true);
        if (this.removeUnnecessaryMessageQueue(mq, pq)) {
            it.remove();
            changed = true;
        }
    } else if (pq.isPullExpired()) {
        pq.setDropped(true);
        if (this.removeUnnecessaryMessageQueue(mq, pq)) {
            it.remove();
            changed = true;
        }
    }
}

会对消费进度进行持久化,集群消费模式下,会往broker中存储消费进度,同时会移除这个MessageQueue的消费进度。另外在顺序消费模式下,还会执行解锁操作,因为顺序消费需要加锁,才能对消费队列的消息进行消费。

java 复制代码
//RebalancePushImpl#removeUnnecessaryMessageQueue
this.defaultMQPushConsumerImpl.getOffsetStore().persist(mq);
this.defaultMQPushConsumerImpl.getOffsetStore().removeOffset(mq);

其次,对于本次新增的MessageQueue,需要进行处理。如果ProcessQueue不包含MessageQueue,说明该消费者要新增对这个消息队列的消费。如果是顺序消息的话,会尝试进行加锁。然后移除掉旧的消费进度,就是把本地的这个MessageQueue的消费进度清空。并且会这个MessageQueue拉取的偏移量,这个也是关键的一个操作。最后会构建拉取请求。

java 复制代码
//RebalanceImpl#updateProcessQueueTableInRebalance
if (!this.processQueueTable.containsKey(mq)) {
    //顺序消费需要对消息队列加锁,会到broker中获取锁。
    if (isOrder && !this.lock(mq)) {
        log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
        continue;
    }
	//移除旧得消费进度
    this.removeDirtyOffset(mq);
    ProcessQueue pq = new ProcessQueue();
    //计算从何处拉取消费偏移量。
    long nextOffset = this.computePullFromWhere(mq);
    if (nextOffset >= 0) {
        ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
        if (pre != null) {
            log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
        } else {
            PullRequest pullRequest = new PullRequest();
            pullRequest.setConsumerGroup(consumerGroup);
            pullRequest.setNextOffset(nextOffset);
            pullRequest.setMessageQueue(mq);
            pullRequest.setProcessQueue(pq);
            pullRequestList.add(pullRequest);
            changed = true;
        }
    } else {
        log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
    }
}

this.dispatchPullRequest(pullRequestList);

在Consumer启动的时候,可以设置consumeFromWhere从何处开始消费,这个配置实际上是对于首次消费来说,消费的位置从哪开始消费。如果之前已经有消费过,那么会从之前消费的进度开始消费。以集群消费为例,消费进度的管理是RemoteBrokerOffsetStore。

以CONSUME_FROM_LAST_OFFSET为例,默认会从OffsetStore(具体实现为RemoteBrokerOffsetStore)读取偏移量,如果偏移量为-1,表示首次消费,如果是重试的TOPIC则直接返回0,否则就会找broker查询当前MessageQueue中最大的偏移量,最后返回偏移量。

java 复制代码
//RebalancePushImpl#computePullFromWhere
public long computePullFromWhere(MessageQueue mq) {
    long result = -1;
    final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere();
    final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore();
    switch (consumeFromWhere) {
        case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST:
        case CONSUME_FROM_MIN_OFFSET:
        case CONSUME_FROM_MAX_OFFSET:
        case CONSUME_FROM_LAST_OFFSET: {
            long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
            if (lastOffset >= 0) {
                result = lastOffset;
            }
            // First start,no offset
            else if (-1 == lastOffset) {
                if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                    result = 0L;
                } else {
                    try {
                        result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
                    } catch (MQClientException e) {
                        result = -1;
                    }
                }
            } else {
                result = -1;
            }
            break;
        }
     }
   return result;
}

在RemoteBrokerOffsetStore的readOffset中,因为是从存储中读取数据,所以会从broker中获取存储的偏移进行返回。如果没有偏移量返回-1,如果其他异常则返回-2。

java 复制代码
//RemoteBrokerOffsetStore#readOffset
public long readOffset(final MessageQueue mq, final ReadOffsetType type) {
    if (mq != null) {
        switch (type) {
            case READ_FROM_STORE: {
                try {
                    long brokerOffset = this.fetchConsumeOffsetFromBroker(mq);
                    AtomicLong offset = new AtomicLong(brokerOffset);
                    this.updateOffset(mq, offset.get(), false);
                    return brokerOffset;
                }
                // No offset in broker
                catch (MQBrokerException e) {
                    return -1;
                }
                //Other exceptions
                catch (Exception e) {
                    log.warn("fetchConsumeOffsetFromBroker exception, " + mq, e);
                    return -2;
                }
            }
            default:
                break;
        }
    }

    return -1;
}

最后会把拉取请求加入到拉取服务中,内部会执行消息的拉取逻辑。

java 复制代码
//RebalancePushImpl#dispatchPullRequest
public void dispatchPullRequest(List<PullRequest> pullRequestList) {
    for (PullRequest pullRequest : pullRequestList) {
        this.defaultMQPushConsumerImpl.executePullRequestImmediately(pullRequest);
        log.info("doRebalance, {}, add a new pull request {}", consumerGroup, pullRequest);
    }
}

在MessageQueue发生变更后,会更新订阅版本号,并且向broker发送心跳,心跳的内容包含了订阅信息。

java 复制代码
//RebalancePushImpl#messageQueueChanged
public void messageQueueChanged(String topic, Set<MessageQueue> mqAll, Set<MessageQueue> mqDivided) {
   
    //更新订阅版本号
    SubscriptionData subscriptionData = this.subscriptionInner.get(topic);
    long newVersion = System.currentTimeMillis();
    subscriptionData.setSubVersion(newVersion);

    //发送心跳,会携带订阅信息
    this.getmQClientFactory().sendHeartbeatToAllBrokerWithLock();
}

在broker上,如果订阅信息发生变化,默认情况下会会通知消费端进行rebalace

java 复制代码
//ConsumerManager#registerConsumer
this.consumerIdsChangeListener.handle(ConsumerGroupEvent.CHANGE, group, consumerGroupInfo.getAllChannel());
java 复制代码
//DefaultConsumerIdsChangeListener#handle
List<Channel> channels = (List<Channel>) args[0];
if (channels != null && brokerController.getBrokerConfig().isNotifyConsumerIdsChangedEnable()) {
    for (Channel chl : channels) {
        this.brokerController.getBroker2Client().notifyConsumerIdsChanged(chl, group);
    }
}

客户端收到通知后会立马触发rebalance,这样就能较快的整个集群进行rebalance。

java 复制代码
//ClientRemotingProcessor#notifyConsumerIdsChanged
public RemotingCommand notifyConsumerIdsChanged(ChannelHandlerContext ctx,
    RemotingCommand request) throws RemotingCommandException {
    try {
        this.mqClientFactory.rebalanceImmediately();
    } catch (Exception e) {
    }
    return null;
}

另外当订阅关系有变更时,会发送订阅信息到broker上,版本号为最新的时间戳。broker上拉取消息时会判断订阅的版本号,如果请求的订阅版本号,大于broker中存储的版本号,则不允许拉取消息。说明broker上的定于关系不是最新的。

java 复制代码
//PullMessageProcessor#processRequest
if (subscriptionData.getSubVersion() < requestHeader.getSubVersion()) {
    log.warn("The broker's subscription is not latest, group: {} {}", requestHeader.getConsumerGroup(),
        subscriptionData.getSubString());
    response.setCode(ResponseCode.SUBSCRIPTION_NOT_LATEST);
    response.setRemark("the consumer's subscription not latest");
    return response;
}

3. 拉取消息

拉取消息服务为PullMessageService,本质上是个Thread,内部会有延迟执行逻辑,以及立即执行逻辑。在run方法中,就是获取拉取消息请求,注意这里用的是take方法,实际内部会进行阻塞。然后拉取到的消息执行pullMessage

java 复制代码
//PullMessageService#run
public void run() {
    while (!this.isStopped()) {
        try {
            PullRequest pullRequest = this.pullRequestQueue.take();
            this.pullMessage(pullRequest);
        } catch (InterruptedException ignored) {
        } catch (Exception e) {
          
        }
    }
}

pullMessage实际会找到对应的消费者实例,DefaultMQPushConsumerImpl,然后调用它的pullMessage方法。

java 复制代码
//PullMessageService
private void pullMessage(final PullRequest pullRequest) {
    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);
    }
}

PullMessageService还提供了立即拉取消息的逻辑,以及延迟拉取的逻辑。立即拉取就是往队列中写消息。

java 复制代码
//PullMessageService#executePullRequestImmediately
public void executePullRequestImmediately(final PullRequest pullRequest) {
    try {
        this.pullRequestQueue.put(pullRequest);
    } catch (InterruptedException e) {
        log.error("executePullRequestImmediately pullRequestQueue.put", e);
    }
}

延迟拉取的话,就是通过ScheduledExecutorService,延迟把拉取请求写入到队列。

java 复制代码
//PullMessageService#executePullRequestLater
public void executePullRequestLater(final PullRequest pullRequest, final long timeDelay) {
    if (!isStopped()) {
        this.scheduledExecutorService.schedule(new Runnable() {
            @Override
            public void run() {
                PullMessageService.this.executePullRequestImmediately(pullRequest);
            }
        }, timeDelay, TimeUnit.MILLISECONDS);
    } 
}

再看DefaultMQPushConsumerImpl中,拉取消息的逻辑。

首先会判断几个状态是否正常:

  1. ProcessQueue是否drop,如果是直接返回。这就说明通过rebalance,这个消费端已经不再负责这个队列了。
  2. 会检查消费端服务状态是否处于running,如果不处于running,则会延迟3s再发起拉取请求。
  3. 会判断当前消费端是否处于暂停,如果处于暂停,则会延迟3s再发起拉取请求。
java 复制代码
//DefaultMQPushConsumerImpl#pullMessage
final ProcessQueue processQueue = pullRequest.getProcessQueue();
if (processQueue.isDropped()) {
    return;
}
try {
    this.makeSureStateOK();
} catch (MQClientException e) {
    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
    return;
}

if (this.isPause()) {
    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_SUSPEND);
    return;
}

接着会进行限流判断:

  1. 本地缓存的消息数,超过1000,则会延迟50ms再发起拉取请求。
  2. 本地缓存的消息大小,超过100MB,则会延迟50ms再发起拉取请求。
  3. 如果是并发消费,消息跨度超过2000,则会延迟50ms再发起拉取请求。消费跨度只指,本地缓存消息中消息队列偏移量(QueueOffset,不是CommitOffset或物理偏移量)的差值(最大值减去最小值)。这里主要存在并发消费情况下,消费失败,且发送会broker也失败的情况下。这种情况下,会在内存中进行重试,但是消费进度却没有更新,避免因为一条消费卡住,消费又一直往下进行,这样如果客户端重启的话,会有大量的消息需要进行重试。而顺序消息,为了保证顺序,默认情况下,一条消息卡住消息就会一直无法往下。
java 复制代码
//DefaultMQPushConsumerImpl#pullMessage
if (cachedMessageCount > this.defaultMQPushConsumer.getPullThresholdForQueue()) {
	this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
	return;
}
if (cachedMessageSizeInMiB > this.defaultMQPushConsumer.getPullThresholdSizeForQueue()) {
	this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
	return;
}
if (processQueue.getMaxSpan() > this.defaultMQPushConsumer.getConsumeConcurrentlyMaxSpan()) {
	this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
	return;
}

接着会向broker发起消息拉取的操作,会通过命令码RequestCode.PULL_MESSAGE拉取。

关于broker拉取消息主要逻辑,在本人另外一篇文章中有说明:RocketMQ消息存储:ConsumeQueue

拉取消息的结果会在PullCallback中处理,当有消息拉取到时,会先将消息写入到ProcessQueue中暂存,然后构建Request请求,提交到线程池中进行消费。如果没有设置拉取延迟,则会继续向borker拉取消息。

java 复制代码
//DefaultMQPushConsumerImpl$PullCallback#onSuccess
//往ProcessQueue暂存消息
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
//构建请求,往线程池中投递
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
    pullResult.getMsgFoundList(),
    processQueue,
    pullRequest.getMessageQueue(),
    dispatchToConsume);
//默认情况下pullInterval为0,会立马向broker继续拉取消息
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
        DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}

在submitConsumeRequest中,如果消息数量小于等于批量消费数量,会直接提交消费请求。如果大于批量消费数量的话,会根据consumeBatchSize进行切分,分成多分进行提交消费请求。

  • consumeMessageBatchMaxSize参数默认为1,也就是一个消息请求只有一条消息,可以调整这个参数实现批量消费。

  • 提交线程池虽然有处理RejectedExecutionException,但是线程池的队列默认是个LinkedBlockingQueue,容量为Integer.MAX_VALUE。所以是不会触发提交线程池异常。而且线程池的maximumPoolSize参数基本也废弃。

    Java线程池需要等队列满了之后才会增加线程,具体可以查看本人另外一篇文章:Tomcat如何扩展Java线程池)。

java 复制代码
//ConsumeMessageConcurrentlyService#submitConsumeRequest
public void submitConsumeRequest(
    final List<MessageExt> msgs,
    final ProcessQueue processQueue,
    final MessageQueue messageQueue,
    final boolean dispatchToConsume) {
    final int consumeBatchSize = this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
    //消息数量小于等于批量消费数量,直接提交
    if (msgs.size() <= consumeBatchSize) {
        ConsumeRequest consumeRequest = new ConsumeRequest(msgs, processQueue, messageQueue);
        try {
            this.consumeExecutor.submit(consumeRequest);
        } catch (RejectedExecutionException e) {
            this.submitConsumeRequestLater(consumeRequest);
        }
    } else {
        //否则,需要根据consumeBatchSize进行分割成不同的Request请求。
        for (int total = 0; total < msgs.size(); ) {
            List<MessageExt> msgThis = new ArrayList<MessageExt>(consumeBatchSize);
            for (int i = 0; i < consumeBatchSize; i++, total++) {
                if (total < msgs.size()) {
                    msgThis.add(msgs.get(total));
                } else {
                    break;
                }
            }

            ConsumeRequest consumeRequest = new ConsumeRequest(msgThis, processQueue, messageQueue);
            try {
                this.consumeExecutor.submit(consumeRequest);
            } catch (RejectedExecutionException e) {
                for (; total < msgs.size(); total++) {
                    msgThis.add(msgs.get(total));
                }

                this.submitConsumeRequestLater(consumeRequest);
            }
        }
    }
}

4. 消费流程

在消费的过程会判断ProcessQueue是否drop,如果是则不会处理,在rebalance之后,可能会将ProcessQueue设置为drop。对于重试消息,会把topic恢复为重试前的名称,因为消息重试的topic会用%RETRY%{groupName}。然后调用MessageListenerConcurrently的consumeMessage方法,让业务进行消费。消费完根据消费结果处理消息进度。

java 复制代码
//ConsumeMessageConcurrentlyService$ConsumeRequest#run
public void run() {
	if (this.processQueue.isDropped()) {
		return;
	}
	long beginTimestamp = System.currentTimeMillis();
	Boolean hasException = false;
	ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
	try {
        //恢复重试消息原先的topic
		ConsumeMessageConcurrentlyService.this.resetRetryTopic(msgs);
        //消息消费
		status = listener.consumeMessage(Collections.unmodifiableList(msgs), context);
	}catch (Throwable e) {
		hasException = true;
	}
    //处理结果
	if (!processQueue.isDropped()) {
		ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);
	}
}

只有在消费状态为RECONSUME_LATER的时候,才会触发消息重试,消息重试会将消息写回broker,然后再从broker中拉取消息消费。消息重试依赖定时消息。

如果消息发送broker失败,则会在内存中进行重试。

java 复制代码
//ConsumeMessageConcurrentlyService#processConsumeResult
List<MessageExt> msgBackFailed = new ArrayList<MessageExt>(consumeRequest.getMsgs().size());
for (int i = ackIndex + 1; i < consumeRequest.getMsgs().size(); i++) {
    MessageExt msg = consumeRequest.getMsgs().get(i);
    boolean result = this.sendMessageBack(msg, context);
    //发送broker失败
    if (!result) {
        msg.setReconsumeTimes(msg.getReconsumeTimes() + 1);
        msgBackFailed.add(msg);
    }
}

if (!msgBackFailed.isEmpty()) {
    consumeRequest.getMsgs().removeAll(msgBackFailed);

    this.submitConsumeRequestLater(msgBackFailed, consumeRequest.getProcessQueue(), consumeRequest.getMessageQueue());
}

最后会移除消费成功的消息,并且会返回ProcessQueue中最小的偏移量,然后进行更新消息消费进度。如果消息消费失败且发送到broker也失败的情况下,那么最小的偏移量实际上会无法更新为最新。前面拉取消息时有所提及。

java 复制代码
//ConsumeMessageConcurrentlyService#processConsumeResult
long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
    this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
}

5. 进度管理

RocketMQ通过OffsetStore接口管理消费进度,主要分析集群模式。

广播模式:消费进度会存储在本地文件。

集群模式:消费进度会存储在broker上。

在更新消费进度上,就是在内存中维护消费进度,根据传参进行设置,offset是ProcessQueue中最小的消息偏移量。

java 复制代码
//RemoteBrokerOffsetStore#updateOffset
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
    if (mq != null) {
        AtomicLong offsetOld = this.offsetTable.get(mq);
        if (null == offsetOld) {
            offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
        }

        if (null != offsetOld) {
            if (increaseOnly) {
                MixAll.compareAndIncreaseOnly(offsetOld, offset);
            } else {
                offsetOld.set(offset);
            }
        }
    }
}

主要有以下情况下会进行持久化,也就是更新到broker上:

  1. 定时任务每5s执行一次。执行persistAll
  2. 消费端执行shutdown时。执行persistAll
  3. rebalance后,不是该消费者负责的MessageQueue会被移除,并且持久化消费进度。执行persist
java 复制代码
//RemoteBrokerOffsetStore#persist
public void persist(MessageQueue mq) {
    AtomicLong offset = this.offsetTable.get(mq);
    if (offset != null) {
        try {
            this.updateConsumeOffsetToBroker(mq, offset.get());
        } catch (Exception e) {
            log.error("updateConsumeOffsetToBroker exception, " + mq.toString(), e);
        }
    }
}

6. 定时消息

定时消息是指消息发送到Broker之后,并不会立即被消费者消费,而是要等到特定的时间才会被消费。RocketMQ只支持特定级别的消息延迟,从1s到最大2h,共计18个等级,如delayLevel=1就是第1级。

定时消息单独设置一个主题:SCHEDULE_TOPIC_XXXX,队列数就等于延迟级别数量。在发送消息时,如果delayLevel大于0,会将消息的原主题名,队列ID存储消息的属性中,然后改变消息的主题、队列与延迟等级对应,消息将转发到延迟队列的消费队列中,然后延迟队列通过定时任务将消息恢复。

在启动的时候,会给每个delayLevel创建对应的延迟任务,初始执行时间为1s,后续会根据执行情况再设置延迟任务。并且还会每隔10s做delayLevel对应的offset的持久化。

java 复制代码
//ScheduleMessageService#start
public void start() {

    for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
        Integer level = entry.getKey();
        Long timeDelay = entry.getValue();
        Long offset = this.offsetTable.get(level);
        if (null == offset) {
            offset = 0L;
        }

        if (timeDelay != null) {
            this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
        }
    }
}

在写CommitLog的时候,如果时延迟消息,会把原Topic和queueId记录到property中。设置topic为延迟topic,queueId为对应的延迟等级。

java 复制代码
//CommitLog#putMessage
if (msg.getDelayTimeLevel() > 0) {
    if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
    }

    topic = ScheduleMessageService.SCHEDULE_TOPIC;
    queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

    // Backup real topic, queueId
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
    msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

    msg.setTopic(topic);
    msg.setQueueId(queueId);
}

在CommitLog转发到MessageQueue消息的时候,如果时延迟消息,则tagsCode值会为存储时间。会用这个时间记录消息是否可以达到预期时间。

java 复制代码
//CommitLog#checkMessageAndReturnSize
{
    String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
    if (ScheduleMessageService.SCHEDULE_TOPIC.equals(topic) && t != null) {
        int delayLevel = Integer.parseInt(t);

        if (delayLevel > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
            delayLevel = this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel();
        }

        if (delayLevel > 0) {
            tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
                storeTimestamp);
        }
    }
}

紧接着分析以下定时任务逻辑,也就是DeliverDelayedMessageTimerTask逻辑

首先会获取延迟Topic下的ConsumeQueue,如果存在该文件才会继续执行。

java 复制代码
//ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
ConsumeQueue cq = ScheduleMessageService.this.defaultMessageStore.findConsumeQueue(SCHEDULE_TOPIC, delayLevel2QueueId(delayLevel));

会根据offset获取队列中有效的消息。然后会遍历ConsumeQueue,每个ConsumeQueue消息为20字节。解析出物理偏移量、消息长度、消息tagCode(也就是消息存储时间),然后会判断消息是否需要投递。

java 复制代码
//ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
for(; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE){
    long offsetPy = bufferCQ.getByteBuffer().getLong();
    int sizePy = bufferCQ.getByteBuffer().getInt();
    //tagsCode就是消息存储时间
    long tagsCode = bufferCQ.getByteBuffer().getLong();
    
    long now = System.currentTimeMillis();
    //会计算真正投递的时间
    long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
    nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
    //当前时间减去投递时间,如果小于0,就说明消息需要投递了
    long countdown = deliverTimestamp - now;
}

当需要投递时,会从CommitLog中找出消息,然后进行消息转换(调用messageTimeup方法),最后在调用消息存储,重新写入到CommitLog文件中。

java 复制代码
//ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
MessageExt msgExt = ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
PutMessageResult putMessageResult = ScheduleMessageService.this.defaultMessageStore.putMessage(msgInner);

最后再看下消息转换的逻辑

会恢复tagCode、property,然后会清理延迟等级的property,最后会恢复原来的topic信息和queueId的信息

java 复制代码
//ScheduleMessageService$DeliverDelayedMessageTimerTask#messageTimeup
private MessageExtBrokerInner messageTimeup(MessageExt msgExt) {
    MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
    msgInner.setBody(msgExt.getBody());
    msgInner.setFlag(msgExt.getFlag());
    MessageAccessor.setProperties(msgInner, msgExt.getProperties());

    TopicFilterType topicFilterType = MessageExt.parseTopicFilterType(msgInner.getSysFlag());
    long tagsCodeValue =
        MessageExtBrokerInner.tagsString2tagsCode(topicFilterType, msgInner.getTags());
    msgInner.setTagsCode(tagsCodeValue);
    msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgExt.getProperties()));

    msgInner.setSysFlag(msgExt.getSysFlag());
    msgInner.setBornTimestamp(msgExt.getBornTimestamp());
    msgInner.setBornHost(msgExt.getBornHost());
    msgInner.setStoreHost(msgExt.getStoreHost());
    msgInner.setReconsumeTimes(msgExt.getReconsumeTimes());

    msgInner.setWaitStoreMsgOK(false);
    MessageAccessor.clearProperty(msgInner, MessageConst.PROPERTY_DELAY_TIME_LEVEL);
	//恢复原topic
    msgInner.setTopic(msgInner.getProperty(MessageConst.PROPERTY_REAL_TOPIC));
	//恢复原queueId
    String queueIdStr = msgInner.getProperty(MessageConst.PROPERTY_REAL_QUEUE_ID);
    int queueId = Integer.parseInt(queueIdStr);
    msgInner.setQueueId(queueId);

    return msgInner;
}

7. 消息重试

前面提到消息重试,实际消息重试以来定时消息,所以先分析定时消息,最后再来看消息重试。消息重试会把消息发回Broker中,对应的Code为RequestCode.CONSUMER_SEND_MSG_BACK

首先,会根据消息组名称生成重试topic:%RETRY%{groupName},已经生成对应的queueId。然后会根据消息的offset找出消息,如果之前没有设置RETRY_TOPIC属性,则会设置这个属性为原始topic。在消息的时候会取这个属性进行恢复。

java 复制代码
//SendMessageProcessor#consumerSendMsgBack
String newTopic = MixAll.getRetryTopic(requestHeader.getGroup());
int queueIdInt = Math.abs(this.random.nextInt() % 99999999) % subscriptionGroupConfig.getRetryQueueNums();

MessageExt msgExt = this.brokerController.getMessageStore().lookMessageByOffset(requestHeader.getOffset())
final String retryTopic = msgExt.getProperty(MessageConst.PROPERTY_RETRY_TOPIC);
if(null == retryTopic){
    MessageAccessor.putProperty(msgExt, MessageConst.PROPERTY_RETRY_TOPIC, msgExt.getTopic());
}

当超过最大重试次数,或者延迟等级小于0,则会直接发送到死信队列中,这种情况只能人工介入。如果延迟等级为0,会设置延迟等级为重试次数+3。

java 复制代码
//SendMessageProcessor#consumerSendMsgBack
int delayLevel = requestHeader.getDelayLevel();
int maxReconsumeTimes = subscriptionGroupConfig.getRetryMaxTimes();
if(request.getVersion() >= MQVersion.Version.V3_4_9.ordinal()){
    maxReconsumeTimes = requestHeader.getMaxReconsumeTimes();
}
//当超过最大重试次数,或者延迟等级小于0,则会直接发送到死信队列中。
if(msgExt.getReconsumeTimes() >= maxReconsumeTimes || delayLevel < 0){
    newTopic = MixAll.getDLQTopic(requestHeader.getGroup());
    queueIdInt = Math.abs(this.random.nextInt() % 99999999) % DLQ_NUMS_PER_GROUP;
}else{
	//如果延迟等级为0,则设置延迟等级为重试次数 + 3
    if(0 == delayLevel){
        delayLevel = 3 + msgExt.getReconsumeTimes();
    }
    msgExt.setDelayTimeLevel(delayLevel);
}

因为设置了延迟队列等级,所以消息会先变成定时消息,到了投递时间又会恢复到重试topic中,客户端会往重试topic拉取消息,所以最后消息又能被消息。

前面也有提到,重试消息在消费的时候,会恢复原来的的topic信息。就是根据PROPERTY_RETRY_TOPIC这个属性进行恢复。

java 复制代码
//ConsumeMessageConcurrentlyService#resetRetryTopic
public void resetRetryTopic(final List<MessageExt> msgs) {
    final String groupTopic = MixAll.getRetryTopic(consumerGroup);
    for (MessageExt msg : msgs) {
        String retryTopic = msg.getProperty(MessageConst.PROPERTY_RETRY_TOPIC);
        if (retryTopic != null && groupTopic.equals(msg.getTopic())) {
            msg.setTopic(retryTopic);
        }
    }
}

8. 参考资料

  • RocketMQ源码 4.4.0分支
  • 《RocketMQ技术内幕》
相关推荐
弗拉唐42 分钟前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi771 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
2401_857610032 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
少说多做3432 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀2 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20202 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深2 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++
凌冰_2 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞2 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举