RocketMQ消息存储原理

831 阅读14分钟

大家好,我是一个喜欢诗词的java研发赛亚人,感谢您的关注~ ┗( ▔, ▔ )┛。微信搜索【程序猿卡卡罗特】,后续有更多硬核文章哦~

今日诗词:为君持酒劝斜阳,且向花间留晚照。 -- [宋·宋祁]《玉楼春·风前欲劝春光住》

使用过RocketMq的同学都知道,消息(Message) 是通过 Producer 经过RocketMQ,然后Consumer通过订阅消息,从而获得Producer的消息的。

但对RocketMq内部的消息存储结构可能不太了解,今天我们就来扒一扒RocketMQ内部消息存储的底裤。

举个栗子

下面是最常见的,生产者、消费者使用RocketMQ的代码。

生产者

public static void main(String[] args) throws Exception {

    //1、创建一个DefaultMQProducer,需要指定消息发送组
    DefaultMQProducer producer = new DefaultMQProducer("Test_Quick_Producer_Name");
    //2、指定Namesvr地址
    producer.setNamesrvAddr("localhost:9876");
    //3、启动Producer
    producer.start();
    System.out.println("producer start...");
    
    //4、构建消息
    Message message = new Message(
        "DEV_HC_TEST",     //主题
        "TagA",                  //标签,可以用来做过滤
        "KeyA",                  //唯一标识,可以用来查找消息
        "hello rocketmq".getBytes()  //要发送的消息字节数组
    );
    
    System.out.println(message);
    
    // 5、发送消息:指定超时时间,默认是3s
    SendResult result = producer.send(message, 13000);
    System.out.println(result);
    
    //6、关闭producer
    producer.shutdown();
}

消费者

public static void main(String[] args) throws Exception {
    //1、创建DefaultMQPushConsumer
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("Test_Quick_Consumer_Name");

    // 2、设置namesrv地址
    consumer.setNamesrvAddr("localhost:9876");

    // 3、设置监听topic
    consumer.subscribe(
        "DEV_HC_TEST",     //指定要读取的消息主题
        "*");        //指定要读取的消息过滤信息,多个标签数据,则可以输入"tag1 || tag2 || tag3"


    //4、创建消息监听
    consumer.setMessageListener(new MessageListenerConcurrently() {
        @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
            try {
                //获取第1个消息
                MessageExt message = msgs.get(0);
                //获取主题
                String topic = message.getTopic();
                //获取标签
                String tags = message.getTags();
                //获取消息
                String result = new String(message.getBody(),"UTF-8");

                System.out.println("topic:"+topic+",tags:"+tags+",result:"+result);

            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
                //消息重试
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
            //消息消费成功
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    });
    
    System.out.println("start...");
    // 5、启动消费监听
    consumer.start();
}

几个问题

1、消息是发送给到RocketMQ 的NameServer 还是Broker?存在NameServer 中还是Broker中?

2、消息是存到哪里了?内存?磁盘?

3、消息的索引 key 是如何加快消息的查找的?

4、我们平时说,消费者从消费队列中消费消息,这个消费队列是内部是如何存储Message的?是存储了完整的Message数据吗?

5、Tag 是如何做到可以过滤消息的?

RocketMQ架构图

为了故事顺序发展下去,我们先来看看RocketMQ的架构图,以及一个Msg从Producer生产出来,到被Consuemer消费,是如何进行流转的。

什么?你已经知道了这个架构了? 那我再带给为回顾回顾趴~

MQ架构图.png

我们以一条Message 生产到消费的流程捋一捋找个流程:

1、NameServer:

  • 相当于是一个注册中心(类似ZK)用于保存Broker的信息(Broker 的集群信息、Topic路由信息...)。什么是Topic路由信息?下面会讲到。
  • 可以集群部署,集群中的每个NameServer不会有数据交互

2、Broker:

  • 也称为消息服务器,用于存储Message
  • Broker 分为主节点和从节点。以上图架构图为例。Broker有两个集群,Master 和 Slave实现了读写分离。
  • Broker 启动时,会向所有的NameServer注册自身的集群信息、自身消息的路由信息

3、Producer:消息生产者

  • Producer启动是会从NameServer集群中随机选取一个NameServer获取 Broker 的信息(topic 信息、BrokerName,Broker的IP...),这些信息我们成为Topic 路由信息
  • 发送消息是直接发给 Broker的,并没有经过Nameserver了(已经拿到了Broker 的IP,为啥还要经过NameServer呢?)
  • 发送消息时会根据 Message的Topic,从路由信息表(启动时从NameServer中获取了)中根据负载策略选择一个Broker主节点,将消息发给这个Broker主节点。

4、Consumer:消息消费者

  • Consumer 启动时会从NameServer 中获取Broker的路由信息
  • 启动后,定时从Broker中拉取消息进行消费(有同学就要问了,Consumer不是有PUSH模式吗,PUSH模式不是Broker消息到达后,主动推给Consumer吗?实际上,PUSH、PULL模式都是Consumer主动从Broker拉取消息的,只是PUSH这个单词会让人很受歧义。后续再写一篇文章扒一扒Conumer消费流程的底裤。 (*´・v・)

好了,相信读者已经大致知道了Msg 发送到消费的流程,下面我们重点讲解Msg在Broker是如何存储的。

等等,你想知道Broker的路由信息到底是啥玩意?好趴,那我们先来唠唠这个路由信息。

小恐龙.webp

Producer路由信息

1、首先,我们常说Msg是存到Broker上的队列(注意这里打上了双引号)上的。

2、实际上我们可以认为,一个Topic会分配多个队列(为了负载考虑),每条消息只会存在一个队列中。

3、Producer 发消息时,首先要找到Broker,然后在根据Topic发到其中一个队列中。

4、实际上一个队列的多个Topic的队列是以数字命名的:0、1、2、3....,也就是常说的queueId(队列ID)

所以路由信息包括哪些信息呢?

  • <Topic, BrokerName> :根据Topic找到对应的Broker集群,主节点的brokerId为0,从节点>0。集群有多个节点时,Producer只会发给Broker主节点。
  • <BrokerName, IP>:根据brokerName找到对应的IP
  • <Topic, List queueId> :Topic 对应多个queueId(根据负载均衡算法找到其中一个queueId)
  • 在最新版本的RokcetMQ中一条消息默认有16个写队列,16个读队列

注意:实际上Broker内部并不是按照上面这种数据结构去定义Topic路由信息的,以上是为了讲解方便。

那有同学就要问了:卡卡罗特,那具体的源码里面Topic路由信息到底有哪些字段?等我更新....(欢迎各位爷评论区给我留言,看看有哪些好学的爷~

10A4FBC4.jpg

Broker存储结构

下面是RocketMQ Broker的主要存储数据的目录:我们说的消息、索引,消费队列,Broker运行时的一些信息都会存到磁盘中。

Coonfig目录.png

有同学又要问了:卡卡罗特,你这不是windows的目录结构吗?RocketMQ是部署在Linux中的吗?怎么会是你这样。

这位好学的同学问得好:实际上RocketMQ在Linux中的存储目录结构也是这样的。我是将RocketMQ源码load到本地,然后在本地IDEA中启动的哦~

MQ源码目录.png

需要先启动 rocketmq-namesrv项目、后启动rocektmq-broker项目(当然需要配置一些数据)。

怎么配置?

157509F4.jpg

等我。。。。


  • commitlog:producer发送的所有Message都存到这个目录下
  • config:Broker运行期间一些配置信息,主要包括下列信息
  • consumequeue:(消费队列)topic的队列(上文提到一个Topic会分配多个队列)
  • index:消息索引文件,主要存储消息Key与Offset的对应关系
  • abort:Broker启动时会生成一个该文件。该文件内容为空(可以看到大小为为0k)。
    • Broker正常退出会删除该文件。非正常退出该文件还会存在。
    • 作用:用于判断Broker是否正常退出
  • checkpoint:文件检测点,存储commitlog文件最后一次刷盘时间戳、consumequeue最后一次刷盘时间、index索引文件最后一次刷盘时间戳

CommitLog

驼峰命名法看起来舒服一点。

我们知道Msg是先存到Broker的内存中的,之后才会刷到磁盘中进行存储,实际上就是存储到这个文件夹的。

注意:所有的消息(不分区Topic)的全部数据都是存到这个文件夹中的。(surprise~ ,那之前说的Topic存储到队列中是什么鬼?别着急嘛,接着往下看 (´・ᴗ・`))

CommitLog目录.png

  • 该目录下存在多个文件,文件时用消息的偏移量命名的,我们称这个文件为MappedFile
  • 每个MappedFile默认1G大小,某个MappedFile存满了,就生成一个新的MappedFile继续消息的存储
偏移量是什么意思?

我们说Message存到到CommitLog 目录下的MappedFile中,每个具体存到MappedFile中**哪里。**我们可以通过offset(偏移量)来定位到这条信息。

1、首先offset 是对整个CommitLog来说的一个全局offset,

2、通过offset查找消息的过程:

  1. 找到MappedFile:offset/1024*1025(1G) 我们就可以得出是第几个 MappedFile
  2. 找到对应的MappedFile后,通过offset % (1024*1024) 就知道了在MappedFile中的偏移量了
  3. 知道在MappedFile中存储的起始地址 + 消息的size 就得到了消息在MappedFile的结束地址
  4. 拿到起始地址,结束地址,就可以直接从 MappedFile中读取到一条Message了

MappedFile.png

消息存储哪些信息?
public class Message implements Serializable {

    private String topic;
    
    private int flag;
    
    // tag、index、delevelLevel.. 属性都存在这个map 中
    private Map<String, String> properties;
    
    // 消息内容
    private byte[] body;
    
    // 如果是事务消息,有ID
    private String transactionId;

}

Message扩展属性,最终存储的Msg

重点注意下面四个属性

public class MessageExt extends Message {

    // 1、消息队列ID
    private int queueId;

    // 2、消息字节数
    private int storeSize;

    // 3、在队列中的偏移量
    private long queueOffset;
    private int sysFlag;
    private long bornTimestamp;
    private SocketAddress bornHost;

    private long storeTimestamp;
    private SocketAddress storeHost;
    private String msgId;
    
    // 4、commitLog中的偏移量
    private long commitLogOffset;
    private int bodyCRC;
    
    private int reconsumeTimes;

    private long preparedTransactionOffset;
}

实际上从这个方法可以看到具体存了哪些数据:

protected static int calMsgLength(int bodyLength, int topicLength, int propertiesLength) {
    final int msgLen = 4 //TOTALSIZE
        + 4 //MAGICCODE
        + 4 //BODYCRC
        + 4 //QUEUEID
        + 4 //FLAG
        + 8 //QUEUEOFFSET
        + 8 //PHYSICALOFFSET
        + 4 //SYSFLAG
        + 8 //BORNTIMESTAMP
        + 8 //BORNHOST
        + 8 //STORETIMESTAMP
        + 8 //STOREHOSTADDRESS
        + 4 //RECONSUMETIMES
        + 8 //Prepared Transaction Offset
        + 4 + (bodyLength > 0 ? bodyLength : 0) //BODY
        + 1 + topicLength //TOPIC
        + 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength
        + 0;
    return msgLen;
}

1)TOTALSIZE:该消息条目总长度,4字节。 2)MAGICCODE:魔数,4字节。固定值0xdaa320a7。 3)BODYCRC:消息体crc校验码,4字节。 4)QUEUEID:消息消费队列ID,4字节。 5)FLAG:消息FLAG, RocketMQ不做处理,供应用程序使用,默认4字节。 6)QUEUEOFFSET:消息在消息消费队列的偏移量,8字节。 7)PHYSICALOFFSET:消息在CommitLog文件中的偏移量,8字节。 8)SYSFLAG:消息系统Flag,例如是否压缩、是否是事务消息等,4字节。 9)BORNTIMESTAMP:消息生产者调用消息发送API的时间戳,8字节。 10)BORNHOST:消息发送者IP、端口号,8字节。 11)STORETIMESTAMP:消息存储时间戳,8字节。 12)STOREHOSTADDRESS:Broker服务器IP+端口号,8字节。 13)RECONSUMETIMES:消息重试次数,4字节。14)Prepared Transaction Offset:事务消息物理偏移量,8字节。 15)BodyLength:消息体长度,4字节。 16)Body:消息体内容,长度为bodyLenth中存储的值。 17)TopicLength:主题存储长度,1字节,表示主题名称不能超过255个字符。 18)Topic:主题,长度为TopicLength中存储的值。 19)PropertiesLength:消息属性长度,2字节,表示消息属性长度不能超过65536个字符。 20)Properties:消息属性,长度为PropertiesLength中存储的值。

所以可以粗略认为该文件的MappedFile中,Msg是如下结构存储的:

CommitLog中的MappedFile.png

MessageId

我们知道每个消息都有一个唯一的msgId,那么这个ID是如何保证唯一性的呢?

实际上MessageId由三部分组成:

  • IP:Broker的IP
  • 端口号:Broker的端口号
  • 消息偏移量:CommitLog的偏移量

通过上述三个部分来保证唯一性。

MessageId.png

所以:我们通过messageId能找到对应的msg原因是:

1、msgId 解析出上述三个部分:IP:Broker 的IP

2、通过offset找到MappedFile,找到该MappedFile 上的消息起始地址,读取该消息的 TOTALSIZE得到结束地址就可以拿到消息了。

Config 文件夹

Coonfig目录.png

主要是一些延迟消息、topic信息、消费队列信息..... (不是本次重点,暂不讨论)

ConsumeQueue文件夹

ComsumeQueue.png

1、每一个Topic 都会在该文件夹中生成一个以Topic命名的文件夹

2、Topic 下级目录是 Topic的队列信息,其实就是我们说的消费队列

3、每个队列中有多个MappedFile(也成为MappedFile),每个文件最大1G

ConsumeQueue 中的MappedFile中存储每个Topic的如下信息:

ConsumeQueueItem.png

  • CommitLog offset:在CommitLog中的偏移量
  • size:消息的总大小。其实就是CommitLog中的MappedFile的消息的 TOTALSIZE
  • tag hashCode:消息的 Tag经过哈希函数得到的哈希值(主要是为了保证每个 Item是20字节)

1、单个ConsumeQueue文件中默认包含30万个条目,单个文件的长度为30w×20字节

2、单个ConsumeQueue文件可以看出是一个ConsumeQueue条目的数组

ConsumeQueueItem 如何查找消息?

ConsumeQueueItem例子.png

1、找到对应的Item 的index,假设是2

2、找到index=2,得到 commitLog offset + size,然后去CommitLog文件夹找就可以得到其下的MappedFile的起始地址、结束地址,也就拿到了消息。

需知

1、消息的全量内容是存到CommitLog中,因为每个消息都有Topic属性,所有在ConsumeQueue文件夹下的其中一个队列会一条Item数据

2、实际上Consumer也是从ConsumeQueue 下根据Topic下的队列(0、1、2、3...)进行消息消费的

  • 假设是消费者是集群模式:每个消费者只会订阅Topic下的部分队列
  • 广播消费:每个消费者订阅Topic下的所有队列

3、Consumer 基于 Tag 过滤也是通过这个文件进行过滤的。

什么?如何进行消息的过滤?

157509F4.jpg

给我留言,等我。。。。


Index

RocketMQ引入了Hash索引机制为消息建立索引,HashMap的设计包含两个基本点:Hash槽与Hash冲突的链表结构。

发送消息时可以指定索引:

Message message = new Message(
    topic,  //主题
    tag,   // 标签
    key,   // 索引
    ("hello rocketmq" + i).getBytes()  //要发送的消息字节数组
);

// Message 的构造函数
public Message(String topic, String tags, String keys, byte[] body) {
    this(topic, tags, keys, 0, body, true);
}

Index 内部存储结构如下:

Index存储属性.png

包含三个部分:

  • IndexHead:该IndexFile的统计信息
    • beginTimestamp:该索引文件中包含消息的最小存储时间。
    • endTimestamp:该索引文件中包含消息的最大存储时间。
    • beginPhyoffset:该索引文件中包含消息的最小物理偏移量(commitlog文件偏移量)。
    • hashslotCount:hashslot个数,并不是hash槽使用的个数,在这里意义不大。
  • hash 槽:一个IndexFile默认包含500万个Hash槽,每个Hash槽存储的是落在该Hash槽的hashcode最新的Index的索引。
  • index Item:默认一个索引文件包含2000万个条目
    • hashcode:key的hashcode。
    • phyoffset:消息对应的物理偏移量。
    • timedif:该消息存储时间与第一条消息的时间戳的差值,小于0该消息无效。
    • preIndexNo:该条目的前一条记录的Index索引,当出现hash冲突时,构建的链表结构。
新增数据

假设一条msg到达Broker,存到CommitLog后,如果指定了 key,就需要存到该结构中。

1、key 经过 hash函数,得到一个hash值,该hashValue % (500w),拿到哈希槽,哈希槽中存储了IndexItem的下标

2、得到IndexItem的下标,填充Index Item的四个属性数据(该位置有值,说明发生哈希冲突,采用开放地址法指定preIndexNo)

通过Index如何查找数据?

1、key -> hashMethod -> hash slot -> IndexItem

2、拿到IndexItem后,取 phyoffset ,即CommitLog的偏移量,就取得到数据了

发现没有,这种索引方式,有点像Mysql的非聚簇索引的检索方式。

什么?什么叫Mysql的非聚簇索引?

157509F4.jpg

给我留言,等我。。。。


结语

相信各位爷读完以上内容后就能回答问题的几个答案了:ヾ(●´∀`●)

为了省事,我在把这几个问题搬过来。

1、消息是发送给到RocketMQ 的NameServer 还是Broker?存在NameServer 中还是Broker中?

2、消息是存到哪里了?内存?磁盘?

3、消息的索引 key 是如何加快消息的查找的?

4、我们平时说,消费者从消费队列中消费消息,这个消费队列是内部是如何存储Message的?是存储了完整的Message数据吗?

5、Tag 是如何做到可以过滤消息的?

往期文章回顾

RocketMQ重试机制