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.消息拉取请求的处理过程大致如下:
- 解析请求头,创建响应
- 校验Broker、Topic读权限
- 解析订阅关系,创建MessageFilter
- 从MessageStore获取消息结果
- 保存Consumer消费位点
- 返回结果
首先是进行校验,确保拉取的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开始检索消息,主要流程如下:
- 定位到ConsumeQueue
- 校验拉取的Offset
- 根据Offset定位ByteBuffer
- MessageFilter过滤(TagsHash)
- CommitLog读取消息
- MessageFilter过滤(SQL92)
- 返回消息列表
一个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上报的消费位点进行存储。