基于4.9.0版本分析,https:///apache/rocketmq/tree/rocketmq-all-4.9.0

1. 缘起

  1. 阿里内部为了适应淘宝更快、更复杂的业务,在2001年启动了「五彩石项目」,第一代消息队列服务Notify在这个背景下应运而生。
  2. 2010年ActiveMQ仍然作为核心技术广泛应用于阿里内部各个业务线,与此同时,支持顺序消息、事务消息、海量消息堆积的消息服务也是阿里急需的,在这种背景下,2011年MetaQ诞生。
  3. 2011年Kafka开源,2012年阿里参考Kafka的设计,研发了一套通用的消息队列引擎,第一代RocketMQ诞生。
  4. 2016年,阿里云上线RocketMQ消息队列云服务。同年11月,阿里将RocketMQ捐献给Apache基金会,开始孵化。
  5. 2017年9月25日,RocketMQ顺利“毕业”,成为Apache顶级项目。

2. 特性

【为什么使用RocketMQ】

  1. 削峰填谷
  2. 服务解耦
  3. 异步处理
  4. 分布式事务最终一致性



3. 架构

springboot rocketmq 发送消息设置key和tag_RocketMQ

3.1 NameServer

名称服务,整个RocketMQ的“大脑”,两大职责:路由管理服务注册与发现
NameServer支持集群部署,但是节点之间不会有任何通信和数据同步,每个节点都是无状态的。Broker启动后,会向所有的NameServer注册自己的路由信息,因此每一个NameServer节点都会有一份完整的数据。当某个NameServer下线,客户端仍然可以动态感知到Broker的存在。
Broker会定时给NameServer发心跳,超过2分钟没有收到心跳认为Broker下线,剔除相关Topic路由数据。

3.2 Broker

消息中转站,负责消息的存储、投递、查询。支持集群部署,相同BrokerName自动组成集群,brokerId=0代表Master,大于0代表Slave,HAService负责主从数据同步。Producer发送消息到Master,Master存储消息,再同步给Slave,然后Consumer从Slave拉取消息并消费。

3.3 Producer

消息生产者,支持集群部署。发送消息时指定Topic,随机与一台NameServer建立长连接,从NameServer拉取Topic路由数据,轮询一个MessageQueue,与队列所在的Broker建立长连接,消息发送。

3.4 Consumer

消息消费者,支持集群部署。订阅感兴趣的Topic,支持Push、Pull两种方式获取消息,新的Consumer实例启动立即重平衡,给自己分配MessageQueue,然后开始拉取消息进行消费。

4. 网络通信协议

底层基于Netty框架实现网络通信,网络通信双方需要有协议约定,RocketMQ网络协议设计的足够简单,请求和响应的协议是一样的。

springboot rocketmq 发送消息设置key和tag_RocketMQ_02

  1. 4字节存储报文总长度。
  2. 1字节序列化方式+3字节Header长度。
  3. Header数据。
  4. 报文主体。


请求头一般不会太长,缩减至3字节,拿出1字节存储序列化方式。0代表JSON序列化,1代表RocketMQ自定义的序列化格式,详见RocketMQSerializable.rocketMQProtocolEncode()。1的方式理论上性能更好,也更节省带宽。

网络传输的总是字节序列,通信双方要进行编解码,对应的类是RemotingCommand。

Header字段

类型

Request说明

Response说明

code

int

请求操作码,应答方根据不同的请求码进行不同的业务处理

应答响应码。0表示成功,非0则表示各种错误

language

LanguageCode

请求方实现的语言

应答方实现的语言

version

int

请求方程序的版本

应答方程序的版本

opaque

int

相当于requestId,在同一个连接上的不同请求标识码,与响应消息中的相对应

应答不做修改直接返回

flag

int

区分是普通RPC还是onewayRPC的标志

区分是普通RPC还是onewayRPC的标志

remark

String

传输自定义文本信息

传输自定义文本信息

extFields

HashMap<String, String>

请求自定义扩展信息

响应自定义扩展信息

opaque:和Netty异步IO通信有关。业务上需要异步转同步,请求方创建ResponseFuture放入Map,阻塞等待结果响应。接收到服务端响应的数据再将opaque对应的ResponseFuture写入结果,请求方线程被唤醒。

Tips:客户端发请求,会创建各种自定义Header,可以理解为请求参数,RemotingCommand序列化时反射将customHeader对象属性名和属性值写入extFields中,然后忽略customHeader对象,序列化结果就是通信协议里的HeaderData。

5. NameServer启动

运行bin目录下的mqnamesrv脚本,它会执行/bin/脚本,启动NamesrvStartup类。

sh ${ROCKETMQ_HOME}/bin/ org.apache.rocketmq.namesrv.NamesrvStartup $@

NameServer比较重要的类:

  1. NamesrvStartup:服务启动类,帮助读取配置,创建Controller并启动服务。
  2. NamesrvController:核心类,负责服务的初始化、启动和停止。
  3. KVConfigManager:KV配置信息管理,支持持久化。
  4. RouteInfoManager:管理路由信息。
  5. NettyRemotingServer:Netty服务端,处理客户端请求。
  6. DefaultRequestProcessor:客户端请求处理器。

RouteInfoManager管理路由信息,HashMap存储,因为读多写少,遂采用读写锁保证线程安全。

/**
 * Topic分布在哪些Broker上?
 * 读写队列数多少?权限是什么?
 */
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
/**
 * Broker集群信息
 * 相同名称的brokerName自动组成集群,brokerId=0为Master,其余为Slave。
 * Broker服务的地址有哪些?
 */
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
/**
 * 业务集群信息
 * 集群下有哪些BrokerName?
 */
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
/**
 * Broker对应的活跃状态
 * Broker服务是否存活?上一次心跳的时间,长时间没收到心跳,剔除服务
 */
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
/**
 * Broker对应的消息过滤服务
 */
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;

Topic路由注册?
通过Broker给NameServer发心跳实现,心跳包里包含topicConfigTable。Broker启动会开启定时任务,默认30秒向所有NameServer发一次心跳。超过120秒没收到心跳,NameServer认为Broker下线,将其剔除。

NameServer何时剔除失效的Broker?

  1. Broker正常关闭,发送UNREGISTER_BROKER命令。
  2. 定时任务10秒扫描一次,距离上次心跳时间超过2分钟,主动剔除。
  3. BrokerHousekeepingService监听Channel事件:异常、关闭、2分钟Channel没有读写事件。


Topic路由发现?
路由发现是非实时的,Topic路由发生变更,NameServer不会主动推送给客户端,由客户端定时发送GET_ROUTEINFO_BY_TOPIC命令主动拉取。NameServer仅保存Topic读写队列数,客户端自己转换成Set<MessageQueue>,Producer取writeQueueNums,Consumer取readQueueNums通过调整读写队列数,平滑扩缩容

6. 消息发送流程

MQProducer是RocketMQ提供的生产者接口,默认实现为DefaultMQProducer,如果要发送事务消息用TransactionMQProducer。

DefaultMQProducer仅仅是RocketMQ暴露给用户使用的外观类,它内部持有一个生产者实现DefaultMQProducerImpl,它才是具体的执行者。使用这种结构的好处是,RocketMQ在后续版本可以任意更换实现类,对用户零感知。

三种通信模式:同步发送、异步发送、单向发送。

springboot rocketmq 发送消息设置key和tag_数据_03

发送消息对应的请求头是SendMessageRequestHeader:

public class SendMessageRequestHeader implements CommandCustomHeader {
    // 消息来自哪个生产者组?
    private String producerGroup;
    // 消息所属Topic
    private String topic;
    // 默认Topic
    private String defaultTopic;
    // 默认Queue数量
    private Integer defaultTopicQueueNums;
    // 消息要发送到MessageQueue的ID
    private Integer queueId;
    /**
     * 系统标记,按Bit位标识
     * 第1位 代表Body是否压缩
     */
    private Integer sysFlag;
    // 消息的创建时间
    private Long bornTimestamp;
    // 消息Flag
    private Integer flag;
    // 自定义属性,HashMap拼接成字符串
    private String properties;
    // 重复消费次数
    private Integer reconsumeTimes;
    private boolean unitMode = false;
    // 是否批量消息
    private boolean batch = false;
    // 最大重复消费次数
    private Integer maxReconsumeTimes;
}

消息主体压缩
为了节省带宽,RocketMQ会尝试对Body超过4KB的消息进行压缩传输,压缩算法是Zip。

Broker解压缩
Producer压缩了消息,Broker如何感知?Message有个sysFlag属性,每个Bit位有特殊含义,第1位代表消息是否被压缩,如果压缩,Broker在存储消息前会解压缩,否则消息不可读了。

TBW102主题有什么用?
RocketMQ默认Topic,消息发送的Topic路由从NameServer查询不存在时,会再查一次,第二次查询的Topic是TBW102,基于该Topic的属性创建新的Topic并缓存到本地,进行正常的消息发送。Broker收到消息后,会对消息做check,Topic不存在会基于TBW102属性创建,随后发心跳给NameServer,新的Topic路由数据就有了。
1.MQClientInstance#updateTopicRouteInfoFromNameServer()
2.TopicConfigManager#createTopicInSendMessageMethod()

消息队列如何负载?
是否启用Broker故障延迟机制?默认sendLatencyFaultEnable=false

  1. 禁用:轮询,但是会跳过上次发送失败的Broker。
  2. 启用:
  • Broker不可用时,计算不可用时长,创建FaultItem规避。
  • 轮询时规避不可用的Broker。
  1. 重写MessageQueueSelector

【总结】

  1. 查找TopicPublishInfo:tryToFindTopicPublishInfo()
  2. 选择一个分区队列:selectOneMessageQueue()
  3. 获取MessageQueue所在Broker的Master机器Channel。
  4. 构建Header、设置Body、发送请求。


7. 高性能存储

RocketMQ支持亿级别的消息堆积能力,还能保证性能不受太大影响。这种级别的数据量,不可能存储在内存。「磁盘IO效率慢」的概念深入人心,每次消息发送都写磁盘,岂不是性能极差?

7.1 三大利器

7.1.1 顺序写

磁盘如果利用的好,它的效率比你想象的要快得多。磁盘随机写的效率确实很差,约100KB每秒的写入速度,但是对于顺序写,在Page Cache的加持下,它的写入速度能达到600MB每秒,这已经超过了绝大多数网卡的读写速度了,所以只要能保证顺序写,磁盘IO并不是性能瓶颈。

RocketMQ存储消息,主要涉及到三大类文件,分别是:CommitLog、ConsumerQueue、Index。

  1. CommitLog存储Broker上所有的消息,不管你是哪个Topic下的,全部写到CommitLog文件,它是完全顺序写的。
  2. ConsumerQueue是RocketMQ用来加速消费者消费消息的索引文件,每个Topic是一个文件夹,下面再以QueueID分片存储,消息写入到CommitLog后,还要往对应的ConsumerQueue文件写入一个索引信息,它也是顺序写的。
  3. Index是RocketMQ用来实现消息查询的索引文件,有了它就可以通过Key和时间范围快速查询消息,同样的,消息写入到CommitLog后,也会往Index中写入索引数据,也是顺序写的。

7.1.2 内存映射与零拷贝

以前,我们从磁盘读写数据时,均需要经过至少两次数据拷贝。

读:磁盘 > 内核缓冲区 > JVM内存。

写:JVM内存 > 内核缓冲区 磁盘。

springboot rocketmq 发送消息设置key和tag_偏移量_04



而内存映射技术,不管是读还是写,均只需要一次数据拷贝。
读:磁盘 > 内核缓冲区。
写:内核缓冲区 > 磁盘。

用户空间直接拿应用程序的逻辑内存地址映射到Linux系统的内核缓冲区,这样应用程序看似读写的是自己的内存,其实读写的是内核缓冲区,数据不用在内核空间和用户空间来回拷贝了,不仅减少了内存复制的开销,还避免了因系统调用引起的软中断。

springboot rocketmq 发送消息设置key和tag_客户端_05

「零拷贝」是提升IO效率的终极利器,以前,如果我们需要把磁盘中的数据发送到网络,至少需要经过4次数据拷贝:磁盘 > 内核缓冲区 > JVM > Socket缓冲区 > 网卡。

springboot rocketmq 发送消息设置key和tag_客户端_06


利用内存映射,最多只需要三次数据拷贝,数据直接从内核缓冲区拷贝到Socket缓冲区就可以直接发送了。实际上,可能连内核缓冲区拷贝到Socket缓冲区的过程都没有了,内核缓冲区和Socket缓冲区也可以建立内存映射,这样就只剩下两次数据拷贝了。

springboot rocketmq 发送消息设置key和tag_RocketMQ_07


综上所述,零拷贝的核心是内存映射,内存映射技术在Linux系统上对应的是mmap系统函数,在Java中对应的是MappedByteBuffer类。

mmap函数有一个缺陷,对映射的文件大小有限制,所以CommitLog单个文件默认为1GB。

7.1.3 异步刷盘

同步刷盘:消息写入Page Cache后调用系统函数fsync将数据同步到磁盘才给客户端返回ACK响应,这种方式对数据的安全性很高,但是性能会有较大影响。

异步刷盘:充分利用Page Cache的优势,只要消息写入Page Cache就给客户端返回ACK响应,RocketMQ会在后台起一个线程异步刷盘,极大的提高了性能和吞吐量。

异步刷盘+缓冲区:提前申请一块直接内存用作缓冲区,并且锁住这块内存避免被交换到Swap分区。消息先写入直接内存缓冲区,然后定时持久化到磁盘。性能最好,但是最不可靠。内存层面读写分离,写往直接内存写、读从PageCache读,最大程度避免PageCache锁竞争,解决Broker响应延时出现毛刺。

springboot rocketmq 发送消息设置key和tag_客户端_08

7.2 消息仓库设计

7.2.1 CommitLog

CommitLog用来存储消息主体和其元数据,虽然RocketMQ是基于Topic主题订阅模式的,但是对于Broker而言,所有消息全部写入CommitLog,不关心Topic,因此CommitLog是完全顺序写的。RocketMQ使用mmap来提升磁盘IO效率,利用NIO的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中,减少了数据在内核空间和用户空间来回复制的开销。但是mmap有一定的限制,映射的文件不能太大,而RocketMQ又要支持海量的消息积压,怎么办呢?

为了解决上述问题,RocketMQ将CommitLog文件进行了切分,将一个大文件切分成多个小文件,每个文件定长为1GB,文件名就是文件的起始偏移量Offset,固定为20位,不足的用0补齐。例如,第一个文件名为00000000000000000000代表起始偏移量为0,第二个文件名为00000000001073741824,起始偏移量为1073741824,因为1GB=1024*1024*1024

当写入新的消息时,Broker会定位到最新的CommitLog文件,判断它是否可以容纳这条消息,如果文件写满了,会创建新的文件继续写。另外,消息被消费后,并不会立马删除,CommitLog的设计决定了RocketMQ不能针对单个消息进行删除,只能将过期的CommitLog文件删除。消息默认会保存3天,每天定时在一个时间点删除过期的文件。理论上,只要文件没有过期,你依然可以通过这些文件重复消费消息。

CommitLog文件结构非常的简单,没有头信息,只有Message,但是Message的长度是不固定的,消息被写入CommitLog的格式如下:

名称

长度

说明

size

4

消息总长度

magicCode

4

魔数,固定值=daa320a7

bodyCRC

4

消息体的CRC码,确保数据无损坏

queueId

4

一个Topic下有多个队列,存储的队列ID

flag

4

消息标记,扩展属性,MQ不做任何处理

queueOffset

8

消息在ConsumerQueue中的逻辑偏移量,queueOffset*20才是物理偏移量

physicalOffset

8

消息在CommitLog中的物理地址偏移量

sysFlag

4

系统标记,每个位不同含义,例如第1位代表是请求还是响应、是否事务消息、消息体是否压缩等

bornTime

8

消息产生时间戳

bornHost

8

生产消息的主机地址

storeTime

8

消息存储时间

storeHostAddress

8

消息存储主机地址

reconsumeTimes

4

消费重试次数

preparedTransactionOffset

8

事务消息偏移量

bodyLength

4

消息体长度

body

变长=bodyLength

消息体

topicLength

1

Topic长度

topic

变长=topicLength

Topic

propertiesLength

2

属性长度

properties

变长=propertiesLength

属性

7.2.2 ConsumerQueue

RocketMQ是基于Topic主题订阅模式的,消费者往往只对自己订阅的Topic感兴趣,如果每次消费都要去CommitLog中检索消息,效率是非常低的,于是有了ConsumerQueue文件。

ConsumerQueue是消息消费队列,用来加速消息消费的性能,Consumer可以根据ConsumerQueue来快速定位要消费的消息。ConsumerQueue是一个逻辑队列,它仅保存消息在CommitLog文件中的偏移量Offset、消息大小size和消息Tag的哈希值。每个索引条目为20字节,单个ConsumerQueue文件由30万个条目组成,因此ConsumerQueue文件也是定长的,约5.72M。

ConsumerQueue文件存储路径为$HOME/store/consumequeue/{topic}/{queueId}/{fileName},每个Topic是一个文件夹,同一个Topic下可以有多个队列,每个队列又是一个文件夹,最后才是ConsumerQueue文件。

名称

长度

说明

Offset

8

消息在CommitLog文件中的偏移量

size

4

消息长度

tagsHash

8

消息Tag哈希码

tagsHash目前取的是Tag字符串的哈希值,在Java中hashCode是int类型,占用4个字节,这里为啥要用8字节存储?
我的理解:1.不排除以后修改哈希计算方式。2.延迟消息,tagsHash存储的是消息交付时间戳,此时必须是8字节,也许是为了兼容。

7.2.3 Index

Index是索引文件,它的主要目的是通过Key和时间范围来快速检索消息。Index文件的存储路径$HOME/store/index/{timestamp},文件名以创建时的时间戳命名,对应的类为org.apache.rocketmq.store.index.IndexFile

Index文件也是定长的,单个文件约400M,单个Index文件可以保存2000万个索引,底层存储结构借鉴了HashMap,用的是哈希索引。当发生哈希碰撞,索引的最后4字节指针用来链接其他索引,故用的是一个哈希+链表的结构。哈希槽存放的永远是最新的索引,因为对于MQ而言,关心的永远是最新的消息。

单个Index文件的构成:

长度

说明

40

文件头信息

500万*4

哈希槽

other

索引数据

Index文件是有头信息的,对应的类为org.apache.rocketmq.store.index.IndexHeader,头信息的构成:

长度

说明

8

索引消息的开始时间戳

8

结束时间戳

8

索引消息在CommitLog起始偏移量

8

结束偏移量

4

哈希槽数量

4

文件中索引的数量

索引条目的构成:

长度

说明

4

哈希值

8

消息在CommitLog文件中的偏移量

4

存盘时间与开始时间戳的时间差(秒)

4

链接下一个索引的指针

7.3 流程分析

springboot rocketmq 发送消息设置key和tag_客户端_09

消息按照固定格式写入文件的代码在CommitLog.DefaultAppendMessageCallback.doAppend()

// 1 TOTALSIZE
this.msgStoreItemMemory.putInt(msgLen);
// 2 MAGICCODE
this.msgStoreItemMemory.putInt(CommitLog.MESSAGE_MAGIC_CODE);
// 3 BODYCRC
this.msgStoreItemMemory.putInt(msgInner.getBodyCRC());
// 4 QUEUEID
this.msgStoreItemMemory.putInt(msgInner.getQueueId());
// 5 FLAG
this.msgStoreItemMemory.putInt(msgInner.getFlag());
// 6 QUEUEOFFSET
this.msgStoreItemMemory.putLong(queueOffset);
System.err.println("queueOffset:" + queueOffset);
// 7 PHYSICALOFFSET
this.msgStoreItemMemory.putLong(fileFromOffset + byteBuffer.position());
// 8 SYSFLAG
this.msgStoreItemMemory.putInt(msgInner.getSysFlag());
// 9 BORNTIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getBornTimestamp());
// 10 BORNHOST
this.resetByteBuffer(bornHostHolder, bornHostLength);
this.msgStoreItemMemory.put(msgInner.getBornHostBytes(bornHostHolder));
// 11 STORETIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getStoreTimestamp());
// 12 STOREHOSTADDRESS
this.resetByteBuffer(storeHostHolder, storeHostLength);
this.msgStoreItemMemory.put(msgInner.getStoreHostBytes(storeHostHolder));
// 13 RECONSUMETIMES
this.msgStoreItemMemory.putInt(msgInner.getReconsumeTimes());
// 14 Prepared Transaction Offset
this.msgStoreItemMemory.putLong(msgInner.getPreparedTransactionOffset());
// 15 BODY
this.msgStoreItemMemory.putInt(bodyLength);
if (bodyLength > 0)
    this.msgStoreItemMemory.put(msgInner.getBody());
// 16 TOPIC
this.msgStoreItemMemory.put((byte) topicLength);
this.msgStoreItemMemory.put(topicData);
// 17 PROPERTIES
this.msgStoreItemMemory.putShort((short) propertiesLength);
if (propertiesLength > 0)
    this.msgStoreItemMemory.put(propertiesData);

final long beginTimeMills = CommitLog.this.defaultMessageStore.now();
// Write messages to the queue buffer
// 写入DM或Page Cache
byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen);

8. 构建ConsumeQueue

理论上来说,RocketMQ只要有CommitLog文件就可以正常运行了,那为何还要维护ConsumeQueue文件呢?

ConsumeQueue是消费队列,引入它的目的是为了提高消费者的消费速度。毕竟RocketMQ是基于Topic主题订阅模式的,消费者往往只关心自己订阅的消息,如果每次消费都从CommitLog文件中检索数据,无疑性能是非常差的。有了ConsumeQueue,消费者就可以根据消息在CommitLog文件中的偏移量快速定位到消息进行消费了。

ReputMessageService
「消息重放服务」,Broker在启动的时候,会开启一个线程每毫秒执行一次doReput()方法。它的目的就是对写入CommitLog文件里的消息进行「重放」,它有一个属性reputFromOffset,记录的是消息重放的偏移量,通过和CommitLog的maxOffset比较就知道有没有新的消息要重放。

消息重放流程:读取CommitLog文件,构建DispatchRequest对象,分发给各个CommitLogDispatcher处理。
MessageStore维护了CommitLogDispatcher对象集合,目前只有三个处理器:

  1. CommitLogDispatcherBuildConsumeQueue:构建ConsumeQueue索引。
  2. CommitLogDispatcherBuildIndex:构建Index索引。
  3. CommitLogDispatcherCalcBitMap:构建布隆过滤器,加速SQL92过滤效率。

写入ConsumeQueue索引项的代码ConsumeQueue.putMessagePositionInfo()

// 每个索引的长度是20字节,byteBufferIndex是循环使用的
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
/**
 * 索引结构:Offset+size+tagsCode
* 8字节 4字节 8字节
*/
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);

【总结】
消息写入CommitLog后,后续的处理由ReputMessageService线程异步分发。它会读取新写入的消息,构建DispatchRequest请求,分发给CommitLogDispatcher处理。CommitLogDispatcherBuildConsumeQueue收到请求后,定位ConsumeQueue文件,写入索引数据。

9. 构建Index

索引文件存储路径为$HOME/store/index/{fileName},fileName以文件创建时的时间戳命名,单个IndexFile是定长的,大小约400M,一个IndexFile可以保存2000万个索引,IndexFile底层存储结构为哈希+链表的结构,借鉴了HashMap的设计。

9.1 IndexFile

索引文件,对应的类是org.apache.rocketmq.store.index.IndexFile。索引文件的构成:

长度(字节)

说明

40

索引头信息

5000000*4

哈希槽

Other

索引数据

9.2 IndexHeader

索引头信息,对应的类是org.apache.rocketmq.store.index.IndexHeader。索引头包含一下信息:

长度(字节)

说明

8

索引起始时间戳

8

结束时间戳

8

索引起始CommitLog偏移量

8

结束CommitLog偏移量

4

哈希槽数量

4

索引数量

9.3 索引条目

单个索引条目是定长的,为20个字节。构成如下:

长度(字节)

说明

4

Key的哈希值

8

消息在CommitLog中的偏移量

4

存盘时间差(秒)

4

链接下一个索引的指针

链接下一个索引的指针默认值为0,当遇到哈希碰撞时,采用头插法,哈希槽指向最新的索引数据,指针链接下一个索引。为什么采用头插法?因为对于RocketMQ来说,关心的总是最新的消息。

9.4 流程分析

springboot rocketmq 发送消息设置key和tag_偏移量_10

写入索引数据的代码在org.apache.rocketmq.store.index.IndexFile.putKey()

this.mappedByteBuffer.putInt(absIndexPos, keyHash);
this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
// Next指针指向的是当前槽位索引
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);


【总结】
消息写入CommitLog后,后续的处理由ReputMessageService线程异步分发。它会读取新写入的消息,构建DispatchRequest请求,分发给CommitLogDispatcher处理。CommitLogDispatcherBuildIndex收到请求后,定位Index文件,写入索引数据。

10. Consumer拉取和消费

消费者获取消息的模式有两种:推模式和拉模式,对应的类分别是DefaultMQPushConsumer和DefaultMQPullConsumer,在4.9.0版本,DefaultMQPullConsumer已经被废弃了。

Push模式下,由Broker接收到消息后主动推送给消费者,实时性较高,但是会增加Broker的压力。Pull模式下,由消费者主动从Broker拉取消息,主动权在消费者,这种方式更灵活,消费者可以根据自己的消费能力拉取适量的消息。

实际上,Push模式也是通过Pull的方式实现的,消息统一由消费者主动拉取,那如何保证消息的实时性呢?
Consumer和Broker会建立长连接,一旦分配到MessageQueue,就会立马构建PullRequest去拉取消息,在不触发流控的情况下,不管有没有拉取到新的消息,Consumer都会立即再次拉取,这样就保证了消息消费的实时性。

如果Broker长时间没有新的消息,Consumer一直拉取,岂不是空转CPU浪费资源?
Consumer在拉取消息时,会携带参数suspendTimeoutMillis,它表示Broker在没有新的消息时,阻塞等待的时间,默认是15秒。如果没有消息,Broker等待15秒再返回结果,避免客户端频繁拉取。如果15秒内有新的消息了,立马返回,保证消息消费的时效性。

10.1 相关组件

DefaultMQPushConsumer

RocketMQ暴露给开发者使用的基于Push模式的默认生产者类,和DefaultMQProducer一样,它也仅仅是一个外观类,基本没有业务逻辑,几乎所有操作都转交给生产者实现类DefaultMQPushConsumerImpl完成。这么做的好处是RocketMQ屏蔽了内部实现,方便在后续的版本中随时更换实现类,而用户无感知。

DefaultMQPushConsumerImpl

默认的基于Push模式的消费者实现类,拥有消费者的所有功能,例如:拉取消息、执行钩子函数、消费者重平衡等等。

PullAPIWrapper

调用拉取消息API的包装类,它是Consumer拉取消息的核心类,它有一个方法特别重要pullKernelImpl,是拉取消息的核心方法。它会根据拉取的MessageQueue去查找对应的Broker,然后构建拉取消息请求头PullMessageRequestHeader发送到Broker,然后执行拉取回调,在回调里会通知消费者消费拉取到的消息。

OffsetStore

OffsetStore是RocketMQ提供的,用来帮助Consumer管理消费位点(消费进度)的接口,它有两个实现类:LocalFileOffsetStore和RemoteBrokerOffsetStore,从名字就可以看出来,一个是将消费进度存储在本地,一个是将消费进度存储在Broker上。
LocalFileOffsetStore会将消费进度持久化到本地磁盘,Consumer启动后会从指定目录读取文件,恢复消费进度。
RemoteBrokerOffsetStore将消费进度交给Broker管理,Consumer不会存储到文件,没有意义,但是消费消息时会暂存消费进度在内存,然后在拉取消息时上报消费进度,由Broker负责存储。

什么场景下需要将消费进度存储在本地呢?这和RocketMQ消息消费模式有关,RocketMQ支持两种消息消费模式:集群消费和广播消费。一个ConsumerGroup下可以有多个消费者实例,集群模式下,消息只会投递给其中一个Consumer实例消费,而广播模式下,消息会投递给每个Consumer实例。集群模式下,消费进度由Broker维护。广播模式下,消费进度由客户端维护。

ConsumeMessageService

消费消息的服务,客户端拉取到消息后,是需要有线程去消费的,所以它是一个线程池,线程数由consumeThreadMinconsumeThreadMax设置,默认线程数为20。

它是一个接口,比较重要的两个方法如下:

// 当前线程直接消费消息
ConsumeMessageDirectlyResult consumeMessageDirectly(final MessageExt msg, final String brokerName);

// 提交消费请求,由线程池去调度
void submitConsumeRequest(
    final List<MessageExt> msgs,
    final ProcessQueue processQueue,
    final MessageQueue messageQueue,
    final boolean dispathToConsume);

一个是由当前线程直接消费消息,另一个是提交消费请求ConsumeRequest由线程池去负责调度,一般情况下使用的还是后者。

RocketMQ提供了两个实现类,分别是ConsumeMessageConcurrentlyService和ConsumeMessageOrderlyService,前者用来并行消费消息,后者用来消费有序消息。

PullMessageService

消息拉取服务,负责从Broker拉取消息,然后提交给ConsumeMessageService消费。它也是一个线程,它的run方法是一个死循环,通过监听阻塞队列来判断是否需要拉取消息。阻塞队列里存放的就是PullRequest对象,当Consumer实例上线后,会做一次负载均衡,从众多MessageQueue中给自己完成分配,当有新的MessageQueue被分配给自己,就会创建PullRequest对象提交到阻塞队列,然后PullMessageService就会开始拉取消息,在拉取完成的回调函数中,不管有没有拉取到新的消息,在不触发流控的情况下,都会一直拉取。

10.2 流程分析

Consumer启动

springboot rocketmq 发送消息设置key和tag_RocketMQ_11

消息拉取

springboot rocketmq 发送消息设置key和tag_RocketMQ_12

消息消费

springboot rocketmq 发送消息设置key和tag_RocketMQ_13

何时触发消息拉取?

  1. 重平衡时,对于新分配的MessageQueue,立即拉取。
  2. 拉取完成,接着拉(流控)。


何时触发重平衡?

  1. Consumer启动,自身立即触发一次。
  2. 新Consumer上线,发心跳给Broker,Broker通知Group下所有实例触发。
  3. Consumer下线,Broker通知Group下所有实例触发。


【总结】
Consumer启动后,会给Broker发心跳,Broker通知消费组下的其它实例进行重平衡操作,当前实例也会立马进行重平衡操作。「重平衡」 就是消费组实例发生变化,Topic下的MessageQueue要重新分配消费者。对于新分配的MessageQueue会开始构建PullRequest请求提交给PullMessageService,消息拉取服务被唤醒,开始拉取消息。消息拉取结束,构建请求继续拉,同时通知ConsumeMessageService开始消费。

11. Broker消息投递

11.1 相关组件

PullMessageProcessor

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

TopicConfigManager

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

MessageFilter

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

DefaultMessageStore

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

ConsumerOffsetManager

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

11.2 流程分析

springboot rocketmq 发送消息设置key和tag_数据_14

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

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

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

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

12. 定时消息

定时消息(延迟消息)是RocketMQ比较有用的特性之一,定时消息被发送到Broker后,不会马上投递给Consumer,而是等待特定的时间,然后再投递消费。应用场景举例:用户下单后,系统锁定库存,如果用户在15分钟内未付款,系统自动取消订单,释放库存让其他用户有购买的机会。这种场景通过延迟消息就可以很轻松的实现。

延迟消息并不支持用户指定任意时间,而是通过设置延迟级别来指定的。RocketMQ最多支持18个延迟级别,每个延迟级别对应的延迟时间可以通过配置messageDelayLevel自定义,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”。该配置属于Broker,对所有Topic有效!默认的level值为0,代表非延迟消息,超过18按最大值18计算。

如果Broker接收到的是延迟消息,会改写消息Topic为SCHEDULE_TOPIC_XXXX,改写queueId为延迟级别level-1,这样在构建ConsumeQueue的时候,消息就不会被构建到目标消费队列,消费者暂时也就无法消费这条消息了。然后由ScheduleMessageService服务,定时去扫描延迟队列里的消息,完成延迟消息的交付。如果消息的交付时间到了,会构建新的Message对象,恢复原来的Topic、queueId等属性,重新写回CommitLog,之后Consumer就可以正常消费这条数据了。

简单点说,就是Broker先替Producer将这条消息暂存到统一的「延迟队列」中,然后定时去扫描这个队列,将需要交付的消息重新写回到CommitLog。

12.1 相关组件

DefaultMessageStore

默认的消息仓库,RocketMQ将消息存储到磁盘,涉及的文件有:CommitLog、ConsumeQueue、Index,这些文件均通过MessageStore进行维护管理,例如:消息写入CommitLog、索引写入ConsumeQueue等。

CommitLog

CommitLog用来存储消息主体和其元数据,虽然RocketMQ是基于Topic主题订阅模式的,但是对于Broker而言,所有消息全部写入CommitLog,不关心Topic,因此CommitLog是完全顺序写的。

ScheduleMessageService

定时消息服务,用来处理延迟消息,将需要交付的消息重新写回CommitLog。它内部有一个Timer定时器,通过提交延时任务的方式来工作。

DeliverDelayedMessageTimerTask

交付延时消息的定时任务,它继承自TimerTask,可以提交到Timer调度执行。当ScheduleMessageService需要处理延时消息时,就会创建该对象提交到Timer,当消息还未到交付时间时,会计算倒计时,然后再重新提交一个延时任务。

12.2 流程分析

springboot rocketmq 发送消息设置key和tag_RocketMQ_15

Tips:如果是延迟消息,TagsHash存储的不再是Tag字符串的哈希值,而是存储消息的交付时间戳。否则判断消息是否需要交付还要去随机读CommitLog,效率太低。

【总结】
RocketMQ对延迟消息的实现原理是,Broker默认会有一个延迟消息专属的Topic,下面有18个队列,每个延迟级别对应一个队列。如果Broker接收到的是延迟消息,会改写消息的Topic和queueId,将消息暂时写入统一的延迟队列中,然后由ScheduleMessageService线程对延迟队进行扫描,将到期需要交付的消息从CommitLog中读出来,恢复消息原本的Topic和queueId等属性,重新写回CommitLog,然后Consumer就可以正常消费了。

13. 事务消息

RocketMQ采用2PC的思想,实现了Producer发送「事务消息」。事务消息的提交分为两个阶段,阶段一,Producer发送半事务(Half)消息到Broker,Broker存储消息,然后响应消息写入结果,此时消息对Consumer是不可见的。阶段二,Producer根据消息发送结果做对应的处理,如果消息发送成功,则开始执行本地事务,并提交事务状态给Broker,事务状态有三种,对应的Broker动作为:

  1. COMMIT_MESSAGE:事务提交,消息对Consumer可见。
  2. ROLLBACK_MESSAGE:事务回滚,丢弃消息。
  3. UNKNOW:事务未知,暂不处理,稍后进行回查。


消息回查是对二阶段的一个补偿机制,因为很可能本地事务执行完了,但是提交事务状态失败。Broker会启动TransactionalMessageCheckService线程,定时会Half消息进行回查,将消息重新发送给Producer检查本地事务。同时,为了避免消息无限制的回查,默认最大回查次数为15,超过15次扔到一个特殊的队列TRANS_CHECK_MAX_TIME_TOPIC(网上有文章说打印日志然后直接丢弃)。

springboot rocketmq 发送消息设置key和tag_客户端_16

13.1 相关组件

TransactionMQProducer

支持事务消息的生产者,继承自DefaultMQProducer,在默认生产者上进行了扩展,支持发送事务消息。它拥有一个线程池executorService用来异步执行本地事务和回查事务,还需要注册TransactionListener事务监听器,里面包含了执行本地事务和回查事务的逻辑。

SendMessageProcessor

Broker用来处理Producer消息发送的处理器,在发送事务消息时,Producer首先会发送一个Half消息,SendMessageProcessor会对事务消息进行判断,如果是事务消息,会改写Topic和queueId,将消息写入到一个统一的事务队列中。

EndTransactionProcessor

Broker用来处理Producer提交事务状态的处理器,它会根据Producer提交的本地事务状态选择将Half消息进行Commit或者Rollback,如果Commit则消息对Consumer可见,否则丢弃Half消息。

TransactionalMessageService

事务消息服务类,它提供了对Half消息的所有操作,包括:准备Half消息、提交或回滚Half消息、删除Half消息、回查Half消息。

TransactionalMessageCheckService

事务消息回查服务,它是一个单独的线程,默认每隔60秒对未确认的Half消息进行回查,根据回查的事务状态选择将消息进行Commit或Rollback。

13.2 流程分析

13.2.1 Half消息发送

springboot rocketmq 发送消息设置key和tag_RocketMQ_17

13.2.2 事务回查

springboot rocketmq 发送消息设置key和tag_偏移量_18

【总结】
RocketMQ实现事务消息的原理和实现延迟消息的原理类似,都是通过改写Topic和queueId,暂时将消息先写入一个对Consumer不可见的队列中,然后等待Producer执行本地事务,提交事务状态后再决定将Half消息Commit或者Rollback。同时,可能因为服务宕机或网络抖动等原因,Broker没有收到Producer的事务状态提交请求,为了对二阶段进行补偿,Broker会主动对未确认的Half消息进行事务回查,判断消息的最终状态是否确认,是通过Op队列实现的,Half消息一旦确认事务状态,就会往Op队列中写入一条消息,消息内容是Half消息所在ConsumeQueue的偏移量。

14. 顺序消息

顺序消息是RocketMQ的特性之一,它可以让Consumer消费消息的顺序严格按照消息的发送顺序来进行。例如:一条订单产生的三条消息:订单创建、订单付款、订单完成。消费时要按照这个顺序依次消费才有意义,但是不同的订单之间这些消息是可以并行消费的。

全局有序:某个Topic下所有的消息都是有序的,所有消息按照严格的先进先出的顺序进行生产和消费,要求Topic下只能有一个分区队列,且Consumer只能有一个线程消费,适用对性能要求不高的场景。

分区有序:某个Topic下,所有消息根据ShardingKey进行分区,相同ShardingKey的消息必须被发送到同一个分区队列,因为队列本身是可以保证先进先出的,此时只要保证Consumer同一个队列单线程消费即可。

RocketMQ里的分区队列MessageQueue本身是能保证FIFO的,正常情况下不能顺序消费消息主要有两个原因:

  1. Producer发送消息到MessageQueue时是轮询发送的,消息被发送到不同的分区队列,就不能保证FIFO了。
  2. Consumer默认是多线程并发消费同一个MessageQueue的,即使消息是顺序到达的,也不能保证消息顺序消费。

综上所述,RocketMQ要想实现顺序消息,核心就是Producer同步发送,确保一组顺序消息被发送到同一个分区队列,然后Consumer确保同一个队列只被一个线程消费。

14.1 相关组件

MessageQueueSelector

分区队列选择器,它是一个接口,只有一个select方法,根据ShardingKey从Topic下所有的分区队列中,选择一个目标队列进行消息发送,必须确保相同ShardingKey选择的是同一个分区队列,常见作法是对队列数取模。

RebalanceLockManager

Consumer重平衡操作时,Broker维护的全局锁管理器。Consumer在重平衡时,会开始拉取新分配的MessageQueue里的消息,但是如果是顺序消息,在拉取消息前,必须向Broker竞争队列锁成功才能拉取。因为,此时MessageQueue很可能还在被其它Consumer实例消费,消费位点还没有上报,直接拉取会导致消息重复消费、消费顺序错乱。

RebalanceLockManager维护了一个ConcurrentMap容器,里面存放了所有MessageQueue对应的LockEntry对象,LockEntry记录了MessageQueue锁的持有者客户端ID和最后的更新时间戳,以此来判断MessageQueue的锁状态和锁超时。

ConsumeMessageOrderlyService

消费顺序消息服务类,与之对应的还有ConsumeMessageConcurrentlyService消费并发消息服务类,它俩最大的区别就是ConsumeMessageOrderlyService在获取MessageQueue里的消息并消费之前,会对MessageQueue加锁,确保同一时间单个MessageQueue最多只会被一个线程消费,因为MessageQueue里的消息是有序的,只要消费有序就能保证最终有序。

MessageQueueLock

Consumer用来维护MessageQueue对应的本地锁对象,使用ConcurrentHashMap来管理。确保同一个MessageQueue同一时间最多只会被一个线程消费,因此线程消费前必须先竞争队列本地锁。通过synchronized关键字来保证同步,因此锁对象就是一个Object对象。

14.2 流程分析

14.2.1 发送有序

springboot rocketmq 发送消息设置key和tag_RocketMQ_19

14.2.2 消费有序

springboot rocketmq 发送消息设置key和tag_客户端_20

【总结】
顺序消费需要Producer、Broker、Consumer三者一起配合才能正常工作。首先,Producer需要确保相同ShardingKey的消息被发送到同一分区队列中,因为队列本身是能保证FIFO的,这是基础。然后,Broker需要维护全局MessageQueue的锁状态,Consumer拉取消息前,必须保证竞争锁队列成功,否则就会导致同一MessageQueue里的消息被多个Consumer实例消费,造成消息重复消费和顺序错乱。最后,Consumer在消费MessageQueue的消息前,必须确保竞争MessageQueue本地锁成功,同一个MessageQueue同一时间最多只能被一个线程消费。

15. 刷盘策略

SYNC_FLUSH

同步刷盘

ASYNC_FLUSH

异步刷盘

ASYNC_FLUSH

&&
transientStorePoolEnable=trye | 异步刷盘+缓冲区 |

15.1 相关组件

FlushCommitLogService

CommitLog刷盘服务的父类,它是一个抽象类,本身没有实现,只是一个标记类,三种刷盘策略均由三个子类负责完成。

GroupCommitService

同步刷盘实现,当有新的消息被写入CommitLog,就会提交一个GroupCommitRequest同步刷盘请求,然后执行doCommit方法开始对CommitLog下的MappedFile文件进行强制刷盘。

FlushRealTimeService

异步刷盘实现,run方法是一个while循环,只要服务没停止,就会一直定对CommitLog下的MappedFile文件进行刷盘。默认间隔时间是500ms,可通过flushIntervalCommitLog属性设置。

Tips:RocketMQ做了一个小优化,异步刷盘时,最小刷盘页是4,即16KB。意味着即使间隔时间到了,只要新写入的数据不足16KB,也会放弃刷盘,因为异步刷盘本身就是允许丢失少量消息的嘛,这样可以避免频繁无意义的刷盘。

CommitRealTimeService

异步刷盘+缓冲区实现,只有当配置ASYNC_FLUSHtransientStorePoolEnable=true时才会生效。在这种刷盘策略下,RocketMQ会提前申请一块直接内存用作缓冲区,放弃使用mmap写文件。数据先写入缓冲区,然后异步线程每200ms将缓冲区的数据写入FileChannel,再唤醒FlushRealTimeService服务将FileChannel里的数据持久化到磁盘。

15.2 流程分析

springboot rocketmq 发送消息设置key和tag_数据_21

开启缓冲区有什么用?
类似在内存层面做了读写分离,写数据走直接内存,读数据走PageCache,最大程度的消除了PageCache锁竞争,避免PageCache被交换到硬盘Swap分区,导致服务响应耗时出现毛刺。