Rocket MQ为了兼顾各种场景,所以提供了普通消费和顺序消费两种方式。顺序消息可以保证全局消息投递、消费顺序一致性,适用于消息时间先后敏感的场景,但是大多数情况我们只需要关系业务逻辑是否能正确执行,并不关心顺序。遂分析一下Rocket的并行消费。
从消息拉取说起
上文我们知道PullMessageService负责消息拉取,负责拉取动作的pullMessage方法的实现及其简单,几乎什么逻辑,就是调用了DefaultMQPushConsumerImpl类的pullMessage方法。
经过各种艰难险阻,重重困难其中包括:
- 确定参数PullRequest对象中对应的Process是否已经被遗弃,因为完全有可能经过新的一轮的负载均衡之后某个Queue分配给了其他消费者
- 检查当前消费者是不是ServiceState.RUNNING状态
- 当前消费者有没有被挂起,暂停消费
- 触发三种流控规则,如果命中标准,则将拉取消息的请求延后
- 检查当前要拉取的Topic的订阅信息是否存在,不存在则将拉取消息的请求延后
跋山涉水终于来到PullAPIWrapper#pullKernelImpl方法,该方法其实就是对Client端RPC请求Broker的包装。该方法主要做了如下几件事:
- 根据BrokerName、BrokerId获取Broker地址信息,如果获取失败会主动向NameServer节点询问一次
- 组装PullMessageRequestHeader对象
- 判断消息过滤模式,消息过滤模式为类过滤,则需要根据Topic、Broker地址找到注册到Broker上的 FilterServer地址然后从FilterServer上拉取消息,否则直接从Broker上拉取。
走到MQClientAPIImpl#pullMessage方法,这个方法会将之前请求头对象生成RemotingCommand,Command类型为RequestCode.PULL_MESSAGE。然后通过网络,Broker在接受到请求之后,如果有消息则会返回给Client。
Client在得到Broker回应之后,会进入回调逻辑。回调行为在请求之初早已定义。
public PullResult pullKernelImpl(
MessageQueue mq, String subExpression, String expressionType, long subVersion,
long offset, int maxNums, int sysFlag, long commitOffset, long brokerSuspendMaxTimeMillis,
long timeoutMillis, CommunicationMode communicationMode, PullCallback pullCallback
) {
return this.mQClientFactory.getMQClientAPIImpl().pullMessage(
brokerAddr,
requestHeader,
timeoutMillis,
communicationMode,
pullCallback
);
}
回调的具体逻辑都包装在pullCallback对象中。
消息拉取之后
消息拉取无论何种原因,经历了什么样的过程,最终结果无非就是两种:成功或者失败。正好对应PullCallback接口中定义的两个方法。既然要分析消息消费的过程自然我们只需要关注onSuccess中定义的行为即可。
/**
* Async message pulling interface
*/
public interface PullCallback {
void onSuccess(final PullResult pullResult);
void onException(final Throwable e);
}
消息过滤
这里回忆一下Rocket MQ中ConsumeQueue文件的设计。以下摘自官方文档
ConsumeQueue文件可以看成是基于topic的commitlog索引文件,ConsumeQueue文件采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M;
根据描述,我大概还原了一下ConsumeQueue中单条记录的个样子 这意味着我们在Broker端过滤消息至少从ConsumeQueue文件看并不会百分之百的精准。因为需要考虑成本、效率等诸多因素,因此实际生产中Hash算法无论设计的多么精妙绝伦总是逃脱不过Hash碰撞的命运。这样就完全有可能两个完全不一样的Tag得到同样的HashCode。这样以来返回的数据中可能会有预期之外的Message。既然这么设计本身有一定的缺陷为什么还要这么实现呢?至少有三个理由:
- ConsumeQueue必须是定长的,因为方便内存映射。
- Hash碰撞毕竟稀少,错误过滤的消息毕竟是少数,不会造成IO瓶颈
- 在Client端过滤消息可以分摊Broker端压力
写到此处,其实谜底已经在谜面上了,因为经过上述铺垫,读者必然可以推断出拉取到本地的消息有一部分其实无用,准确的讲是对本Consume Group无用,为了避免在错误的消费组进行消费,onSuccess首先要对消息通过Tag本身进行一次过滤。具体逻辑参见:PullAPIWrapper#processPullResult方法。
pullResult = DefaultMQPushConsumerImpl.this.pullAPIWrapper.processPullResult(
pullRequest.getMessageQueue(),
pullResult,
subscriptionData
);
判定拉取状态
NO_NEW_MSG || NO_MATCHED_MSG
两者处理逻辑一致,因为本次请求并未得到有价值的消息,所以立即发起下一轮的消息拉取。这里有一个细节需要注意一下,pullRequest对象是会被反复使用的,拉取结果会告知Client下一次消息拉取的起点,因此更新pullRequest的nextOffset属性即可。
不知是作者有意为之,还是实在偷懒不想重新构造一个除了nextOffset属性之外其余都一摸一样的对象出来,但结果是减少了多次内存的分配与回收,减少了一些些GC的压力。
case NO_NEW_MSG:
case NO_MATCHED_MSG:
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
break;
OFFSET_ILLEGAL
OFFSET_ILLEGAL状态是我们最不愿意看到的,因为它代表着可能发生了预期之外的问题。同样先更新pullRequest的nextOffset属性,然后将本ProcessQueue设置为丢弃状态,最后会提交一个延迟任务。
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(
() -> {
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
);
persist的实现跟消费模式有关,集群消费的时候其实是将Client端内存中管理的进度同步到远程Broker服务器,如果是广播模式这里会持久化到硬盘。
FOUND
这个状态我们喜闻乐见,证明本次请求平稳着陆。同时这个状态的处理也最为复杂:
- 更新下一次消息拉取的起点
- 累计消息拉取次数与Broker Response Time
- 判断消息个数如果为零,立即重新拉取一次
- 拉取到消息,则延迟DefaultMQPushConsumer#pullInterval毫秒,触发下一次拉取动作
case FOUND:
long prevRequestOffset = pullRequest.getNextOffset();
/* 更新 PullRequest 的下一次的拉取偏移量 */
pullRequest.setNextOffset(pullResult.getNextBeginOffset());
long pullRT = System.currentTimeMillis() - beginTimestamp;
DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullRT(
pullRequest.getConsumerGroup(),
pullRequest.getMessageQueue().getTopic(),
pullRT
);
long firstMsgOffset = Long.MAX_VALUE;
/**
* 有一种情况明明消息拉取成功,但是消息集合为空 || 集合长度为0,即一条符合条件的都没有
* 就是本地全部过滤掉了,立即进行下一次消息拉取
*/
if (pullResult.getMsgFoundList() == null || pullResult.getMsgFoundList().isEmpty()) {
DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
} else {
firstMsgOffset = pullResult.getMsgFoundList().get(0).getQueueOffset();
DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullTPS(
pullRequest.getConsumerGroup(),
pullRequest.getMessageQueue().getTopic(),
pullResult.getMsgFoundList().size()
);
/* !!! */
boolean dispatchToConsume = processQueue
.putMessage(pullResult.getMsgFoundList());
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
pullResult.getMsgFoundList(), processQueue,
pullRequest.getMessageQueue(), dispatchToConsume
);
/* !!! */
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
DefaultMQPushConsumerImpl.this.executePullRequestLater(
pullRequest,
DefaultMQPushConsumerImpl.this
.defaultMQPushConsumer
.getPullInterval()
);
} else {
DefaultMQPushConsumerImpl.this
.executePullRequestImmediately(pullRequest);
}
}
上面被重点标记代码的就是故事的开始,后续一切的消费行为都要从这里说起。
消息安置
好不容易从远程拉取到的消息要被安置在何处呢?需要持久化吗?消息在Broker端已经持久化过,不必担心丢失,显然Client不用重复劳作。
还记得前文中多次提到的ProcessQueue?官方称其:"Queue consumption snapshot",PullMessageService从消息服务器默认每次拉取32条消息按消息队列偏移量顺序存放在ProcessQueue中。查看ProcessQueue#putMessage之后发现消息被维护在一棵红黑树中。
public class ProcessQueue {
private final TreeMap<Long, MessageExt> msgTreeMap =
new TreeMap<>();
}
public boolean putMessage(List<MessageExt> msgs) {
boolean dispatchToConsume = false;
try {
/* 涉及到多线程协作,申请写锁 */
this.lockTreeMap.writeLock().lockInterruptibly();
try {
int validMsgCnt = 0;
for (MessageExt msg : msgs) {
/* key: Consume Queue Offset, value: msg本身 */
MessageExt old = msgTreeMap.put(msg.getQueueOffset(), msg);
if (null == old) {
validMsgCnt++;
this.queueOffsetMax = msg.getQueueOffset();
/* 当前ProcessQueue存放的消息占用内存大小*/
msgSize.addAndGet(msg.getBody().length);
}
}
/* 当前ProcessQueue存放消息的数量 */
msgCount.addAndGet(validMsgCnt);
if (!msgTreeMap.isEmpty() && !this.consuming) {
dispatchToConsume = true;
this.consuming = true;
}
if (!msgs.isEmpty()) {
MessageExt messageExt = msgs.get(msgs.size() - 1);
String property = messageExt.getProperty(MessageConst.PROPERTY_MAX_OFFSET);
if (property != null) {
long accTotal = Long.parseLong(property) - messageExt.getQueueOffset();
if (accTotal > 0) {
this.msgAccCnt = accTotal;
}
}
}
} finally {
/* 释放写锁 */
this.lockTreeMap.writeLock().unlock();
}
} catch (InterruptedException e) {
log.error("putMessage exception", e);
}
return dispatchToConsume;
}
消息消费
本来以为ProcessQueue已经有本地消息全集,消费线程关注ProcessQueue的msgTreeMap即可知道是否具备消费条件。我没看源码之前猜测这里可能会是一个轮询模型,因为这样的设计最简单,但是如果是轮询会有两个明显的矛盾:
- 如果时间间隔设置太短,则会造成CPU浪费
- 如果时间间隔设置太长,消息即时性会受到挑战
所以Rocket MQ抛弃了轮询设计,转向了经典的生产者消费者模型。如果我们从全局俯瞰Rocket MQ的并行消费实现,其实就可以抽象成如下这张图。
生产者
生产时机
消息拉取成功后,会提交消费请求,这里是消费任务的源头,显然此处就是生产者。
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
pullResult.getMsgFoundList(),
processQueue,
pullRequest.getMessageQueue(),
dispatchToConsume
);
提交任务细节
submitConsumeRequest方法并不是将Broker返回的的消息一次性的提交到任务队列,而是分批后将消息包装成ConsumeRequest对象提交到任务队列,粒度由DefaultMQPushConsumer#consumeMessageBatchMaxSize控制,默认为1,也就是说pullResult.getMsgFoundList()里面有多少个消息,就提交多少个任务,如果触发了拒绝策略,则延迟5000ms后再次提交。
public void submitConsumeRequest(
List<MessageExt> msgs, ProcessQueue processQueue,
MessageQueue messageQueue, boolean dispatchToConsume
) {
/* consumeBatchSize默认为1 */
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 {
/* 分页处理 */
for (int total = 0; total < msgs.size(); ) {
List<MessageExt> msgThis = new ArrayList<>(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);
}
}
}
}
消费者
现在只需要明白消费行为,就可以厘清消费整体链路。在Rocket MQ中由ConsumeMessageConcurrentlyService负责并行消费。其实这里就是一个JDK中的线程池,只不过还兼具其他的业务功能。其构造方法也很简单,就是初始化一个线程池。
public ConsumeMessageConcurrentlyService(DefaultMQPushConsumerImpl defaultMQPushConsumerImpl,
MessageListenerConcurrently messageListener) {
this.defaultMQPushConsumerImpl = defaultMQPushConsumerImpl;
this.messageListener = messageListener;
this.defaultMQPushConsumer = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer();
this.consumerGroup = this.defaultMQPushConsumer.getConsumerGroup();
/* 消费请求全部都存在在这个队列 */
this.consumeRequestQueue = new LinkedBlockingQueue<>();
/* 初始化消息消费线程池 */
this.consumeExecutor = new ThreadPoolExecutor(
this.defaultMQPushConsumer.getConsumeThreadMin(),
this.defaultMQPushConsumer.getConsumeThreadMax(),
1000 * 60,
TimeUnit.MILLISECONDS,
this.consumeRequestQueue,
new ThreadFactoryImpl("ConsumeMessageThread_")
);
this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryImpl("ConsumeMessageScheduledThread_")
);
/* 过期清理调度线程 */
this.cleanExpireMsgExecutors = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryImpl("CleanExpireMsgScheduledThread_")
);
}
暂存ConsumeRequest的consumeRequestQueue是一个无界队列。这里我们就不担心会OOM吗?读者可以自行思考一下,Rocket MQ的作者凭什么可以断定这里一定不会占用过多内存?
线程池的最小最大线程数定义在DefaultMQPushConsumer文件中。同时也请读者思考一下这里最大最小值为什么设置为一样的,或者说Max即使大于Min有作用吗。本来ConsumeMessageConcurrentlyService还提供了 incCorePoolSize、decCorePoolSize两个方法用来动态调节核心线程数,但这两个方法也没有任何用处,因为全是空实现。
public class DefaultMQPushConsumer extends ClientConfig
implements MQPushConsumer {
private int consumeThreadMin = 20;
private int consumeThreadMax = 20;
}
ConsumeRequest
初始化
知道了生产者、消费者,自然需要理解生产出来的ConsumeRequest到底是做什么的,ConsumeRequest只有一个构造当法,不可或缺的成员变量,皆在对象产生的时候赋值。
public ConsumeRequest(List<MessageExt> msgs, ProcessQueue processQueue, MessageQueue messageQueue) {
/* 注意这里其实只有一条信息,原因上文讲过 */
this.msgs = msgs;
this.processQueue = processQueue;
this.messageQueue = messageQueue;
}
执行细节
被提交到线程池中的对象,一定是Runable接口的具体实现,因此想要知道ConsumeRequest的执行细节,只需要关注他的run方法即可:
- 判断ProcessQueue是否为丢弃状态,如果是则停止消费行为
- 检查消息消费时候是否注册了钩子函数,如果有的话需要先执行钩子函数
- 记录该消息的消费开始时间,以k-v形式放入消息的properties属性中,key为"CONSUME_START_TIME"
- 将msgs包装成为不可变集合,然后调用MessageListener#consumeMessage方法,走到这里终于开始执行我们自定义的业务逻辑了。
- consumeMessage会返回ConsumeConcurrentlyStatus这个枚举对象,标识该Message是否消费成功
Rocket MQ源码做了防御性编程来应对意外情况,比如出现异常,比如不按照规范返回一个null,假如出现了这些情况,统一认为消费失败,将消费状态设置为ConsumeConcurrentlyStatus.RECONSUME_LATER。
后续处理
处理完上述逻辑之后,依然要再次判断ProcessQueue是否被丢弃,如果是则结束后续流程。
if (!processQueue.isDropped()) {
processConsumeResult(status, context, this);
} else {
log.warn("processQueue is dropped without process consume result.
messageQueue={}, msgs={}",
messageQueue,
msgs
);
}
processConsumeResult中最最重要的作用其实就是维护消费进度。
- 首先判断当前ConsumeRequest中的消息是否为空,如果不存在消息则没必要进行后续处理
- 判断消费成功与否,统计一些消费指标
- 判断消费模式,如果是广播模式则记录相关日志即可,如果是集群模式且有消费失败的消息,则会知会Broker,Broker会重新生成一条消息put进CommitLog,如果与Broker的RPC失败则将失败消息包装成ConsumeRequest对象,5000ms后重新放回consumeRequestQueue队列中。
- 将消费成功的消息从ProcessQueue#msgTreeMap移除,更新Client端内存中的消费进度,至于将进度同步到远程Broker会有专门的定时任务去做。
这附近的源码涉及到ACK Broker的那一块,写的很绕,因为里面的代码不是线性执行的,很多代码完全可能会被跳过,需要读者认真甄别,如果大家感兴趣可以自己看一下,为了防止大家迷惑,我在此处贴出一些重点提示。
public void processConsumeResult(ConsumeConcurrentlyStatus status, ConsumeConcurrentlyContext context, ConsumeRequest consumeRequest) {
/* 默认等于Integer.MAX_VALUE,且源码并无他处修改 */
int ackIndex = context.getAckIndex();
/* 这里会维护ackIndex */
switch (status) {
......
}
switch (defaultMQPushConsumer.getMessageModel()) {
case BROADCASTING:
......
case CLUSTERING:
List<MessageExt> msgBackFailed = new ArrayList<>(consumeRequest.getMsgs().size());
/* ⚠️:如果不修改默认设置,s = 1,且消费成功这个for根本不会进入 */
for (int i = ackIndex + 1, s = consumeRequest.getMsgs().size(); i < s; i++) {
MessageExt msg = consumeRequest.getMsgs().get(i);
boolean result = this.sendMessageBack(msg, context);
if (!result) {
msg.setReconsumeTimes(msg.getReconsumeTimes() + 1);
msgBackFailed.add(msg);
}
}
/* 与broker通信失败,上一个for要是没走,那这里也不会走 */
if (!msgBackFailed.isEmpty()) {
consumeRequest.getMsgs().removeAll(msgBackFailed);
this.submitConsumeRequestLater(
msgBackFailed, consumeRequest.getProcessQueue(), consumeRequest.getMessageQueue()
);
}
break;
......
}
}