RocketMQ源码分析9:Consumer消费流程

·  阅读 233

本文已参与[新人创作礼]活动,一路开启掘金创作之路。

基于rocketmq-4.9.0 版本分析rocketmq

1.拉取消息前的准备工作

在去broker拉取消息前必须要做的一件很重要的操作:触发重平衡

前面我们分析了Consumer的启动流程,其中有两个服务类是特别关注的,一个是重平衡服务类RebalanceService,一个是拉取消息的服务类PullMessageService

我们先看下拉取消息的服务类PullMessageService,他是一个异步线程,启动后将阻塞。

PullMessageService对象是拉取消息的入口

public class PullMessageService extends ServiceThread {

       private final LinkedBlockingQueue<PullRequest> pullRequestQueue = new LinkedBlockingQueue<PullRequest>();
  
       //TODO:...省略其他代码.....
  
        @Override
        public void run() {
            log.info(this.getServiceName() + " service started");

            while (!this.isStopped()) {
                try {
                    //TODO:从队列中获取 PullRequest, 刚开始肯定获取不到,那么我们就要看是什么时候将PullRequest放入队列中的呢?
                    PullRequest pullRequest = this.pullRequestQueue.take();
                    this.pullMessage(pullRequest);
                } catch (InterruptedException ignored) {
                } catch (Exception e) {
                    log.error("Pull Message Service Run Method exception", e);
                }
            }

            log.info(this.getServiceName() + " service end");
        }
 }       

它要从队列pullRequestQueue中获取PullRequest对象,但是刚开始肯定获取不到,所以我们就要看是什么时候将PullRequest放入到队列中去的。

没错,这就要看重平衡服务了。

那么接下来我们就看下重平衡服务都做了什么?重平衡服务也是一个异步线程服务,我们就看下核心逻辑:

private void rebalanceByTopic(final String topic, final boolean isOrder) {
    switch (messageModel) {
        case BROADCASTING: {
            //TODO: 忽略广播模式的代码
            break;
        }
        case CLUSTERING: {
            //TODO: 获取这个topic下的所有队列(默认是4个)
            Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
            //TODO: 获取集群下所有客户端的id,我这里目前就一个消费者
            List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
           
           //TODO: ....忽略一些判断代码

            if (mqSet != null && cidAll != null) {
                List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
                mqAll.addAll(mqSet);

                Collections.sort(mqAll);
                Collections.sort(cidAll);

                //TODO: 默认是平均分配策略
                AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;

                //TODO: 分配结果
                List<MessageQueue> allocateResult = null;
                try {
                    //TODO: 第一个参数是消费者组
                    //TODO: 第二个参数是当前的客户端id
                    //TODO: 第三个参数是所有的queue(默认4个)
                    //TODO: 第四个参数是所有的客户端id
                    allocateResult = strategy.allocate(
                        this.consumerGroup,
                        this.mQClientFactory.getClientId(),
                        mqAll,
                        cidAll);
                } catch (Throwable e) {
                    log.error("AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}", strategy.getName(),
                        e);
                    return;
                }

                //TODO:保存了当前消费者需要消费的队列
                Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
                if (allocateResult != null) {
                    allocateResultSet.addAll(allocateResult);
                }

                //TODO:这个方法内部要做的内容很多,我在下面进行阐述
                boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
                if (changed) {
                    log.info(
                        "rebalanced result changed. allocateMessageQueueStrategyName={}, group={}, topic={}, clientId={}, mqAllSize={}, cidAllSize={}, rebalanceResultSize={}, rebalanceResultSet={}",
                        strategy.getName(), consumerGroup, topic, this.mQClientFactory.getClientId(), mqSet.size(), cidAll.size(),
1.                         allocateResultSet.size(), allocateResultSet);
                    this.messageQueueChanged(topic, mqSet, allocateResultSet);
                }
            }
            break;
        }
        default:
            break;
    }
}

然后继续看下updateProcessQueueTableInRebalance(...)方法的核心逻辑:

private boolean updateProcessQueueTableInRebalance(final String topic, final Set<MessageQueue> mqSet,
    final boolean isOrder) {
    boolean changed = false;

    //TODO:....省略部分代码.......

    List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
    for (MessageQueue mq : mqSet) {
            //TODO:......忽略......

            if (nextOffset >= 0) {
                ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
                if (pre != null) {
                    log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
                } else {
                    log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
                    //TODO:这里很重要,构建了PullRequest对象
                    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);
            }
        }
    }

    //TODO:分发PullRequest
    this.dispatchPullRequest(pullRequestList);

    return changed;
}

这里说一下,它会遍历MessageQueue集合,这里的MessageQueue集合是经过分配策略分配的结果(默认是平均分配策略),然后根据每个MessageQueue会构建PullRequest对象。

假如我有两个消费者A,B;并且是集群消费,然后队列数是4个,按照平均分配策略,那么A分配到的队列是2个(queueid=0,1),而B也分配2个(queueid=2,3); 集群模式下,一个队列只能被同一个消费者组下的一个消费者去消费。

现在是只有一个消费者A,队列数是4个,所以当前消费者A根据分配策略会分配到4个queue,然后遍历属于消费者A的4个queue,为每个queue创建一个PullRequest,然后放入集合中。

然后我们继续看下分发PullRequest方法的逻辑:

@Override
public void dispatchPullRequest(List<PullRequest> pullRequestList) {
    for (PullRequest pullRequest : pullRequestList) {
        this.defaultMQPushConsumerImpl.executePullRequestImmediately(pullRequest);
        log.info("doRebalance, {}, add a new pull request {}", consumerGroup, pullRequest);
    }
}

这里就是遍历PullRequest集合,然后我们在继续往下看:

public void executePullRequestImmediately(final PullRequest pullRequest) {
    try {
        this.pullRequestQueue.put(pullRequest);
    } catch (InterruptedException e) {
        log.error("executePullRequestImmediately pullRequestQueue.put", e);
    }
}

这里就是重点了,它将PullRequest对象放入了pullRequestQueue队列中,这个队列还熟悉吗?没错,这就是文章开始提到的PullMessageServicepullRequestQueue队列中获取PullRequest对象的队列。刚开始获取不到,它会一直阻塞,现在经过重平衡后,队列中有了数据,现在就可以获取了,然后接下来就是拉取消息。

那么接下来我们就看消费者是如何通过PullMessageService拉取消息的

2. 拉取消息

public class PullMessageService extends ServiceThread {

    //TODO:.....
    
    @Override
    public void run() {
        log.info(this.getServiceName() + " service started");

        while (!this.isStopped()) {
            try {
                //TODO:这里最开始是阻塞的,经过重平衡后可以获取到PullRequest
                PullRequest pullRequest = this.pullRequestQueue.take();
                //TODO:拉取消息
                this.pullMessage(pullRequest);
            } catch (InterruptedException ignored) {
            } catch (Exception e) {
                log.error("Pull Message Service Run Method exception", e);
            }
        }

        log.info(this.getServiceName() + " service end");
    }
}    

现在经过重平衡后,这里就可以获取到PullRequest对象,现在就可以拉取消息了;那么我们继续跟进去,最后来到 DefaultMQPushConsumerImpl#pullMessage(PullRequest request)中. 我们看具体都干了什么?

代码就不贴出来了,比较多,我就直接分析比较重要的部分

2.1 判断是否触发流控

流控主要是保护消费者。当消费者消费能力不够时,拉取速度太快会导致大量消息积压,很可能内存溢出

  1. 判断queue缓存的消息数量是否超过1000(可以根据pullThresholdForQueue参数配置),如果超过1000,则先不去broker拉取消息,而是先暂停50ms,然后重新将对象放入队列中(this.pullRequestQueue.put(pullRequest)),然后重新拉取(就是上面代码中的 this.pullReuqestQueue.take())
  2. 判断queue缓存的消息大小是否超过100M(可以根据 pullThresholdSizeForQueue参数配置),如果超过100M,则先不去broker拉取消息,而是先暂停50ms,然后重新将对象放入队列中(this.pullRequestQueue.put(pullRequest)),然后重新拉取(就是上面代码中的 this.pullReuqestQueue.take())

2.2 构建消息处理的回调对象 PullCallback

它是非常重要的,但是我这里先不说它,等从broker拉取到消息后,会交给它来处理,到时候我们在返回来看它,这里先跳过。

2.3PullAPIWrapper拉取消息

2.3.1 客户端构建拉取消息的请求

//TODO:简单看下参数
this.pullAPIWrapper.pullKernelImpl(
    //TODO: 指定去哪个queue拉取消息
    pullRequest.getMessageQueue(),
    //TODO:表达式,就是tag/sql
    subExpression,
    //TODO: 表达式类型,TAG/SQL
    subscriptionData.getExpressionType(),
    subscriptionData.getSubVersion(),
    //TODO: 这个非常重要的,第一次拉取它的值是 0 
    pullRequest.getNextOffset(),
    //TODO: 这个参数值默认是32
    this.defaultMQPushConsumer.getPullBatchSize(),
    sysFlag,
    commitOffsetValue,
    //TODO:当consumer拉取消息但broker没有时,此时broker会将请求挂起,默认是15s
    BROKER_SUSPEND_MAX_TIME_MILLIS,
    CONSUMER_TIMEOUT_MILLIS_WHEN_SUSPEND,
    //TODO: 异步
    CommunicationMode.ASYNC,
    //TODO: 它就是2.2中的回调对象
    pullCallback
);

nextOffset 参数比较重要,RocketMQ就是通过这个参数来保证消息不会重复消息的(宏观上)

将上面的参数信息封装到PullMessageRequestHeader对象中,然后拉取消息

    PullMessageRequestHeader requestHeader = new PullMessageRequestHeader();
    requestHeader.setConsumerGroup(this.consumerGroup);
    requestHeader.setTopic(mq.getTopic());
    //TODO:消费哪个queue,重平衡服务做的就是这个
    requestHeader.setQueueId(mq.getQueueId());
    //TODO:从哪个queue的offset开始消费
    requestHeader.setQueueOffset(offset);
    //TODO: pullBatchSize, 默认是32
    requestHeader.setMaxMsgNums(maxNums);
    requestHeader.setSysFlag(sysFlagInner);
    requestHeader.setCommitOffset(commitOffset);
    //TODO:当consumer拉取消息但broker没有时,此时broker会将请求挂起,默认是15s
    requestHeader.setSuspendTimeoutMillis(brokerSuspendMaxTimeMillis);
    requestHeader.setSubscription(subExpression);
    requestHeader.setSubVersion(subVersion);
    requestHeader.setExpressionType(expressionType);

    //TODO: 拉取消息
    PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage(
        brokerAddr,
        requestHeader,
        timeoutMillis,
        communicationMode,
        pullCallback);

    return pullResult;

创建拉取消息的netty指令,发送到broker

public PullResult pullMessage(
    final String addr,
    final PullMessageRequestHeader requestHeader,
    final long timeoutMillis,
    final CommunicationMode communicationMode,
    final PullCallback pullCallback
) throws RemotingException, MQBrokerException, InterruptedException {
    //TODO: 构建拉取消息的netty指令:PULL_MESSAGE
    RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.PULL_MESSAGE, requestHeader);

    switch (communicationMode) {
        case ONEWAY:
            assert false;
            return null;
        case ASYNC:
            //TODO: 异步拉取,将拉取消息的结果交给 PullCallback 处理
            this.pullMessageAsync(addr, request, timeoutMillis, pullCallback);
            return null;
        case SYNC:
            return this.pullMessageSync(addr, request, timeoutMillis);
        default:
            assert false;
            break;
    }

    return null;
}

异步拉取消息,将拉取到的消息交给 PullCallback 进行处理,后面我们在回来看PullCallback

2.3.2 服务端接收拉取消息的请求

服务端接收拉取消息请求的处理器是:PullMessageProcessor

首先是一系列的参数,权限判断,我们直接跳过,来到拉取消息的核心代码

public class PullMessageProcessor extends AsyncNettyRequestProcessor implements NettyRequestProcessor {

    private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend) {
    
        RemotingCommand response = RemotingCommand.createResponseCommand(PullMessageResponseHeader.class);
        //TODO:省略部分代码
    
        //TODO: 从broker 拉取消息
        final GetMessageResult getMessageResult =
            this.brokerController.getMessageStore().getMessage(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                requestHeader.getQueueId(), requestHeader.getQueueOffset(), requestHeader.getMaxMsgNums(), messageFilter);
                
                
        //TODO:....省略大段代码......  
        
        
        //TODO:...校验拉取结果
        switch (response.getCode()) {
            case ResponseCode.SUCCESS:

                this.brokerController.getBrokerStatsManager().incGroupGetNums(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                    getMessageResult.getMessageCount());

                this.brokerController.getBrokerStatsManager().incGroupGetSize(requestHeader.getConsumerGroup(), requestHeader.getTopic(),
                    getMessageResult.getBufferTotalSize());

                this.brokerController.getBrokerStatsManager().incBrokerGetNums(getMessageResult.getMessageCount());
                if (this.brokerController.getBrokerConfig().isTransferMsgByHeap()) {
                    final long beginTimeMills = this.brokerController.getMessageStore().now();
                    //TODO: 从 getMessageResult 对象中获取消息内容
                    final byte[] r = this.readGetMessageResult(getMessageResult, requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId());
                    this.brokerController.getBrokerStatsManager().incGroupGetLatency(requestHeader.getConsumerGroup(),
                        requestHeader.getTopic(), requestHeader.getQueueId(),
                        (int) (this.brokerController.getMessageStore().now() - beginTimeMills));
                    //TODO:设置到response中,返回给消费者
                    response.setBody(r);
                }else {
                   //TODO:...省略else....
                } 
               
              //TODO:如果没有拉取到消息,则挂起请求
              case ResponseCode.PULL_NOT_FOUND:

                    if (brokerAllowSuspend && hasSuspendFlag) {
                        long pollingTimeMills = suspendTimeoutMillisLong;
                        if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                            pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
                        }

                        String topic = requestHeader.getTopic();
                        long offset = requestHeader.getQueueOffset();
                        int queueId = requestHeader.getQueueId();
                        //TODO:构建新的拉取对象
                        //TODO:pollingTimeMills = 15 * 1000
                        PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
                            this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);

                        //TODO:放入 pullRequestTable 中,等待 PullRequestHoldService 唤醒
                        this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);

                        //TODO:如果挂起请求,则将response=null, 返回给消费者
                        response = null;
                        break;
                    }
                 }  
                 
           //TODO:....省略部分代码.....      
    
    }
    
    retrun respone;
}

然后我们继续点进去看,它会来到DefaultMessageStore对象的 getMessage(final String group, final String topic, final int queueId, final long offset,final int maxMsgNums, final MessageFilter messageFilter)方法

getMessge()方法参数简单说明一下

  1. 第一个参数是消费者组
  2. 第二个参数是topic
  3. 第三个参数是queueid,表示消费哪个队列
  4. 第四个参数是offset,表示消费的起始偏移量,这个参数比较重要的
  5. 第五个参数是pullBatchSize的值,默认是32
  6. 第六个参数是消息过滤器(消费端可以根据TAG/SQL过滤消息,但是SQL过滤要在broker端完成过滤)

接下来就是拉取消息的核心逻辑了:

//TODO: maxMsgNums 默认是32,取的是 pullBatchSize的值
public GetMessageResult getMessage(final String group, final String topic, final int queueId, final long offset,
    final int maxMsgNums,
    final MessageFilter messageFilter) {
    //TODO: ......省略一些不关注的代码......
    long beginTime = this.getSystemClock().now();

    GetMessageStatus status = GetMessageStatus.NO_MESSAGE_IN_QUEUE;
    //TODO: 下一次消费的起始偏移量,这里先将客户端传递过来的offset赋值给它,继续往后看
    long nextBeginOffset = offset;
    long minOffset = 0;
    long maxOffset = 0;

    //TODO: 创建保存消息的容器
    GetMessageResult getResult = new GetMessageResult();
    //TODO:commmitlog的最大物理偏移量
    final long maxOffsetPy = this.commitLog.getMaxOffset();


    //TODO: 根据 topic 和 queueId 获取 ConsumeQueue
    // 一个 ConsumeQueue 对应一个 MappedFileQueue
    // 一个 MappedFileQueue 对应多个 MappedFile
    ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId);
    if (consumeQueue != null) {
        //TODO: 队列中保存了最大,最小 offset, 会设置到保存消息的容器GetMessageResult中
        minOffset = consumeQueue.getMinOffsetInQueue();
        maxOffset = consumeQueue.getMaxOffsetInQueue();
        //TODO: ...... 省略offset的边缘检测.......
        } else {

            //TODO: 从 consumequeue 中读取索引数据
            //TODO: 这个和消息分发 ReputMessageService 从 commitlog 中读取消息是一样的
            //TODO: 第一次 offset=0, 从 consumequeue中读取多少消息呢?
            //TODO: 在数据分发后,MappedFile wrotePosition 会记录写入的位置(就是记录写到哪里了)
            SelectMappedBufferResult bufferConsumeQueue = consumeQueue.getIndexBuffer(offset);
            if (bufferConsumeQueue != null) {
                try {
                    status = GetMessageStatus.NO_MATCHED_MESSAGE;

                    long nextPhyFileStartOffset = Long.MIN_VALUE;
                    long maxPhyOffsetPulling = 0;

                    int i = 0;

                    //TODO: pullBatchSize(32) 好像并没有用, 只有当 pullBatchSize > 800 时才有用?
                    final int maxFilterMessageCount = Math.max(16000, maxMsgNums * ConsumeQueue.CQ_STORE_UNIT_SIZE);
                    final boolean diskFallRecorded = this.messageStoreConfig.isDiskFallRecorded();
                    ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();


                    //TODO: bufferConsumeQueue.getSize()  就是consumequeue 中的消息索引单元的总size(size/20 = 索引个数)
                    //TODO: 每20个字节往前推
                    for (; i < bufferConsumeQueue.getSize() && i < maxFilterMessageCount; i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {                       
                       //TODO: 一个索引单元包含三个元素:消息偏移量,消息大小,消息tag的hashcode
                        long offsetPy = bufferConsumeQueue.getByteBuffer().getLong();                      
                        int sizePy = bufferConsumeQueue.getByteBuffer().getInt();
                        long tagsCode = bufferConsumeQueue.getByteBuffer().getLong();

                        //TODO: offsetPy + sizePy = 确定一条消息

                        maxPhyOffsetPulling = offsetPy;

                        if (nextPhyFileStartOffset != Long.MIN_VALUE) {
                            if (offsetPy < nextPhyFileStartOffset)
                                continue;
                        }

                        boolean isInDisk = checkInDiskByCommitOffset(offsetPy, maxOffsetPy);

                        //TODO: pullBatchSize在这里会工作,当超过默认的32条后,就会跳出循环
                        if (this.isTheBatchFull(sizePy, maxMsgNums, getResult.getBufferTotalSize(), getResult.getMessageCount(),
                            isInDisk)) {
                            break;
                        }
                        
                        //TODO: ..... 忽略判断代码.......

                        //TODO: 从commitlog 读取消息,一次读取一条消息
                        SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
                        
                        //TODO: .....忽略判断代码,比如没有读取到消息就continue....
                         
                        //TODO: 将读到的消息放入容器中,然后继续循环 
                        getResult.addMessage(selectResult);
                        status = GetMessageStatus.FOUND;
                        nextPhyFileStartOffset = Long.MIN_VALUE;
                    }

                    if (diskFallRecorded) {
                        long fallBehind = maxOffsetPy - maxPhyOffsetPulling;
                        brokerStatsManager.recordDiskFallBehindSize(group, topic, queueId, fallBehind);
                    }


                    //TODO: 下一次的 queue offset
                    //TODO: 假如第一次读取,并且只有一条,那么 nextBeginOffset = 0 + 20 / 20 = 1;
                    nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);

                    long diff = maxOffsetPy - maxPhyOffsetPulling;
                    long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
                        * (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
                    getResult.setSuggestPullingFromSlave(diff > memory);
                } finally {

                    bufferConsumeQueue.release();
                }
            } else {
                status = GetMessageStatus.OFFSET_FOUND_NULL;
                nextBeginOffset = nextOffsetCorrection(offset, consumeQueue.rollNextFile(offset));
                log.warn("consumer request topic: " + topic + "offset: " + offset + " minOffset: " + minOffset + " maxOffset: "
                    + maxOffset + ", but access logic queue failed.");
            }
        }
    } 
    //TODO: 忽略else

    getResult.setStatus(status);

    //TODO: 设置 nextBeginOffset ,消费者拿到 nextBeginOffset 后会设置到 nextOffset
    //TODO: 然后消费者下次传过来,他就是这个方法的参数的  offset
    getResult.setNextBeginOffset(nextBeginOffset);
    getResult.setMaxOffset(maxOffset);
    getResult.setMinOffset(minOffset);
    return getResult;
}

接下来我们对拉取逻辑做个总结:

  1. 创建保存消息的容器对象GetMessageResult,它重要保存4部分内容
  • 1)它会保存拉取到的信息
  • 2)逻辑消费队列的nextBeginOffset,这个参数非常非常的重要,它就表示消费者下次消费时从哪开始读取消息(指的是消息索引,根据索引读取真正的消息),后面我们会看到这个参数;
  • 3)逻辑消费队列的 minOffset,最小消费偏移量
  • 4)逻辑消费队列的 maxOffset, 最大消费偏移量

image.png

  1. 根据topic和queueid获取ConsumeQueue,它就是逻辑消费队列,保存着索引单元数据,以及最大offset, 最小offset,以及最大物理偏移量maxPhysicOffset

这个queueid 就是通过重平衡后分配的

  1. ConsumeQueue中读取索引数据,从offset位置开始读取,那么这个offset是多少?它取的值是从消费端传过来的nextOffset(请看2.3.1);而这个nextOffset,就是从broker返回的nextBeginOffset,后面还会看到它。我假设是第一次读取消息,那么它肯定是0,那么读取多少消息索引数据呢?在我的消息生产章节中,有提到,当消息索引写入后,会有一个wrotePosition参数,记录已经写到的位置; 所以,我这里就读取从 offset到wrotePositon间的索引数据。
//TODO: 这个offset是消费端传过来的 nextOffset,而这个nextOffst 是broker返回的nextBeginOffset
SelectMappedBufferResult bufferConsumeQueue = consumeQueue.getIndexBuffer(offset);

假设我在queueid=0的队列写入了33条消息,那么这里返回的SelectMappedBufferResult对象中,其内部的size=660,因为每个消息索引单元是固定20字节,所以20*33=660

  1. 遍历消息的索引单元(我假设消息生产者向queueid=0队列写入了33条数据,而我读取的也是queueid=0的队列,而且是第一次消费)
//TODO: bufferConsumeQueue.getSize()就是consumequeue 中的消息索引单元的总size(size/20 = 索引个数),如果是写入了33条数据,则size=660
//TODO: 循环条件是每次递增20byte(因为每个索引单元固定20byte)
int i = 0;
for (; i < bufferConsumeQueue.getSize() && i < maxFilterMessageCount; i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
  //TODO:......
}

i = 0, 遍历第1条消息索引单元,读取这个索引单元存储的3个数据,分别是消息偏移量offsetPy,消息大小sizePy,消息tag的hashcode,然后根据消息的物理偏移量offsetPy消息大小sizePy,从commitlog中读取一条消息

SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);

然后将消息保存到容器对象GetMessageResult中.
i = 20 .....遍历第2条消息索引单元.......
i = 40 .....遍历第3条消息索引单元.......
........
i = 620 .....遍历第32条消息索引单元,将消息保存到容器中,此时容器中已经有了32条消息
i = 640 .....遍历第33条消息索引单元,但是此时(pullBatchSize的值<=容器中消息的总数)的结果是true, 如果是true,则跳出循环,不在遍历索引数据。

  1. 计算下一次从队列的哪个位置开始消费,也就是计算队列新的起始offset
//TODO: 下一次的 queue offset
nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);

前面我读到 i=640 时,也就是读到第32条消息后退出了循环。由于是第一次消费,所以offset=0, i = 640, 640/20 =32, 所以nextBeginOffset=32(当我下次读取时,这个nextBeginOffset 就变成了上面的offset)

那么本次共计读取了32条消息(还有1条消息没有读取消费,等待下一次读取)

  1. 给容器对象GetMessageResult设置offset值(重要)
//TODO: 设置 nextBeginOffset ,消费者拿到 nextBeginOffset 后会设置到 nextOffset
//TODO: 然后消费者下次传过来,他就是这个方法的参数的  offset
getResult.setNextBeginOffset(nextBeginOffset);
getResult.setMaxOffset(maxOffset);
getResult.setMinOffset(minOffset);

nextBeginOffset=32,maxOffset=33,minOffset=0

  1. 检查拉取结果

2.3.3 检查拉取结果

  1. 如果拉取到了消息,则将消息内容设置到响应对象RemotingCommandPullMessageResponseHeader)中,然后返回给客户端。
  2. 如果没有拉取到消息,则将请求挂起,然后RemotingCommand将置为null,返回给消费者。然后通过异步任务PullRequestHoldService实时扫描挂起的拉取请求。
  • 如果有消息达到,则立刻唤醒挂起的请求,主动去broker拉取消息,然后主动推送给消费者
  • 如果没有消息到达,但是挂起时间到了(20s),则也会主动去broker拉取消息,如果此时有消息,则将拉取结果主动推送给消费者,如果没有,则继续挂起请求。

2.3.4 客户端获取broker的响应结果

就是将broker响应的PullMessageResponseHeader对象转换成客户端本地对象PullResult,然后将PullResult对象交给回调函数PullCallback处理(就是 2.2 步骤)

2.4 回调函数PullCallback处理拉取到消息(参考2.2步骤)

//TODO: 拉取消息回调,这里非常重要,不过这里是从broker拉取消息成功后才执行的,继续往后看
PullCallback pullCallback = new PullCallback() {
    @Override
    public void onSuccess(PullResult pullResult) {
        if (pullResult != null) {

            /**
             * 处理从broker读取到的消息
             * 将二进制内容抓换成 MessageExt 对象 并根据tag进行过滤
             */
            pullResult = DefaultMQPushConsumerImpl.this.pullAPIWrapper.processPullResult(pullRequest.getMessageQueue(), pullResult,
                subscriptionData);

            switch (pullResult.getPullStatus()) {

                //TODO: 发现了消息
                case FOUND:
                    long prevRequestOffset = pullRequest.getNextOffset();
                    pullRequest.setNextOffset(pullResult.getNextBeginOffset());
                    long pullRT = System.currentTimeMillis() - beginTimestamp;
                    DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullRT(pullRequest.getConsumerGroup(),
                        pullRequest.getMessageQueue().getTopic(), pullRT);

                    long firstMsgOffset = Long.MAX_VALUE;

                    //TODO: 如果没有消息则立即执行,立即拉取的意思是继续将PullRequest 放入队列中,这样
                    // take()方法将不会在阻塞,然后继续从broker拉取消息,从而达到持续从broker拉取消息
                    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());

                        //TODO: 将消息提交到线程池中,由ConsumeMessageService 进行消费
                        DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
                            pullResult.getMsgFoundList(),
                            processQueue,
                            pullRequest.getMessageQueue(),
                            dispatchToConsume);


                        //TODO: 上面是异步消费,然后这里是将PullRequest放入 队列中,这样take()方法将不会
                        //TODO: 阻塞,然后继续从broker拉取消息,从而达到持续从broker拉取消息
                        //延迟 pullInterval 时间再去拉取消息
                        if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
                            DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
                                DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
                        } else {
                            //立即拉取消息
                            DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                        }
                    }

                    if (pullResult.getNextBeginOffset() < prevRequestOffset
                        || firstMsgOffset < prevRequestOffset) {
                        log.warn(
                            "[BUG] pull message result maybe data wrong, nextBeginOffset: {} firstMsgOffset: {} prevRequestOffset: {}",
                            pullResult.getNextBeginOffset(),
                            firstMsgOffset,
                            prevRequestOffset);
                    }

                    break;
                case NO_NEW_MSG:
                case NO_MATCHED_MSG:
                    pullRequest.setNextOffset(pullResult.getNextBeginOffset());

                    DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);

                    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                    break;
                case OFFSET_ILLEGAL:
                    log.warn("the pull request offset illegal, {} {}",
                        pullRequest.toString(), pullResult.toString());
                    pullRequest.setNextOffset(pullResult.getNextBeginOffset());

                    pullRequest.getProcessQueue().setDropped(true);
                    DefaultMQPushConsumerImpl.this.executeTaskLater(new Runnable() {

                        @Override
                        public void run() {
                            try {
                                DefaultMQPushConsumerImpl.this.offsetStore.updateOffset(pullRequest.getMessageQueue(),
                                    pullRequest.getNextOffset(), false);

                                DefaultMQPushConsumerImpl.this.offsetStore.persist(pullRequest.getMessageQueue());

                                DefaultMQPushConsumerImpl.this.rebalanceImpl.removeProcessQueue(pullRequest.getMessageQueue());

                                log.warn("fix the pull request offset, {}", pullRequest);
                            } catch (Throwable e) {
                                log.error("executeTaskLater Exception", e);
                            }
                        }
                    }, 10000);
                    break;
                default:
                    break;
            }
        }
    }
    
    @Override
    public void onException(Throwable e) {
        if (!pullRequest.getMessageQueue().getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
            log.warn("execute the pull request exception", e);
        }

        DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
    }
    
 };

2.4.1 如果拉取出现异常

如果拉取出现异常,则执行异常回调

@Override
public void onException(Throwable e) {
    if (!pullRequest.getMessageQueue().getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
        log.warn("execute the pull request exception", e);
    }

    //TODO:延迟3s钟,将`PullRequest`对象再次放入队列`pullRequestQueue`中,等待再次`take()`,然后继续拉取消息的逻辑
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
}

如果broker没有消息,则将拉取请求挂起,然后返回一个null对象给消费者,消费者如果拿到的是null,则视为是异常情况,然后执行异常回调。

所以说,如果没有消息,则broker将拉取请求挂起,其目的是如果有消息到达,能立刻写给消费者;同时消费者也会每隔3s去broker拉取一次,如果这次依然没有消息,则继续将本次拉取请求挂起。

2.4.2 拉取成功,开始处理消息

2.4.2.1 消息转换和过滤

将二进制内容转换成 MessageExt 对象;并根据tag进行过滤

public PullResult processPullResult(final MessageQueue mq, final PullResult pullResult,
    final SubscriptionData subscriptionData) {
    PullResultExt pullResultExt = (PullResultExt) pullResult;

    this.updatePullFromWhichNode(mq, pullResultExt.getSuggestWhichBrokerId());
    if (PullStatus.FOUND == pullResult.getPullStatus()) {
        ByteBuffer byteBuffer = ByteBuffer.wrap(pullResultExt.getMessageBinary());
        //TODO:将二进制消息转换成MessageExt对象
        List<MessageExt> msgList = MessageDecoder.decodes(byteBuffer);

        List<MessageExt> msgListFilterAgain = msgList;
        //TODO:根据TAG过滤消息
        if (!subscriptionData.getTagsSet().isEmpty() && !subscriptionData.isClassFilterMode()) {
            msgListFilterAgain = new ArrayList<MessageExt>(msgList.size());
            for (MessageExt msg : msgList) {
                if (msg.getTags() != null) {
                    if (subscriptionData.getTagsSet().contains(msg.getTags())) {
                        msgListFilterAgain.add(msg);
                    }
                }
            }
        }
        
        //TODO:....省略......
}

2.4.2.2 更新PullRequest对象的nextOffset属性值

//TODO: 发现了消息
case FOUND:
    long prevRequestOffset = pullRequest.getNextOffset();
    //TODO:更新nextOffset的值
    pullRequest.setNextOffset(pullResult.getNextBeginOffset());
    long pullRT = System.currentTimeMillis() - beginTimestamp;
    DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullRT(pullRequest.getConsumerGroup(),
        pullRequest.getMessageQueue().getTopic(), pullRT);

在前面2.3.2步骤中,我们举例读取了32条消息后,nextBegingOffset经过计算是32,然后将消息和offset值一并返回给消费者。所以这里PullRequestnextOffset值是32.

2.4.2.3 将读取到的消息保存到本地缓存队列ProcessQueue

//TODO:将本次读取到的所有消息(经过了TAG/sql过滤了)保存到队列中
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());

2.4.2.4 将消息提交到线程池中进行消费(重要)

这里我先不展开说,我放到第3大步骤中详细展开

2.4.2.5 再次将 PullRequest 放到阻塞队列

//TODO: 上面是异步消费(5.4.2.4),然后这里是将PullRequest放入 队列中,这样take()方法将不会
//TODO: 阻塞,然后继续从broker拉取消息,从而达到持续从broker拉取消息
//延迟 pullInterval 时间再去拉取消息
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
        DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
    //立即拉取消息
    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}

这里有一个 pullInterval参数,表示间隔多长时间在放入队列中(实际上就是间隔多长时间再去broker拉取消息)。当消费者消费速度比生产者快的时候,可以考虑设置这个值,这样可以避免大概率拉取到空消息。

上面将新的对象PullRequest放入队列中(这个新仅仅是因为nextOffset值变了),然后还是执行第2大步骤,当broker接收到拉取请求后,然后将根据nextOffset(值=32)读取逻辑索引的值,我当初举例的时候,是总共写入了33条消息(那么就有33条索引数据),所以,他会读取出[32,33]区间的索引数据,也就是最后一条消息索引,然后读取出真正的消息,再次计算 nextBeginOffset的值,然后返回给消费者。如此反复,从broker读取消息消费。

3.ConsumeMessageService消费消息

就是2.4.2.4步骤的逻辑

//TODO: 将消息提交到线程池中,由ConsumeMessageService 进行消费
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
    pullResult.getMsgFoundList(),
    processQueue,
    pullRequest.getMessageQueue(),
    dispatchToConsume);

由于我们的是普通消息(不是顺序消息),所以由ConsumeMessageConcurrentlyService类来消费消息。

ConsumeMessageConcurrentlyService内部会创建一个线程池ThreadPoolExecutor,这个线程池非常重要,消息最终将提交到这个线程池中。

但是在提交到线程池之前,还要做一件事 ---》 分割消息

3.1 分割消息

@Override
public void submitConsumeRequest(
    final List<MessageExt> msgs,
    final ProcessQueue processQueue,
    final MessageQueue messageQueue,
    final boolean dispatchToConsume) {
    //TODO:默认值是1
    final int consumeBatchSize = this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
    //TODO: msg.size()是从broker拉取到的经过TAG/SQL过滤后的消息总和
    if (msgs.size() <= consumeBatchSize) {
        ConsumeRequest consumeRequest = new ConsumeRequest(msgs, processQueue, messageQueue);
        try {
            this.consumeExecutor.submit(consumeRequest);
        } catch (RejectedExecutionException e) {
            this.submitConsumeRequestLater(consumeRequest);
        }
    } else {
        //TODO:分割消息
        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);
            }
        }
    }
}

就是比较本次拉取到的消息总数size与consumeMessageBatchMaxSize(默认=1)值的大小.如果size > consumeMessageBatchMaxSize,则按照consumeMessageBatchMaxSize将消息分割,然后分批次将消息submit到线程池中。

3.2 将消息submit到线程池中开始消费

提交到线程池中的是 ConsumeRequest对象,他是一个Runnable, 所以我们就看ConsumeRequestrun()方法就好。

获取消息监听器然后开始消费

@Override
public void run() {
    //TODO: 省略部分代码.....
    //TODO: 获取消息监听器MessageListenerConcurrently
    MessageListenerConcurrently listener = ConsumeMessageConcurrentlyService.this.messageListener;
   
    //TODO:省略部分代码.......
    try {
        //TODO:开始消费
        status = listener.consumeMessage(Collections.unmodifiableList(msgs), context);
    } catch (Throwable e) {
        log.warn("consumeMessage exception: {} Group: {} Msgs: {} MQ: {}",
            RemotingHelper.exceptionSimpleDesc(e),
            ConsumeMessageConcurrentlyService.this.consumerGroup,
            msgs,
            messageQueue);
        hasException = true;
    }
    //TOOD:省略部分代码
}

这个监听器以及消费方法熟悉吗? 没错,他就是我们消费代码中指定的回调监听器

image.png

到这里,消费者就真正开始消费消息了。。。。。

3.3 处理消费结果

ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);

直接看它的核心逻辑:

switch (this.defaultMQPushConsumer.getMessageModel()) {
    case BROADCASTING:
        //TODO: 广播模式下,如果消费失败,则直接丢弃消息
        //消费失败才会进入循环
        for (int i = ackIndex + 1; i < consumeRequest.getMsgs().size(); i++) {
            MessageExt msg = consumeRequest.getMsgs().get(i);
            log.warn("BROADCASTING, the message consume failed, drop it, {}", msg.toString());
        }
        break;
    case CLUSTERING:
        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);
            //TODO:将消息发送到broker,继续看这个方法内部
            boolean result = this.sendMessageBack(msg, context);
            if (!result) {
                msg.setReconsumeTimes(msg.getReconsumeTimes() + 1);
                msgBackFailed.add(msg);
            }
        }

        //TODO: 如果消息发送都broker失败,也不能丢弃,延迟5s后再次放入线程池中
        if (!msgBackFailed.isEmpty()) {
            consumeRequest.getMsgs().removeAll(msgBackFailed);

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

3.3.1 消费失败or成功

3.3.1.1 广播模式消费

广播模式消费成功,然后执行3.3.2步骤
广播模式消费失败,直接丢弃消息,什么也不做

3.3.1.2 集群模式消费

集群模式消费成功,然后执行3.3.2步骤
集群模式消费失败,则遍历消息,将每条消息重新发回到broker;如果消息发回到broker失败,也不能丢弃,则将消息重新放到ConsumeMessageConcurrentlyService内部的线程池中,等待再次消费。

这里简单说下,发回broker都做了什么?

  1. 根据消费者组构建重试topic"%RETRY%GroupName"
  2. 从commitlog再次读取出这条消息,在其properties中标记为retry。读取这条消息的目的是为了使用它的一些消息内容
  3. 设置延迟等级(再次说明消息的重试是利用延迟消息机制),第一次delayLevel默认是3,对应的延迟时间是10s,每次重试延迟等级+1;超过默认的16次后,则放入死信队列。

image.png

  1. 构建消息体对象MessageExtBrokerInner,设置topic为重试topic,设置重试次数+1
  2. 然后将消息写入到commitlog中参考消息写入过程,然后消息分发创建索引。
  3. 然后等待10s后,读取消息重试

这也说明,重试的消息虽然和原来的消息一模一样,但本质已经是新的消息了(原来的消息实际上已经被消费过了)

这里有一个疑问?
假如我提交到线程池中的消息总数是10条,我前面9条都消费成功了,但是最后一条消费失败了,那么前面9条也要重试吗?
答案是:是的。这也说明重试可能会导致重复消费。这一点还是要注意的。

3.3.2 从本地缓存队列中移除消息并持久化offset

long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
    this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
}

无论是否消费成功,都会将队列缓存的消息remove掉,然后更新offset到offset表中(offsetTable)

@Override
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);
            }
        }
    }
}

在Consumer客户端启动的时候,会启动很多定时任务,其中就有持久化offset的定时任务

//TODO: 延迟10s之后,每隔5s执行一次持久化任务
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

    @Override
    public void run() {
        try {
            MQClientInstance.this.persistAllConsumerOffset();
        } catch (Exception e) {
            log.error("ScheduledTask persistAllConsumerOffset exception", e);
        }
    }
}, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

遍历所有queue,然后将其offset持久化到文件中。
总结下步骤:

  1. 构建UpdateConsumerOffsetRequestHeader对象,设置topic,queueid,offset
  2. 构建netty指令(RequestCode.UPDATE_CONSUMER_OFFSET),发送到broker端
RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.UPDATE_CONSUMER_OFFSET, requestHeader);
  1. broker接收到指令后,将信息保存到ConsumerOffsetManager对象的offsetTable属性中

注意:这个offsetTable是服务端的,前面那个是消费者客户端的

  1. 服务端持久化offset,在broker(BrokerController)启动的时候,也会启动很多定时任务,其中就有持久化offset的,就是将上面的offsetTable内容写到文件中;代码如下:
//TODO:延迟10s,每隔5s持久化一次offset
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        try {
            BrokerController.this.consumerOffsetManager.persist();
        } catch (Throwable e) {
            log.error("schedule persist consumerOffset error.", e);
        }
    }
}, 1000 * 10, this.brokerConfig.getFlushConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

持久化的默认文件路径是:$home/store/config/consumerOffset.json
文件内容如下:

image.png

至此,消费者的消费就结束了。

4.总结

消费的过程要比消息的生产复杂的多,简单总结下

  1. 消费者启动拉取消息的服务PullMessageService,先阻塞在这里
  2. 然后启动重平衡服务,重平衡就是给当前消费者指定要去消费哪个queue,然后上面的就可以拉取消息了
  3. 发送netty请求到broker,从commitlog读取消息
  4. 读取到消息后开始处理消息,解码消息,根据TAG过滤消息
  5. 消费者消费消息
  6. 处理消费者的消费结果

限于作者水平,文中难免有错误之处,欢迎指正,勿喷,感谢感谢

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