RocketMQ并行消费浅析

·  阅读 698
RocketMQ并行消费浅析

      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中单条记录的个样子 image.png       这意味着我们在Broker端过滤消息至少从ConsumeQueue文件看并不会百分之百的精准。因为需要考虑成本、效率等诸多因素,因此实际生产中Hash算法无论设计的多么精妙绝伦总是逃脱不过Hash碰撞的命运。这样就完全有可能两个完全不一样的Tag得到同样的HashCode。这样以来返回的数据中可能会有预期之外的Message。既然这么设计本身有一定的缺陷为什么还要这么实现呢?至少有三个理由:

  1. ConsumeQueue必须是定长的,因为方便内存映射。
  2. Hash碰撞毕竟稀少,错误过滤的消息毕竟是少数,不会造成IO瓶颈
  3. 在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即可知道是否具备消费条件。我没看源码之前猜测这里可能会是一个轮询模型,因为这样的设计最简单,但是如果是轮询会有两个明显的矛盾:

  1. 如果时间间隔设置太短,则会造成CPU浪费
  2. 如果时间间隔设置太长,消息即时性会受到挑战

      所以Rocket MQ抛弃了轮询设计,转向了经典的生产者消费者模型。如果我们从全局俯瞰Rocket MQ的并行消费实现,其实就可以抽象成如下这张图。 image.png

生产者

生产时机

      消息拉取成功后,会提交消费请求,这里是消费任务的源头,显然此处就是生产者。

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;
        
        ......
    }
}
复制代码

总体回顾

image.png

分类:
后端
收藏成功!
已添加到「」, 点击更改