RocketMQ如何构建ComsumerQueue的?

前言

RocketMQ的消息都是按照先来后到,顺序的存储在CommitLog中的,而消费者通常只关心某个Topic下的消息。顺序的查找CommitLog肯定是不现实的,我们可以构建一个索引文件,里面存放着某个Topic下面所有消息在CommitLog中的位置,这样消费者获取消息的时候,只需要先查找这个索引文件,然后再去CommitLog中获取消息就 OK了。这个索引文件,就是我们的ComsumerQueue。

如何构建

在Broker中,构建ComsummerQueue不是存储完CommitLog就马上同步构建的,而是通过一个线程任务异步的去做这个事情。在DefaultMessageStore中有一个ReputMessageService成员,它就是负责构建ComsumerQueue的任务线程。

public class DefaultMessageStore implements MessageStore {
    // 。。。。省略无关代码
    private final ReputMessageService reputMessageService;
 }   

ReputMessageService继承自ServiceThread,表明其是一个服务线程,它的run方法很简单,如下所示:

 public void run() {
            while (!this.isStopped()) {
                try {
                    Thread.sleep(1);
                    this.doReput(); // 构建ComsumerQueue
                } catch (Exception e) {
                    DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
                }
            }
        }

在run方法里,每休息1毫秒就进行一次构建ComsumerQueue的动作。因为必须先写入CommitLog,然后才能进行ComsumerQueue的构建。那么不排除构建ComsumerQueue的速度太快了,而CommitLog还没写入新的消息。这时就需要sleep下,让出cpu时间片,避免浪费CPU资源。

doReput

doReput的代码如下所示:

private void doReput() {
            for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
                SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);// 拿到所有的最新写入CommitLog的数据
                if (result != null) {
                    try {
                        this.reputFromOffset = result.getStartOffset();

                        for (int readSize = 0; readSize < result.getSize() && doNext; ) {
                            DispatchRequest dispatchRequest =
                            DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false); // 一条一条的读消息
                            int size = dispatchRequest.getMsgSize();

                            if (dispatchRequest.isSuccess()) {
                                if (size > 0) {
                                    DefaultMessageStore.this.doDispatch(dispatchRequest); // 派发消息,进行处理,其中就包括构建ComsumerQueue
                                    this.reputFromOffset += size;
                                    readSize += size;
                                } else if (size == 0) { // 
                                    this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
                                    readSize = result.getSize();
                                }
                            } else if (!dispatchRequest.isSuccess()) { // 获取消息异常

                                if (size > 0) {
                                    log.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
                                    this.reputFromOffset += size;
                                } else {
                                    doNext = false;
                                    if (DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
                                        this.reputFromOffset += result.getSize() - readSize;
                                    }
                                }
                            }
                        }
                    } finally {
                        result.release();
                    }
                } else {
                    doNext = false;
                }
            }
        }

为了突出重点,省略了一些和构建ComsumerQueue不相干的代码。在doReput里面,其实做了3件事情:
1-获取最新写入到CommitLog中的数据byteBuffer。
2-从byteBuffer中一条条的读取消息,并派发出去处理。
3-更新reputFromOffset位移。

从何处构建

reputFromOffset是一个非常重要参数,它指出了我们应该从哪里开始构建ComsumerQueue。在DefaultMessageStore的start()方法中,对reputFromOffset进行了初始化:

public void start() throws Exception 
        if (this.getMessageStoreConfig().isDuplicationEnable()) {
            this.reputMessageService.setReputFromOffset(this.commitLog.getConfirmOffset());
        } else {
            this.reputMessageService.setReputFromOffset(this.commitLog.getMaxOffset());
        }
        
        this.reputMessageService.start();
    }

如果允许消息重复,那么reputFromOffset会从CommitLog的ConfirmOffset中获取,否则获取CommitLog的最大偏移量。duplicationEnable默认是关闭的,也就是默认是获取CommitLog的最大写入的偏移量。
我对这个confirmOffset其实并不理解,在代码里也没有搜到设置该值的源头,应该是需要自己实现MessageStore类的时候才用得到。既然默认是不允许重复的,那么这个就不再去深究了,也不影响我们对ComsumerQueue的理解。

看到这里,我其实是有一个疑问的:为什么会从CommitLog的最大偏移量开始构建呢?。按照正常的思路,应该最开始从零开始构建,然后构建一个,reputFromOffset就累加一个。构建完一批后,如果又可以构建了,则接着从上次结束的地方开始构建。我们来一探究竟。

我们先看看getMaxOffset方法:

public long getMaxOffset() {
        MappedFile mappedFile = getLastMappedFile();
        if (mappedFile != null) {
            return mappedFile.getFileFromOffset() + mappedFile.getReadPosition();
        }
        return 0;
    }

在RocketMQ第一次启动时,没有发送消息,Commit文件还没有创建。此时getLastMappedFile()返回的是null,因此getMaxOffset拿到的就是0。也就是说,当RocketMQ第一次启动,构建ConsumerQueue是从头开始的,这个符合我们的期望。通过断点查看reputFromOffset,确实如此,如下所示:
在这里插入图片描述
那么如果CommitLog还有数据没有处理完,Broker通过shutdown正常停止了呢?那下次重新启动,reputFromOffset设置成了getMaxOffset,那不是丢了一部分数据了嘛?

不必担心,在DefaultMessageStore关闭时,会尽力等待数据追上。具体来说就是通过50次sleep,每次100毫秒,如果在这时间内ConsumerQueue可以把数据追平(reputFromOffset == maxOffset),那么就没有问题。如果仍然不能追平,那没办法了,打个警告日志吧。后续可以通过手工重发消息处理。

public void shutdown() {
            for (int i = 0; i < 50 && this.isCommitLogAvailable(); i++) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
            }

            if (this.isCommitLogAvailable()) {
                log.warn("shutdown ReputMessageService, but commitlog have not finish to be dispatched, CL: {} reputFromOffset: {}",
                    DefaultMessageStore.this.commitLog.getMaxOffset(), this.reputFromOffset);
            }

            super.shutdown();
        }

好,继续回到我们doReput所做的3件事情。

获取数据

当设置好reputFromOffset之后,就可以从CommitLog中获取从reputFromOffset到目前已经写入的所有消息内容。

public SelectMappedBufferResult getData(final long offset, final boolean returnFirstOnNotFound) {
        int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMapedFileSizeCommitLog();
        MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
        if (mappedFile != null) {
            int pos = (int) (offset % mappedFileSize);
            SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
            return result;
        }

        return null;
    }

首先从mappedFileQueue中,根据偏移量找到消息的mappedFile文件,具体算法是根据reputFromOffset-第一个文件的offset,然后除以单个文件大小,得到该文件的索引值。如果该文件存在,则返回,否则循环查找一遍。
找到文件后,取出对应的byteBuffer内容,封装后返回。

派发处理

拿到所有消息的byteBuffer后,循环读取消息,封装成DispatchRequest进行派发处理。

DefaultMessageStore.this.doDispatch(dispatchRequest);

DispatchRequest类如下所示:

public class DispatchRequest {
    private final String topic;
    private final int queueId;
    private final long commitLogOffset;
    private final int msgSize;
    private final long tagsCode;
    private final long storeTimestamp;
    private final long consumeQueueOffset; // 逻辑偏移量,非物理偏移量
    private final String keys;
    private final boolean success;
    private final String uniqKey;

    // .....省略非重点
  }  

构建ComsumerQueue的派发处理在CommitLogDispatcherBuildConsumeQueue类中进行处理,如下所示:

 public void dispatch(DispatchRequest request) {
            final int tranType = MessageSysFlag.getTransactionValue(request.getSysFlag());
            switch (tranType) {
                case MessageSysFlag.TRANSACTION_NOT_TYPE:
                case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
                    DefaultMessageStore.this.putMessagePositionInfo(request);
                    break;
                case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
                case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
                    break;
            }
        }

由于正常情况下是非事务消息,因此走到的是MessageSysFlag.TRANSACTION_NOT_TYPE。

public void putMessagePositionInfo(DispatchRequest dispatchRequest) {
        ConsumeQueue cq = this.findConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
        cq.putMessagePositionInfoWrapper(dispatchRequest);
    }

然后根据topic和queueId找到对应的ConsumerQueue。这里queueId是Producer发送消息时就根据算法选好了的,具体怎么选的可以参考之前的文章:消息队列如何选择的?。找到队列后,就可以保存相关信息了,如下所示:

 boolean result = this.putMessagePositionInfo(request.getCommitLogOffset(),
                request.getMsgSize(), tagsCode, request.getConsumeQueueOffset());

其中这个cosumerQueueOffset是逻辑偏移量,并非物理偏移量。因为每一个条目都是固定的20个字节大小,
存放的内容是8字节的消息偏移+4字节的消息长度+8字节的tagsCode。这样存储一个索引条目时,通过这个逻辑偏移量*20字节,就可以得到它的物理偏移量。如下所示:

 final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;

然后就是熟悉的流程了,找到偏移所在的文件,然后保存内容。

更新reputFromOffset位移

在循环处理消息时,每处理一条,则将reputFromOffset更新。这里更新是根据获取消息的结果来的,因此有必要看看是如何获取单条消息的。

 /**
     * check the message and returns the message size
     *
     * @return 0 Come the end of the file // >0 Normal messages // -1 Message checksum failure
     */
    public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,
        final boolean readBody) {}

注释写的比较清楚,size=0,说明到了文件末尾。 > 0说明是正常的消息。< 0说明消息有误。下面我们跟着checkMessageAndReturnSize代码一个个来看:

            int magicCode = byteBuffer.getInt();
            switch (magicCode) {
                case MESSAGE_MAGIC_CODE:
                    break;
                case BLANK_MAGIC_CODE:
                    return new DispatchRequest(0, true /* success */);
                default:
                    log.warn("found a illegal magic code 0x" + Integer.toHexString(magicCode));
                    return new DispatchRequest(-1, false /* success */);
            }

在之前的讲解RocketMQ消息存储结构-CommitLog的时候,讲过这个magicCode:
在这里插入图片描述
因此当该mappedFile没有空间存储该条消息时,其magicCode是BLANK_MAGIC_CODE,此时我们返回的DispatchRequest(0, true /* success */)。后续的处理就是滚动到下一个mappedFile:
在这里插入图片描述
此时reputFromOffset更新为下个文件的偏移开始位置。
注:rollNextFile里面reputFromOffset计算显得略微复杂了,不知为何。其实直接取该mappedFile的fileFromOffset就可以了。

当magicCode不正确的时候,表明消息出问题了。此时返回DispatchRequest(-1, false /* success */)。后续处理就是结束本次doReput,并将reputFromOffset设置到本次数据末尾。
在这里插入图片描述
消息出问题后,Broker MASTER节点的reputFromOffset跳过了剩下的数据,这意味着后续的部分数据都被忽略不再处理了。为什么不仅仅跳过该条消息呢?这里我还不大清楚。

接下来是检查消息CRC,如果检查失败,返回的也是DispatchRequest(-1, false /* success */),处理方法同magicCode一样,都是消息本身有问题的处理方式。

然后是手动计算一遍消息大小和读取的消息大小是否一致,如果不一致,则返回DispatchRequest(totalSize, false/* success */)。此时reputFromOffset就跳过该条消息,如下所示:

this.reputFromOffset += size;

至此,reputFromOffset的更新,我们就讲完了。

通过此篇文章,我们大致的了解了RocketMQ是如何构建消费队列(ConsumerQueue)的。通过一个服务线程,异步的从CommitLog中获取已经写入的消息,然后将消息位置,大小,tagsCode保存至我们的选好的ConsumerQueue中。但是,这里的保存仅仅是写入byteBuffer,还没有真正的落到物理文件上。真正的落盘操作,也是通过服务线程进行异步处理的,限于篇幅,我们后续再谈。

发布了379 篇原创文章 · 获赞 85 · 访问量 59万+

猜你喜欢

转载自blog.csdn.net/GAMEloft9/article/details/103778895