RocketMQ4源码(二)普通消息发送

前言

本章基于rocketmq4.6.0分析普通消息的发送流程,仅涉及producer和broker两个角色。

主要包括:

  1. producer侧相关后台任务
  2. producer侧发送消息
  3. broker侧自动创建topic
  4. broker侧写消息
  5. broker侧构建consumequeue和index

其他特性及HA后续再看。

Producer

继承关系、构造、属性

MQAdmin:基础的管理功能,忽略。

MQProducer:提供给客户端的api,下面列举了常用的方法。

java 复制代码
public interface MQProducer extends MQAdmin {
    // 启停
    void start() throws MQClientException;
    void shutdown();
	// 获取topic下所有queue
    List<MessageQueue> fetchPublishMessageQueues(final String topic) throws MQClientException;
    // 发送普通消息
    SendResult send(final Message msg) throws MQClientException, RemotingException, MQBrokerException,
    InterruptedException;
    // 指定queue发送消息
    SendResult send(final Message msg, final MessageQueue mq) throws MQClientException,
        RemotingException, MQBrokerException, InterruptedException;
	// 发送oneway消息
    void sendOneway(final Message msg) throws MQClientException, RemotingException,
    InterruptedException;
    // 选择MessageQueue发送消息,顺序消息
    SendResult send(final Message msg, final MessageQueueSelector selector, final Object arg)
    throws MQClientException, RemotingException, MQBrokerException, InterruptedException;
    // 发送事务消息
    TransactionSendResult sendMessageInTransaction(final Message msg,
                                                   final Object arg) throws MQClientException;
	// 批量发送消息
    SendResult send(final Collection<Message> msgs) throws MQClientException, RemotingException, MQBrokerException,
    InterruptedException;
    // rpc调用消息
    Message request(final Message msg, final long timeout) throws RequestTimeoutException, MQClientException,
    RemotingException, MQBrokerException, InterruptedException;
}

ClientConfig:客户端配置,consumer和producer都继承这个类。

包含比如nameserver地址、客户端心跳间隔30s、从nameserver拉取路由间隔30s等等。

DefaultMQProducer:包含两类属性

  • 配置:生产者特有配置,如发送消息超时时间、发送消息重试次数等等;
  • 组件:DefaultMQProducerImpl是生产者的底层实现,TraceDispatcher消息轨迹组件;
java 复制代码
public class DefaultMQProducer extends ClientConfig implements MQProducer {
    // 生产者组,事务消息
    private String producerGroup;
    // 自动创建topic模板topic TBW102
    private String createTopicKey = MixAll.AUTO_CREATE_TOPIC_KEY_TOPIC;
    // 默认创建topic队列数量 4
    private volatile int defaultTopicQueueNums = 4;
    // 发送消息超时时间 3s
    private int sendMsgTimeout = 3000;
    // 消息体超过多少,开启压缩,4kb
    private int compressMsgBodyOverHowmuch = 1024 * 4;
    // 同步发送重试次数 2
    private int retryTimesWhenSendFailed = 2;
    // 异步发送重试次数 2
    private int retryTimesWhenSendAsyncFailed = 2;
    // 当发送消息失败,是否重试其他broker false
    private boolean retryAnotherBrokerWhenNotStoreOK = false;
    // 消息最大4M
    private int maxMessageSize = 1024 * 1024 * 4;
    // 底层实际生产者实现
    protected final transient DefaultMQProducerImpl defaultMQProducerImpl;
    // 消息轨迹组件
    private TraceDispatcher traceDispatcher = null;
}

DefaultMQProducer构造入参:

  • namespace:命名空间,默认空,逻辑隔离;
  • producerGroup:生产者组,事务消息需要;
  • rpcHook:远程调用钩子,目前只有acl权限实现;
  • enableMsgTrace:是否开启消息轨迹;
  • customizedTraceTopic:存储消息轨迹的topic;

对于普通消息发送来说,这些属性都不重要,唯一重要的是底层DefaultMQProducerImpl。

DefaultMQProducerImpl构造仅创建了一个线程池,用于异步发送消息。

启动

DefaultMQProducer#start:启动所有组件,主要看DefaultMQProducerImpl。

DefaultMQProducerImpl#start(true):重点在于第一步,后两步省略

  1. 开启底层通讯客户端;
  2. 向所有broker发送一次心跳;
  3. 开启定时任务,每1秒扫描超时请求(RequestFutureTable存储rpc的id和future);

DefaultMQProducerImpl#start(true):构建MQClientInstance并启动。

构建MQClientInstance

MQClientManager#getOrCreateMQClientInstance:

对于一个clientId存在一个MQClientInstance实例

ClientConfig#buildMQClientId :默认instanceName=pid,对于用户而言,一个进程中只有一个MQClientInstance实例

producer和consumer底层通讯nettyclient都是同一个

MQClientInstance构造创建了很多组件,其中和producer相关的主要是MQClientAPIImpl。

MQClientAPIImpl实际维护底层通讯客户端NettyRemotingClient

启动MQClientInstance

MQClientInstance#start:启动多个后台线程执行不同任务,这里和producer相关的是startScheduledTask部分。

MQClientInstance#startScheduledTask

这里包含两个和生产者相关的任务,这两个任务也和消费者相关。

路由刷新

30s一次。

MQClientInstance#updateTopicRouteInfoFromNameServer():

收集consumer和producer需要用到的topic,循环每个topic从NameServer查询路由信息。

每个MQProducerInner会在运行阶段,把发送过的topic存储到topicPublishInfoTable,这样定时路由刷新能收集到这些topic,持续更新路由。

MQClientInstance#updateTopicRouteInfoFromNameServer:

  1. 根据topic获取TopicRouteData,上一章看过数据结构(这里省略获取模板topic=TBW102的路由数据,作用是为了自动创建topic时,根据模板topic决定队列数量);
  2. 存储brokerName->brokerAddrs的关系;
  3. 更新所有MQProducerInner的发布路由表,即topic->TopicPublishInfo

关键在于nameserver的TopicRouteData模型转换为TopicPublishInfo模型。

MQClientInstance#topicRouteData2TopicPublishInfo

上一章提到过,如果nameserver支持orderTopic,可以配死路由,这里优先取orderTopicConf,其实这段可以忽略。

一般都会走第二段逻辑,匹配有写权限且master broker存活的QueueData,将QueueData转换为MessageQueue 模型(后文简称MQ),放入TopicPublishInfo

MessageQueue模型如下,将QueueData中可写队列数量拆分为从0-n。

清理下线broker

30s一次。

根据路由,MQClientInstance会维护brokerName->brokerId->brokerAddr的映射关系,即brokerAddrTable。

MQClientInstance#cleanOfflineBroker:broker地址的映射关系,通过另外一个任务定期清理。

MQClientInstance#isBrokerAddrExistInTopicRouteTable:

根据所有topic的原始路由TopicRouteData,判断是否存在对应BrokerData,不存在将被移除。

发送心跳给broker

30s一次。

MQClientInstance#sendHeartbeatToAllBrokerWithLock:客户端发送心跳给所有broker。

MQClientInstance#sendHeartbeatToAllBroker:

  1. 准备心跳数据包HeartbeatData
  2. 扫描broker地址table;
  3. 跳过非master broker
  4. 发送心跳

MQClientInstance#prepareHeartbeatData

clientId一般是ip+pid,生产者数据只包含所有生产者组,所以生产者的心跳主要服务事务消息。

Message

对于生产者用户侧来说,一个消息对应一个Message。

普通消息只需要关注topic和body属性。

更多的扩展点在于properties这个扩展map。

比如延迟消息,key=DELAY,value=延迟级别。

比如broker存储相关,是否等待刷盘结束返回,key=WAIT,value=true/false,默认true。

比如Tag过滤,key=Tags。

比如设置业务key,key=KEYS,value=业务key。

可以通过业务key定位到消息id。

发送消息

DefaultMQProducerImpl#sendDefaultImpl是发送消息的核心方法,大致分为四步:

  1. 获取发布路由;
  2. 根据发布路由选择Queue;
  3. 发送;
  4. 结果处理;

获取发布路由

DefaultMQProducerImpl#tryToFindTopicPublishInfo:

根据topic查询TopicPublishInfo,如果发布路由还未获取到,手动从nameserver同步一次topic路由,和后台自动刷新逻辑一致。

选择MessageQueue

MQFaultStrategy#selectOneMessageQueue:

MQFaultStrategy针对生产者的容错策略。

目前只有一种容错措施,即LatencyFaultTolerance延迟容错。

默认sendLatencyFaultEnable=false,所以不会开启延迟容错,简单了解一下。

大致意思是,每次发送消息后,都记录发送消息的耗时,匹配耗时到latencyMax数组中的某一区间,再对应到notAvailableDuration对应下标代表的不可用时间。

每次发送消息前,选择MQ,都尽量避开选择这些由于延迟而标记为不可用的broker。

TopicPublishInfo#selectOneMessageQueue(String)

如果当前不在重试,走正常选择一个MQ;

如果当前正在重试,即lastBrokerName非空,绕过本次处理失败broker的所有MQ,根据一个topic纬度自增int(sendWhichQueue)选择下一个MQ,如果找不到,走正常选择一个MQ。

TopicPublishInfo#selectOneMessageQueue

正常选择一个MQ就是根据topic纬度自增int,每次轮询下一个MQ。

发送

DefaultMQProducerImpl#sendKernelImpl:

Step1,对Message对象做一些特殊处理,比如生成messageId、body压缩等。

DefaultMQProducerImpl#sendKernelImpl:

Step2,构造业务请求头SendMessageRequestHeader。

注意这里的defaultTopic(TBW102)和defaultTopicQueueNums(4),之后在broker侧用于自动创建topic。

DefaultMQProducerImpl#sendKernelImpl:

Step3,调用通讯层发送消息

MQClientAPIImpl#sendMessage:

封装RemotingCommand,将Message.body作为通讯请求体,注意请求编码为SEND_MESSAGE_V2(310)。

结果处理

MQClientAPIImpl#processSendResponse:

通讯层对于broker返回的响应码只会处理四种,其他都属于Exception。

这四种ResponseCode,都一一映射到SendStatus。

SendResult里包含一些broker返回的数据:

  • uniqMsgId:生产者msgId;
  • responseHeader#msgId:broker生成的msgId;
  • responseHeader#queueOffset:broker-topic-queue纬度的逻辑offset,从0开始自增;

DefaultMQProducerImpl#sendDefaultImpl:

业务侧这四种都归类为NotStoreOk,默认是不支持重试的,直接返回用户SendResult。

DefaultMQProducerImpl#sendDefaultImpl:

业务侧对于异常的处理如下,continue代表可以重试;

对于通讯异常、客户端异常支持重试;

对于broker正常返回response,而不属于NotStoreOk的,有特殊处理,部分支持重试,部分不支持。

Broker

启动

先来梳理一下Broker启动的大致流程。

BrokerStartup,先创建BrokerController,然后启动BrokerController。

BrokerStartup#createBrokerController:创建BrokerController分四步。

  • 读配置
  • 构建BrokerController,内部都是new对象
  • BrokerController初始化
  • 注册ShutdownHook

BrokerController#initialize:初始化阶段,从磁盘加载数据到内存,构建通讯server、开启后台线程。

BrokerController#start:启动阶段,执行各组件的start方法,开始向nameserver发送心跳,可以对外提供服务。

通讯层

BrokerController#registerProcessor

SEND_MESSAGE_V2由SendMessageProcessor处理。

BrokerController#initialize:

处理生产消息的业务线程池,默认核心和最大线程数都是1,队列大小为1万。

同步刷盘sendMessageThreadPoolNums官方建议调优。

异步刷盘建议使用自旋锁,同步刷盘建议使用重入锁,调整Broker配置项useReentrantLockWhenPutMessage,默认为false;异步刷盘建议开启TransientStorePoolEnable;建议关闭transferMsgByHeap,提高拉消息效率;同步刷盘建议适当增大sendMessageThreadPoolNums,具体配置需要经过压测。

新版本,sendMessageThreadPoolNums默认改为min(核数,4)了,基本就是4。

SendMessageProcessor#sendMessage

Step1,msgCheck,对消息做一些校验,核心逻辑是自动创建topic。

Step2,broker侧将Message转换为MessageExtBrokerInner。

Step3,调用MessageStore存储消息,返回PutMessageResult。

Step4,根据PutMessageResult,响应客户端。

自动创建Topic

TopicConfigManager

Broker侧由TopicConfigManager管理所有topic配置,即TopicConfig。

运行时,所有Topic配置表现为TopicConfigManager.topicConfigTable。

模板topic=TBW102在TopicConfigManager构建时放入topicConfigTable。

BrokerController#initialize阶段,首先会装载topic配置。

TopicConfigManager#decode:将~/store/config/topics.json装载到TopicConfigManager内存。

json 复制代码
{
	"dataVersion":{
		"counter":17,
		"stateVersion":0,
		"timestamp":1686643918215
	},
	"topicConfigTable":{
		"TopicTest1":{
			"attributes":{},
			"order":false,
			"perm":6,
			"readQueueNums":4,
			"topicFilterType":"SINGLE_TAG",
			"topicName":"TopicTest1",
			"topicSysFlag":0,
			"writeQueueNums":4
		},
		"TBW102":{
			"attributes":{},
			"order":false,
			"perm":6,
			"readQueueNums":8,
			"topicFilterType":"SINGLE_TAG",
			"topicName":"TBW102",
			"topicSysFlag":0,
			"writeQueueNums":8
		}
	}
}

TopicConfig

TopicConfig代表每个topic在当前broker的配置,包含读写队列数量、权限等。

自动创建Topic

AbstractSendMessageProcessor#msgCheck:

消息处理前,除了一些校验之外,如果TopicConfigManager中没有topic对应TopicConfig,走自动创建topic逻辑。

TopicConfigManager#createTopicInSendMessageMethod:

入参topic是需要自动创建的topic,defaultTopic是客户端传入模板topic(TBW102),clientDefaultTopicQueueNums是客户端传入队列数量(4)。

Step1,构建TopicConfig。

最终TopicConfig的队列数量取min(broker默认队列数量,client默认队列数量),默认为4。

Step2,Topic配置写内存+写磁盘。

Step3,向所有nameserver立即发送一次注册请求,目的是刷新topic路由。

消息存储基础

DefaultMessageStore

DefaultMessageStore是broker的默认MessageStore实现,管理消息存储组件。

MappedFile(通用服务)

MappedFile,代表一个文件通过mmap映射到内存。

使用mmap内存映射能够避免内核空间到用户空间的数据拷贝,但是要求文件定长。

MappedFile#init(fileName fileSize):MappedFile构造时将磁盘文件mmap映射到内存buffer。

MappedFileQueue(通用服务)

MappedFileQueue管理多个通过mmap内存映射的文件MappedFile。

比如commitlog,在磁盘上表现为commitlog目录下的多个文件。

MappedFileQueue#load:

在broker启动时,MappedFile往往会通过MappedFileQueue,从磁盘mmap映射到内存。

比如commitlog加载commitlog路径下的所有1G文件映射到内存,按照文件名排序。

写消息

DefaultMessageStore#putMessage:写消息,调用底层CommitLog。

CommitLog(主流程)

众所周知,rocketmq所有消息,不区分topic和queue,都会写入逻辑上的一个commitlog文件。

CommitLog#putMessage:

  • 获取最后一个MappedFile
  • 全局锁(putMessageLock),写消息到MappedFile
  • 刷盘
  • HA(本章忽略)

Step1 找最后一个MappedFile

对于CommitLog来说,每个文件1G,第一个文件是00000000000000000000,第二个文件是00000000001073741824,依此类推。

MappedFileQueue#getLastMappedFile():只需要从MappedFile列表中取最后一个返回即可。

Step2 写内存(pagecache)

注意,这一步由CommitLog.putMessageLock保护,同一时间只有一个线程能写内存。

MappedFile#appendMessagesInner:组装数据调用上层传入的AppendMessageCallback。

AppendMessageCallback只有commitlog实现,即CommitLog.DefaultAppendMessageCallback

CommitLog在每个broker单例,每个CommitLog也只有一个DefaultAppendMessageCallback。

DefaultAppendMessageCallback主要存储一些中间buffer。

写commitlog是加锁(自旋或reentrant)单线程写,所以buffer都可以复用。

CommitLog.DefaultAppendMessageCallback#doAppend:消息写入底层MappedByteBuffer。

入参:

  • fileFromOffset:文件起始offset,其实就是文件名;
  • byteBuffer:mmap底层MappedByteBuffer;
  • maxBlank:文件剩余容量;
  • msgInner:消息;

出参:AppendMessageResult。

本质是将MessageExtBrokerInner写入bytebuffer(写pagecache),关注AppendMessageResult关键字段。

wroteOffset,物理offset,代表这条消息相对于当前broker的所有commitlog文件中的写入位置。

wroteOffset=当前文件起始offset(文件名)+当前文件已经写入的字节数(MappedFile#wrotePosition)。

msgId,由客户端ip+port和物理offset两部分组成,没有记录在commitlog中,仅作为出参返回。

这个msgId对于单broker节点来说是唯一的,因为物理offset在单个broker唯一。

logicsOffset(queueOffset) ,逻辑offset,消息在broker-topic-queue纬度的偏移量,从0开始,每次处理完后增加1。

消息大小+8字节,超出mmap文件剩余容量,处理逻辑如下:

  1. 4个字节写入消息大小为剩余总容量
  2. 4个字节写入BLANK_MAGIC_CODE魔数,标记这条消息是空
  3. 返回END_OF_FILE

读commitlog时,通过第一个4字节能判断这条消息的长度,第二个4字节能判断当前这条消息是空消息,还是正常消息。

CommitLog#putMessage:如果写MappedFile内存返回END_OF_FILE,创建一个新的MappedFile继续写入。

创建MappedFile步骤忽略,文件名=上一个文件名+1G,由AllocateMappedFileService实际操作。

Step3 刷盘

CommitLog#handleDiskFlush:根据不同的策略,执行刷盘。

case1:broker同步刷盘,且消息设置WAIT=true,这是默认情况,提交GroupCommitRequest到GroupCommitService,等待刷盘结束,超时时间5s;request的入参是下一个写入的物理offset;

case2:broker同步刷盘,但消息设置WAIT=false,支持客户端针对消息级别设置为异步刷盘,唤醒GroupCommitService执行刷盘;

case3:broker异步刷盘,唤醒GroupCommitService执行刷盘;

rocketmq里有很多ServiceThread都使用了多线程生产单线程消费的生产消费模型,GroupCommitService也一样。

GroupCommitService中有两个队列。

putRequest方法:requestsWrite支持多线程写入刷盘请求;

swapRequests方法:当GroupCommitService被唤醒后,会执行swapRequests交换两个队列,单线程处理requestsRead中的刷盘请求;

CommitLog.GroupCommitService#doCommit

同步刷盘和异步刷盘,最终都会调用MappedFileQueue#flush。

之所以叫GroupCommit组提交,是因为实际刷盘offset可能会超出请求刷盘offset,支持对于多个offset一并刷盘。

由于写pagecache线程(sendMessageThreadPoolNums)和GroupCommitService刷盘线程是两个线程,所以GroupCommitService可能实际在跑的时候已经有多个消息被写入pagecache了,可以一起刷盘。

注意,写内存pagecache是被CommitLog.putMessageLock锁的,同一时间只有一个线程在写pagecache,但是提交刷盘请求并不在锁范围内。

MappedFileQueue#flush:

MappedFileQueue根据上次刷盘的全局commitlog物理offset(flushedWhere),找到单个MappedFile,执行MappedFile#flush,更新flushedWhere。

MappedFile#flush:处理单个文件的刷盘。

如果写内存的进度(wrotePosition)大于刷盘的进度(flushedPosition),调用force api执行刷盘,并更新刷盘进度。

二级索引

Reput

消息存储完成后(不考虑HA),直接就能响应producer客户端了,但是发送消息还有一些其他任务需要处理。

ReputMessageService是一个后台运行的线程,当commitlog的物理offset(pagecache)发生变化,这里会做一些事情。

DefaultMessageStore.ReputMessageService#doReput:

当reputFromOffset(ReputMessageService对于物理offset的处理进度)小于已经写入pagecache的最大物理offset,持续处理这些新写入的消息。

  1. 查询新写入的buffer,即最大物理offset - reputFromOffset这段buffer;
  2. 构建DispatchRequest;
  3. 分发DispatchRequest;
  4. MessageArrivingListener通知消息到达,消费相关忽略;

找新写入的buffer

CommitLog#getData:根据物理offset定位MappedFile,最后定位到具体的buffer。

MappedFile#selectMappedBuffer(int):buffer中有三个参数,物理offset、buffer大小、buffer。

构建DispatchRequest

CommitLog#checkMessageAndReturnSize:

首先呼应写pagecache那段逻辑,这里如果读到魔数是BLANK_MAGIC_CODE,代表当前buffer所属MappedFile已经处理完了。

在ReputMessageService#doReput中会判断size=0,更新reputFromOffset,去找下一段buffer。

接下来就是从buffer中读取一条完整的消息,构建成DispatchRequest。除了tagsCode是特殊处理的(取tag.hashCode),其他都是原来commitlog中的。

分发DispatchRequest

DefaultMessageStore#doDispatch:将DispatchRequest分发到所有CommitLogDispatcher处理。

DefaultMessageStore构造时加入了两个CommitLogDispatcher。

忽略BrokerController加入的CommitLogDispatcherCalcBitMap,和消费者SQL92过滤相关。

构建ConsumeQueue

众所周知,消息写入commitlog对consumer并不可见,需要写到ConsumeQueue才能被consumer感知。

CommitLogDispatcherBuildConsumeQueue构建ConsumeQueue。

物理存储上,ConsumeQueue由consumequeue/{topic}/{queueId}下的多个MappedFile组成,还是用了MappedFileQueue来管理这多个MappedFile。

每个MappedFile默认存储30w条消息,每条20byte,共600wbyte。

DefaultMessageStore#putMessagePositionInfo:

  1. 获取或创建ConsumeQueue
  2. ConsumeQueue处理DispatchRequest

DefaultMessageStore#findConsumeQueue:

查询ConsumeQueue是否存在,如果不存在放入内存table,纬度是topic+queueId。

ConsumeQueue#putMessagePositionInfo:构建ConsumeQueue的核心方法。

第一,ConsumeQueue针对每条消息,用20个字节存储。

8字节物理offset,4字节消息长度,8字节tag的hashCode。

根据前8个字节能定位到CommitLog中的某个MappedFile的某段buffer,4字节就能取出一条消息。

第二,这里MappedFile#appendMessage仅仅写入pagecache,并未flush。

DefaultMessageStore.FlushConsumeQueueService:

后台线程默认每1秒扫描所有ConsumeQueue,执行刷盘。

具体流程省略,调用MappedFile里的FileChannel.force。

构建IndexFile

rocketmq支持根据messageId、业务key查询消息。

这些查询通过一组index文件实现。

CommitLogDispatcherBuildIndex构建index索引文件。

IndexFile

每个IndexFile也是个定长MappedFile,包含40字节头+500w个slot4字节+2kw个索引20字节。

IndexHeader

每个IndexFile开始有40个字节的IndexHeader,代表这段索引的元数据:

  • begin/endTimestamp:这段索引对应消息存储的开始时间/结束时间
  • begin/endPhyOffset:这段索引对应消息的物理offset开始/结束
  • hashSlotCount:暂时没什么用
  • indexCount:索引数量

IndexService

IndexService管理所有IndexFile。

这里不用MappedFileQueue是因为IndexFile的文件名不是物理offset,而是时间戳。

构建索引

IndexService#buildIndex:获取IndexFile、写messageId索引、写业务key索引。

IndexService#getAndCreateLastIndexFile:

由于IndexFile是按照时间戳命名排序的,所以最后一个IndexFile就是时间最晚的一个。

如果不存在IndexFile,用当前时间作为文件名创建一个IndexFile。

IndexService#putKey:控制IndexFile的滚动,当IndexFile#putKey写满,创建新IndexFile继续写入。

IndexFile分为三段:header、slot、index。

IndexFile#putKey:写索引的核心逻辑。

第一步,根据key(messageId、业务key)的hashCode模slot数量(500w),得到一个下标absSlotPos。

第二步,从这个absSlotPos上读取4字节的slotValue,后面看是啥。

第三步,计算这条消息,和这个index文件中最早的消息的时间差timeDiff。

第四步,找到index写入位置=header长度+slot总长度+索引数量*20索引长度,写入index结构包含20字节:

  • 4字节key.hashCode;
  • 8字节物理offset,用于定位commitlog中的一条消息;
  • 4字节timeDiff,和当前index文件最早的消息的时间差;
  • 4字节slotValue;

第五步,更新absSlotPos上的slotValue为当前索引总数

所以slotValue代表上一个落在同一个slot的索引在当前index文件中的索引数量计数

用链表法解决了哈希冲突。

第六步,更新IndexFile的IndexHeader元数据,主要索引数量要+1。

总得来说,IndexFile的存储结构还是hashtable+链表,只不过通过一个连续的字节数组来存储。

比如先收到消息A,key为hello,后收到消息B,key为world。

对于消息A,hello.hashCode%500w定位到一个slotX,slotX当前是0,代表还没有产生哈希冲突。

消息A是这个indexFile中的第222个索引,消息A的索引位置=20+500w4+22220,写入20字节索引数据,索引A的slotValue是0,更新当前slotX为222。

对于消息B,world.hashCode%500w定位到同一个slot,此时slotX是222,其实对应消息A的位置。

消息B是这个indexFile中的第666个索引,消息A的索引位置=20+500w4+66620,写入20字节索引数据,索引B的slotValue是222,更新当前slotX为666。

假设查询IndexFile中key=hello的索引:

  1. hello.hashCode%500w,定位到slot
  2. 通过20+500w4+66620,定位到world的index
  3. world.hash不等于hello.hash,根据world的slotValue继续查
  4. 通过20+500w4+22220,定位到hello的index

IndexService#queryOffset:

但是实际查询时,并没有那么理想,因为IndexFile是通过时间戳命名的,在没有提供时间范围查询条件的情况下,需要从最新的IndexFile开始往前遍历。

总结

Producer

每个客户端有一个MQClientInstance,持有通讯客户端NettyRemotingClient。

对于producer而言有三个相对重要的后台任务,每30s执行一次:

  1. 路由刷新:从NameServer获取TopicRouteData路由数据,存储brokerName->brokerAddrs的关系,更新所有MQProducerInner的发布路由表,即topic->TopicPublishInfo;
  2. broker清理:扫描brokerName->brokerAddrs的关系,清理路由表中不存在的broker;
  3. 心跳:发送HeartbeatData给所有broker,其中包含所有producer的group,服务于事务消息;

对于producer,每条消息包含topic、properties、body三部分。

其中properties包含众多特性,比如延迟消息、刷盘策略、TAG、KEY、messageId等。

producer发送消息经过四个步骤:

  1. 获取发布topic的路由TopicPublishInfo,如果不存在,从nameserver同步一次;
  2. 选择MessageQueue,同一个topic下采用轮询的方式选择,支持延迟容错(默认未开启);
  3. 发送消息给broker;
  4. 处理响应结果,默认刷盘超时、HA相关超时不会重试,通讯异常、客户端异常支持重试;

Broker

自动创建Topic

topic在broker侧由TopicConfigManager管理,每个topic对应一个TopicConfig。

进入消息处理流程前,需要确保topic存在,默认broker支持自动创建topic。

消息存储

DefaultMessageStore,broker的默认MessageStore实现,管理消息存储组件。

MappedFile,代表一个文件通过mmap映射到内存。

MappedFileQueue,管理多个通过mmap内存映射的文件MappedFile。

写消息

对于一个broker,所有消息都会写入逻辑上的一个commitlog,在物理上体现为commitlog目录下的N个1G的文件。每个文件名为当前文件的第一条消息的物理offset。

写消息流程如下:

  1. 获取commitlog目录下最后一个文件MappedFile;
  2. 获取putMessageLock锁,单线程写MappedFile内存pagecache,释放putMessageLock锁;
  3. GroupCommitService将commitlog刷盘,同步刷盘情况下,主线程最多等待5s;异步刷盘,主线程直接返回;
  4. HA(本章忽略);

默认broker处理客户端发送消息SEND_MESSAGE_V2只有一个线程,同步刷盘的情况下可调优。

针对commitlog有两个offset需要区分:

  • logicsOffset/queueOffset:逻辑offset,消息在broker-topic-queue纬度的offset,从0开始,每个消息+1;
  • phyOffset/wroteOffset:物理offset,消息在broker的所有commitlog文件下的物理偏移量,与内存映射息息相关。可以认为是消息的一级索引,consumequeue和index通过物理offset+消息长度,从commitlog中定位到唯一一条消息;

二级索引

当消息写入commitlog后,ReputMessageService线程会分发这些新消息buffer。

主要用于构建consumequeue和index两个索引。

无论构建哪个索引,ReputMessageService线程仅仅会将数据写入pagecache。

Consumequeue

众所周知,消息写入commitlog对consumer并不可见,需要写到ConsumeQueue才能被consumer感知。

物理存储上,ConsumeQueue由consumequeue/{topic}/{queueId}下的多个文件组成,还是用了MappedFileQueue来管理多个MappedFile。每个文件名是consumequeue的物理offset。

每个MappedFile默认存储30w条consumequeue数据,每条20byte,共600wbyte。

consumequeue包含8字节物理offset、4字节消息长度、8字节tagHashCode。

FlushConsumeQueueService线程会将consumequeue刷盘。

Index

Index提供根据时间、消息key、消息id查询消息的能力。

物理存储上,每个IndexFile包含40字节头+500w个slot4字节+2kw个索引20字节,也是个定长MappedFile。

区别是,IndexFile的文件名并不是什么物理offset,而是时间戳。

每个IndexFile分为三段:header、slot、index。

header部分是这个IndexFile的元数据,比如这段索引对应消息存储的开始时间/结束时间,这段索引对应消息的物理offset开始/结束等等。

slot部分默认包含500个槽,每个槽存储4字节的int,代表上次发生哈希冲突的key的相对位置(这个key在IndexFile中的计数)。

index部分是实际的索引数据,每个索引包含20字节的结构。

总得来说,IndexFile的存储结构还是hashtable+链表,只不过通过一个连续的字节数组来存储。

欢迎大家评论或私信讨论问题。

本文原创,未经许可不得转载。

欢迎关注公众号【程序猿阿越】。

相关推荐
陈平安Java and C4 小时前
MyBatisPlus
java
秋野酱5 小时前
如何在 Spring Boot 中实现自定义属性
java·数据库·spring boot
安的列斯凯奇5 小时前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
Bunny02125 小时前
SpringMVC笔记
java·redis·笔记
架构文摘JGWZ6 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC6 小时前
Swift语言的网络编程
开发语言·后端·golang
feng_blog66886 小时前
【docker-1】快速入门docker
java·docker·eureka
邓熙榆6 小时前
Haskell语言的正则表达式
开发语言·后端·golang
东阳马生架构7 小时前
RocketMQ原理—3.源码设计简单分析下
rocketmq
枫叶落雨2227 小时前
04JavaWeb——Maven-SpringBootWeb入门
java·maven