【RocketMQ】Broker消息投递源码分析

356 阅读9分钟

1. 前言

之前的文章介绍了Consumer是怎么拉取消息的,但是没有介绍Broker是如何处理Consumer消息拉取请求的,Broker在接收到Consumer的消息拉取请求后,是如何检索消息然后投递给客户端的呢?本篇文章会详细分析。 ​

回顾一下Consumer消息拉取流程,Consumer服务启动会做一次重平衡操作,重新分配MessageQueue,对于新分配的MessageQueue会构建对应的PullRequest去拉取消息,此时PullMessageService线程会被唤醒,调用PullAPIWrapper的pullKernelImpl方法开始拉取消息。 ​

拉取消息对应的RequestCode为PULL_MESSAGE,Broker服务接收到Consumer的请求后,会根据RequestCode去查找对应的处理器PullMessageProcessor,然后调用其processRequest方法开始处理请求。 ​

2. 相关组件

在看源码之前,先了解一下消息投递涉及的相关组件很有必要。

2.1 PullMessageProcessor

用来处理Consumer消息拉取请求的处理器,在处理请求前它会做一些基础校验,例如:Broker、Topic是否有读权限,服务是否正常运行等等。基础校验通过后,再开始解析请求头,构建SubscriptionData订阅关系,创建MessageFilter过滤消息,从MessageStore检索消息,保存Consumer消费位点,返回结果。 ​

2.2 TopicConfigManager

Topic配置信息管理器,它用来管理Broker上所有的Topic信息,例如:Topic下的读写队列个数、权限、消息过滤类型、是否是顺序消息等。会定时持久化到store/config/topics.json文件,Broker启动时会读取磁盘文件进行恢复。

2.3 MessageFilter

消息过滤器,RocketMQ允许Consumer在订阅Topic时给定一个子表达式来过滤消息,表达式类型可以是TAG或SQL92语法。MessageFilter接口有两个方法用来匹配消息是否需要投递给Consumer,isMatchedByConsumeQueue方法在读取ConsumeQueue索引时即可根据TagsHash快速匹配,当然存在哈希碰撞的概率,Consumer会再做一次Tag字符串的匹配isMatchedByCommitLog方法通过读取CommitLog文件来进行匹配,因为SQL92语法可以根据消息属性来过滤消息,而消息属性是存储在CommitLog中的,因此必须通过该方法来判断。

2.1 DefaultMessageStore

默认的消息仓库,RocketMQ将消息存储到磁盘,涉及的文件有:CommitLog、ConsumeQueue、Index,这些文件均通过MessageStore进行维护管理。在处理Consumer的拉取请求时,MessageStore会先根据Topic和queueId定位到ConsumeQueue,然后根据拉取位点Offset定位到具体的索引项,索引项的前8个字节记录的是消息在CommitLog文件中的物理偏移量,根据该偏移量即可快速定位到具体的消息。

2.1 ConsumerOffsetManager

消费者消费位点管理器,用来存储消费者的消费进度,会定时持久化到store/config/consumerOffset.json文件,Broker启动时同样会读取文件恢复数据。Consumer在拉取消息时,会在请求头带上自己的消费位点commitOffset,Broker处理拉取请求时会顺带记录Consumer的消费进度。 ​

3. 源码阅读

Broker消息投递的整体流程并不复杂,时序图如下: 在这里插入图片描述

Netty服务端在接收到客户端的数据包后,将字节序列编码为RemotingCommand对象,然后根据RequestCode去匹配对应的处理器,这里是PullMessageProcessor,然后调用方法processRequest开始处理请求。 ​

1.消息拉取请求的处理过程大致如下:

  1. 解析请求头,创建响应
  2. 校验Broker、Topic读权限
  3. 解析订阅关系,创建MessageFilter
  4. 从MessageStore获取消息结果
  5. 保存Consumer消费位点
  6. 返回结果

首先是进行校验,确保拉取的Broker有读权限。

if (!PermName.isReadable(this.brokerController.getBrokerConfig().getBrokerPermission())) {
    response.setCode(ResponseCode.NO_PERMISSION);
    response.setRemark(String.format("the broker[%s] pulling message is forbidden", this.brokerController.getBrokerConfig().getBrokerIP1()));
    return response;
}

然后解析sysFlag,没有消息时Broker是否阻塞?Consumer是否上报了消费位点?是否需要根据子表达式过滤消息?

// 没有消息时是否阻塞等待
final boolean hasSuspendFlag = PullSysFlag.hasSuspendFlag(requestHeader.getSysFlag());
// 是否上报消费位点
final boolean hasCommitOffsetFlag = PullSysFlag.hasCommitOffsetFlag(requestHeader.getSysFlag());
// 是否需要根据子表达式过滤消息
final boolean hasSubscriptionFlag = PullSysFlag.hasSubscriptionFlag(requestHeader.getSysFlag());
// Broker阻塞等待时间
final long suspendTimeoutMillisLong = hasSuspendFlag ? requestHeader.getSuspendTimeoutMillis() : 0;

查找Consumer拉取的Topic配置信息,确保它有读权限,校验拉取的queueId是否合法。

// 查找Topic配置
TopicConfig topicConfig = this.brokerController.getTopicConfigManager().selectTopicConfig(requestHeader.getTopic());
if (null == topicConfig) {
    log.error("the topic {} not exist, consumer: {}", requestHeader.getTopic(), RemotingHelper.parseChannelRemoteAddr(channel));
    response.setCode(ResponseCode.TOPIC_NOT_EXIST);
    response.setRemark(String.format("topic[%s] not exist, apply first please! %s", requestHeader.getTopic(), FAQUrl.suggestTodo(FAQUrl.APPLY_TOPIC_URL)));
    return response;
}
// 校验Topic可读权限
if (!PermName.isReadable(topicConfig.getPerm())) {
    response.setCode(ResponseCode.NO_PERMISSION);
    response.setRemark("the topic[" + requestHeader.getTopic() + "] pulling message is forbidden");
    return response;
}
// 校验拉取消息的queueId
if (requestHeader.getQueueId() < 0 || requestHeader.getQueueId() >= topicConfig.getReadQueueNums()) {
    String errorInfo = String.format("queueId[%d] is illegal, topic:[%s] topicConfig.readQueueNums:[%d] consumer:[%s]",
                                     requestHeader.getQueueId(), requestHeader.getTopic(), topicConfig.getReadQueueNums(), channel.remoteAddress());
    log.warn(errorInfo);
    response.setCode(ResponseCode.SYSTEM_ERROR);
    response.setRemark(errorInfo);
    return response;
}

如果需要根据子表达式过滤消息,Broker还要根据表达式构建SubscriptionData对象,如果是通过SQL92语法过滤则更加复杂,还要构建ConsumerFilterData对象,SQL92过滤效率较低,RocketMQ引入了布隆过滤器来加快效率。

SubscriptionData subscriptionData = null;
ConsumerFilterData consumerFilterData = null;
if (hasSubscriptionFlag) {// 如果需要根据子表达式过滤消息,构建SubscriptionData对象
    try {
        subscriptionData = FilterAPI.build(
            requestHeader.getTopic(), requestHeader.getSubscription(), requestHeader.getExpressionType()
        );
        if (!ExpressionType.isTagType(subscriptionData.getExpressionType())) {
            // SQL92的方式
            consumerFilterData = ConsumerFilterManager.build(
                requestHeader.getTopic(), requestHeader.getConsumerGroup(), requestHeader.getSubscription(),
                requestHeader.getExpressionType(), requestHeader.getSubVersion()
            );
            assert consumerFilterData != null;
        }
    } catch (Exception e) {
        log.warn("Parse the consumer's subscription[{}] failed, group: {}", requestHeader.getSubscription(),
                 requestHeader.getConsumerGroup());
        response.setCode(ResponseCode.SUBSCRIPTION_PARSE_FAILED);
        response.setRemark("parse the consumer's subscription failed");
        return response;
    }
}

2.知道了Consumer的消息过滤规则,就可以创建MessageFilter了。MessageFilter只是接口,方法定义如下:

public interface MessageFilter {
    /**
     * 通过ConsumerQueue文件匹配
     * 1.Tag过滤时,TagsHash快速判断
     */
    boolean isMatchedByConsumeQueue(final Long tagsCode,
        final ConsumeQueueExt.CqExtUnit cqExtUnit);

    /**
     * 通过CommitLog文件匹配
     * 1.SQL92过滤时,读取消息属性进行过滤
     */
    boolean isMatchedByCommitLog(final ByteBuffer msgBuffer,
        final Map<String, String> properties);
}

如果仅使用Tag过滤,方法一即可满足,效率最高,通过ConsumeQueue文件中的索引就可快速判断,不用再读取CommitLog文件。如果使用SQL92语法过滤,因为支持对属性进行过滤,而消息属性是存储在CommitLog文件里的,因此不得不再次读取CommitLog文件才能进行判断,效率较低。

Broker会根据filterSupportRetry属性创建不同的实现类,ExpressionForRetryMessageFilter支持重试队列里的消息过滤,仅此而已。

MessageFilter messageFilter;
if (this.brokerController.getBrokerConfig().isFilterSupportRetry()) {
    messageFilter = new ExpressionForRetryMessageFilter(subscriptionData, consumerFilterData,
                                                        this.brokerController.getConsumerFilterManager());
} else {
    messageFilter = new ExpressionMessageFilter(subscriptionData, consumerFilterData,
                                                this.brokerController.getConsumerFilterManager());
}

3.一切准备就绪,通过MessageStore开始检索消息,主要流程如下:

  1. 定位到ConsumeQueue
  2. 校验拉取的Offset
  3. 根据Offset定位ByteBuffer
  4. MessageFilter过滤(TagsHash)
  5. CommitLog读取消息
  6. MessageFilter过滤(SQL92)
  7. 返回消息列表

一个Topic下可以有多个队列,每个队列对应一个ConsumeQueue,根据Topic和queueId定位ConsumeQueue,然后获取最小和最大的逻辑位点。

ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId);
// ConsumeQueue文件的最小和最大逻辑位点
minOffset = consumeQueue.getMinOffsetInQueue();
maxOffset = consumeQueue.getMaxOffsetInQueue();

先对Consumer拉取的位点进行校验,位点不能越界,消费队列为空就代表没有消息。

if (maxOffset == 0) {
    // 该队列没有消息
    status = GetMessageStatus.NO_MESSAGE_IN_QUEUE;
    nextBeginOffset = nextOffsetCorrection(offset, 0);
} else if (offset < minOffset) {
    // 拉取的位点太小
    status = GetMessageStatus.OFFSET_TOO_SMALL;
    nextBeginOffset = nextOffsetCorrection(offset, minOffset);
} else if (offset == maxOffset) {
    // 拉取的位点太大
    status = GetMessageStatus.OFFSET_OVERFLOW_ONE;
    nextBeginOffset = nextOffsetCorrection(offset, offset);
} else if (offset > maxOffset) {
    status = GetMessageStatus.OFFSET_OVERFLOW_BADLY;
    if (0 == minOffset) {
        nextBeginOffset = nextOffsetCorrection(offset, minOffset);
    } else {
        nextBeginOffset = nextOffsetCorrection(offset, maxOffset);
    }
}

拉取位点校验通过后,就可以根据拉取的逻辑位点定位到物理偏移量,因为单个索引项是20字节嘛,计算出物理偏移量后,截取对应的映射文件缓冲区。

public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
    int mappedFileSize = this.mappedFileSize;
    // 计算物理偏移量
    long offset = startIndex * CQ_STORE_UNIT_SIZE;
    if (offset >= this.getMinLogicOffset()) {
        // 根据物理偏移量定位具体的文件(文件名是起始偏移量)
        MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
        if (mappedFile != null) {
            SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
            return result;
        }
    }
    return null;
}

消息检索会导致CommitLog文件随机读,随机读的效率是很低的,如果一直没匹配到需要的消息,会导致大量的随机读,所以Broker会限制单次过滤的最大消息数,最大是800。

final int maxFilterMessageCount = Math.max(16000, maxMsgNums * ConsumeQueue.CQ_STORE_UNIT_SIZE);

接下来就是循环读取ConsumeQueue索引项了,提取消息偏移量Offset、长度size、TagsHash。

// 消息在CommitLog中的偏移量
long offsetPy = bufferConsumeQueue.getByteBuffer().getLong();
// 消息大小
int sizePy = bufferConsumeQueue.getByteBuffer().getInt();
// 消息Tag哈希
long tagsCode = bufferConsumeQueue.getByteBuffer().getLong();

先根据TagsHash快速匹配消息是否需要投递给Consumer,匹配的方式也很简单,就是判断tagsCode是否存在SubscriptionData的codeSet集合内。

if (messageFilter != null
    && !messageFilter.isMatchedByConsumeQueue(isTagsCodeLegal ? tagsCode : null, extRet ? cqExtUnit : null)) {
    if (getResult.getBufferTotalSize() == 0) {
        status = GetMessageStatus.NO_MATCHED_MESSAGE;
    }
    continue;
}

Tag校验通过,就可以根据偏移量去读取CommitLog文件里的消息了。

SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
if (null == selectResult) {
    if (getResult.getBufferTotalSize() == 0) {
        status = GetMessageStatus.MESSAGE_WAS_REMOVING;
    }

    nextPhyFileStartOffset = this.commitLog.rollNextFile(offsetPy);
    continue;
}

如果使用SQL92语法过滤,会再进行一次isMatchedByCommitLog匹配,因为消息属性存储在CommitLog文件里,必须在读取消息后进行。

if (messageFilter != null
    && !messageFilter.isMatchedByCommitLog(selectResult.getByteBuffer().slice(), null)) {
    if (getResult.getBufferTotalSize() == 0) {
        status = GetMessageStatus.NO_MATCHED_MESSAGE;
    }
    // release...
    selectResult.release();
    continue;
}

所有的消息过滤都通过后,就会将结果追加到GetMessageResult,然后返回。 ​

这里有一点需要注意,Broker并不会将CommitLog里存储的消息构建为Message对象再返回给Consumer,返回的仅仅是ByteBuffer字节序列,Consumer接受到ByteBuffer后再按照CommitLog存储格式解析成Message对象。

4.从MessageStore获取到消息结果后,流程基本就结束了,接下来对Response做一些设置,就可以响应结果了。Broker会在Response里带上nextBeginOffset,告诉Consumer下次拉取的位点。

responseHeader.setNextBeginOffset(getMessageResult.getNextBeginOffset());
responseHeader.setMinOffset(getMessageResult.getMinOffset());
responseHeader.setMaxOffset(getMessageResult.getMaxOffset());

在响应结果前,会对Consumer上报的消费位点进行存储:

boolean storeOffsetEnable = brokerAllowSuspend;
storeOffsetEnable = storeOffsetEnable && hasCommitOffsetFlag;
storeOffsetEnable = storeOffsetEnable
            && this.brokerController.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE;
if (storeOffsetEnable) {
    this.brokerController.getConsumerOffsetManager().commitOffset(RemotingHelper.parseChannelRemoteAddr(channel),
                                                                  requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId(), requestHeader.getCommitOffset());
}

ConsumerOffsetManager将消费位点先暂存到Map,然后5秒进行一次持久化,如果持久化之前Broker宕机了怎么办呢?消费进度岂不是丢失了?丢失就丢失了,消息重新投递,Consumer做好消费幂等就好了,RocketMQ本身的设计就是允许消息重复消费的。 ​

PullMessageProcessor方法执行完成,返回的Response会由Netty服务端写回给Consumer,Consumer获取到消息拉取结果后,开始通知消费线程开始消费,至此流程结束。 ​

4. 总结

Broker在接收到Consumer的消息拉取请求后,先对自身和对应Topic做权限校验,确保有读取权限,然后根据拉取的Topic和queueId定位到ConsumeQueue文件,根据拉取位点计算物理偏移量,根据偏移量从具体的ConsumeQueue文件中截取对应的映射文件缓冲区,循环读取索引项,先根据TagsHash进行快速过滤,然后根据Offset去CommitLog文件读取消息,再根据SQL92语法进行过滤,只有被MessageFilter成功匹配的数据才会返回给客户端。在返回消息给客户端之前,Broker还会对Consumer上报的消费位点进行存储。