本文主要介绍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中,拉取消息的逻辑。
首先会判断几个状态是否正常:
- ProcessQueue是否drop,如果是直接返回。这就说明通过rebalance,这个消费端已经不再负责这个队列了。
- 会检查消费端服务状态是否处于running,如果不处于running,则会延迟3s再发起拉取请求。
- 会判断当前消费端是否处于暂停,如果处于暂停,则会延迟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;
}
接着会进行限流判断:
- 本地缓存的消息数,超过1000,则会延迟50ms再发起拉取请求。
- 本地缓存的消息大小,超过100MB,则会延迟50ms再发起拉取请求。
- 如果是并发消费,消息跨度超过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上:
- 定时任务每5s执行一次。执行persistAll
- 消费端执行shutdown时。执行persistAll
- 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技术内幕》