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+链表,只不过通过一个连续的字节数组来存储。

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

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

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

相关推荐
阿伟*rui2 小时前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj4 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck4 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei4 小时前
java的类加载机制的学习
java·学习
码农小旋风5 小时前
详解K8S--声明式API
后端
Peter_chq5 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml46 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~6 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616886 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7896 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot