基于4.9.0版本分析,https:///apache/rocketmq/tree/rocketmq-all-4.9.0
1. 缘起
- 阿里内部为了适应淘宝更快、更复杂的业务,在2001年启动了「五彩石项目」,第一代消息队列服务Notify在这个背景下应运而生。
- 2010年ActiveMQ仍然作为核心技术广泛应用于阿里内部各个业务线,与此同时,支持顺序消息、事务消息、海量消息堆积的消息服务也是阿里急需的,在这种背景下,2011年MetaQ诞生。
- 2011年Kafka开源,2012年阿里参考Kafka的设计,研发了一套通用的消息队列引擎,第一代RocketMQ诞生。
- 2016年,阿里云上线RocketMQ消息队列云服务。同年11月,阿里将RocketMQ捐献给Apache基金会,开始孵化。
- 2017年9月25日,RocketMQ顺利“毕业”,成为Apache顶级项目。
2. 特性
【为什么使用RocketMQ】
- 削峰填谷
- 服务解耦
- 异步处理
- 分布式事务最终一致性
3. 架构
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网络协议设计的足够简单,请求和响应的协议是一样的。
- 4字节存储报文总长度。
- 1字节序列化方式+3字节Header长度。
- Header数据。
- 报文主体。
请求头一般不会太长,缩减至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比较重要的类:
- NamesrvStartup:服务启动类,帮助读取配置,创建Controller并启动服务。
- NamesrvController:核心类,负责服务的初始化、启动和停止。
- KVConfigManager:KV配置信息管理,支持持久化。
- RouteInfoManager:管理路由信息。
- NettyRemotingServer:Netty服务端,处理客户端请求。
- 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?
- Broker正常关闭,发送UNREGISTER_BROKER命令。
- 定时任务10秒扫描一次,距离上次心跳时间超过2分钟,主动剔除。
- 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在后续版本可以任意更换实现类,对用户零感知。
三种通信模式:同步发送、异步发送、单向发送。
发送消息对应的请求头是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
- 禁用:轮询,但是会跳过上次发送失败的Broker。
- 启用:
- Broker不可用时,计算不可用时长,创建FaultItem规避。
- 轮询时规避不可用的Broker。
- 重写MessageQueueSelector
【总结】
- 查找TopicPublishInfo:
tryToFindTopicPublishInfo()
。 - 选择一个分区队列:
selectOneMessageQueue()
。 - 获取MessageQueue所在Broker的Master机器Channel。
- 构建Header、设置Body、发送请求。
7. 高性能存储
RocketMQ支持亿级别的消息堆积能力,还能保证性能不受太大影响。这种级别的数据量,不可能存储在内存。「磁盘IO效率慢」的概念深入人心,每次消息发送都写磁盘,岂不是性能极差?
7.1 三大利器
7.1.1 顺序写
磁盘如果利用的好,它的效率比你想象的要快得多。磁盘随机写的效率确实很差,约100KB每秒的写入速度,但是对于顺序写,在Page Cache的加持下,它的写入速度能达到600MB每秒,这已经超过了绝大多数网卡的读写速度了,所以只要能保证顺序写,磁盘IO并不是性能瓶颈。
RocketMQ存储消息,主要涉及到三大类文件,分别是:CommitLog、ConsumerQueue、Index。
- CommitLog存储Broker上所有的消息,不管你是哪个Topic下的,全部写到CommitLog文件,它是完全顺序写的。
- ConsumerQueue是RocketMQ用来加速消费者消费消息的索引文件,每个Topic是一个文件夹,下面再以QueueID分片存储,消息写入到CommitLog后,还要往对应的ConsumerQueue文件写入一个索引信息,它也是顺序写的。
- Index是RocketMQ用来实现消息查询的索引文件,有了它就可以通过Key和时间范围快速查询消息,同样的,消息写入到CommitLog后,也会往Index中写入索引数据,也是顺序写的。
7.1.2 内存映射与零拷贝
以前,我们从磁盘读写数据时,均需要经过至少两次数据拷贝。
读:磁盘 > 内核缓冲区 > JVM内存。
写:JVM内存 > 内核缓冲区 磁盘。
而内存映射技术,不管是读还是写,均只需要一次数据拷贝。
读:磁盘 > 内核缓冲区。
写:内核缓冲区 > 磁盘。
用户空间直接拿应用程序的逻辑内存地址映射到Linux系统的内核缓冲区,这样应用程序看似读写的是自己的内存,其实读写的是内核缓冲区,数据不用在内核空间和用户空间来回拷贝了,不仅减少了内存复制的开销,还避免了因系统调用引起的软中断。
「零拷贝」是提升IO效率的终极利器,以前,如果我们需要把磁盘中的数据发送到网络,至少需要经过4次数据拷贝:磁盘 > 内核缓冲区 > JVM > Socket缓冲区 > 网卡。
利用内存映射,最多只需要三次数据拷贝,数据直接从内核缓冲区拷贝到Socket缓冲区就可以直接发送了。实际上,可能连内核缓冲区拷贝到Socket缓冲区的过程都没有了,内核缓冲区和Socket缓冲区也可以建立内存映射,这样就只剩下两次数据拷贝了。
综上所述,零拷贝的核心是内存映射,内存映射技术在Linux系统上对应的是mmap
系统函数,在Java中对应的是MappedByteBuffer类。
mmap
函数有一个缺陷,对映射的文件大小有限制,所以CommitLog单个文件默认为1GB。
7.1.3 异步刷盘
同步刷盘:消息写入Page Cache后调用系统函数fsync
将数据同步到磁盘才给客户端返回ACK响应,这种方式对数据的安全性很高,但是性能会有较大影响。
异步刷盘:充分利用Page Cache的优势,只要消息写入Page Cache就给客户端返回ACK响应,RocketMQ会在后台起一个线程异步刷盘,极大的提高了性能和吞吐量。
异步刷盘+缓冲区:提前申请一块直接内存用作缓冲区,并且锁住这块内存避免被交换到Swap分区。消息先写入直接内存缓冲区,然后定时持久化到磁盘。性能最好,但是最不可靠。内存层面读写分离,写往直接内存写、读从PageCache读,最大程度避免PageCache锁竞争,解决Broker响应延时出现毛刺。
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 流程分析
消息按照固定格式写入文件的代码在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对象集合,目前只有三个处理器:
- CommitLogDispatcherBuildConsumeQueue:构建ConsumeQueue索引。
- CommitLogDispatcherBuildIndex:构建Index索引。
- 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 流程分析
写入索引数据的代码在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
消费消息的服务,客户端拉取到消息后,是需要有线程去消费的,所以它是一个线程池,线程数由consumeThreadMin
和consumeThreadMax
设置,默认线程数为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启动
消息拉取
消息消费
何时触发消息拉取?
- 重平衡时,对于新分配的MessageQueue,立即拉取。
- 拉取完成,接着拉(流控)。
何时触发重平衡?
- Consumer启动,自身立即触发一次。
- 新Consumer上线,发心跳给Broker,Broker通知Group下所有实例触发。
- 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 流程分析
消息检索会导致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 流程分析
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动作为:
- COMMIT_MESSAGE:事务提交,消息对Consumer可见。
- ROLLBACK_MESSAGE:事务回滚,丢弃消息。
- UNKNOW:事务未知,暂不处理,稍后进行回查。
消息回查是对二阶段的一个补偿机制,因为很可能本地事务执行完了,但是提交事务状态失败。Broker会启动TransactionalMessageCheckService线程,定时会Half消息进行回查,将消息重新发送给Producer检查本地事务。同时,为了避免消息无限制的回查,默认最大回查次数为15,超过15次扔到一个特殊的队列TRANS_CHECK_MAX_TIME_TOPIC
(网上有文章说打印日志然后直接丢弃)。
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消息发送
13.2.2 事务回查
【总结】
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的,正常情况下不能顺序消费消息主要有两个原因:
- Producer发送消息到MessageQueue时是轮询发送的,消息被发送到不同的分区队列,就不能保证FIFO了。
- 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 发送有序
14.2.2 消费有序
【总结】
顺序消费需要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_FLUSH
且transientStorePoolEnable=true
时才会生效。在这种刷盘策略下,RocketMQ会提前申请一块直接内存用作缓冲区,放弃使用mmap写文件。数据先写入缓冲区,然后异步线程每200ms将缓冲区的数据写入FileChannel,再唤醒FlushRealTimeService服务将FileChannel里的数据持久化到磁盘。
15.2 流程分析
开启缓冲区有什么用?
类似在内存层面做了读写分离,写数据走直接内存,读数据走PageCache,最大程度的消除了PageCache锁竞争,避免PageCache被交换到硬盘Swap分区,导致服务响应耗时出现毛刺。