MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

探索RocketMQ架构的消息存储机制

2023-11-065.8k 阅读

RocketMQ 消息存储概述

在 RocketMQ 架构中,消息存储机制是保障消息可靠传递和高效处理的关键组件。RocketMQ 的消息存储模块负责将生产者发送的消息持久化到磁盘,并在消费者请求时快速检索和提供这些消息。它设计精良,能够在高并发场景下保证数据的可靠性与读写性能。

RocketMQ 采用基于文件系统的存储方式,主要由 CommitLog、ConsumeQueue 和 IndexFile 等核心部分组成。CommitLog 是消息主体的存储文件,所有主题的消息都顺序写入其中。ConsumeQueue 则类似于消息的索引,记录了消息在 CommitLog 中的物理偏移量、消息大小等信息,以方便消费者快速定位消息。IndexFile 用于支持根据消息的 key 快速查询消息,为某些特定场景提供了高效的检索手段。

CommitLog:消息主体存储

物理结构

CommitLog 文件默认位于 RocketMQ 存储目录下的 store/commitlog 目录中。它以固定大小的文件(默认为 1G)进行存储,当一个文件写满后,会创建新的文件继续写入。这种分段存储的方式便于管理和维护,同时也有利于在系统崩溃或重启时进行恢复操作。

每个 CommitLog 文件都有一个文件名,文件名是该文件第一条消息在整个 CommitLog 中的物理偏移量。例如,文件名 00000000000000000000 表示该文件是第一个 CommitLog 文件,从偏移量 0 开始存储消息。

写入流程

  1. 生产者发送消息:生产者将消息发送到 Broker,Broker 接收到消息后,会将消息写入到 CommitLog 中。
  2. 分配写入位置:Broker 根据当前 CommitLog 文件的剩余空间,决定是否需要切换到新的文件。如果当前文件剩余空间不足,则创建新的 CommitLog 文件,并将新消息写入新文件的起始位置。
  3. 构建消息格式:RocketMQ 将消息按照特定的格式进行序列化。消息格式包含了消息的元数据(如消息长度、消息体长度、Topic 长度等)以及消息体内容。
  4. 写入文件:将序列化后的消息追加写入到 CommitLog 文件的末尾。同时,更新文件的写入位置和相关元数据信息。

以下是一段简化的 Java 代码示例,模拟了消息写入 CommitLog 的逻辑(实际 RocketMQ 代码更为复杂,此处仅作示意):

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;

public class CommitLogWriter {
    private File commitLogFile;
    private FileOutputStream outputStream;
    private long currentOffset;

    public CommitLogWriter(String filePath) throws IOException {
        this.commitLogFile = new File(filePath);
        this.outputStream = new FileOutputStream(commitLogFile, true);
        this.currentOffset = commitLogFile.length();
    }

    public void writeMessage(String topic, String messageBody) throws IOException {
        // 构建消息元数据
        byte[] topicBytes = topic.getBytes();
        byte[] messageBodyBytes = messageBody.getBytes();
        int totalLength = 4 + topicBytes.length + 4 + messageBodyBytes.length; // 4字节topic长度 + topic字节数 + 4字节消息体长度 + 消息体字节数
        ByteBuffer byteBuffer = ByteBuffer.allocate(totalLength);
        byteBuffer.putInt(topicBytes.length);
        byteBuffer.put(topicBytes);
        byteBuffer.putInt(messageBodyBytes.length);
        byteBuffer.put(messageBodyBytes);

        // 写入文件
        outputStream.write(byteBuffer.array());
        currentOffset += totalLength;
    }

    public void close() throws IOException {
        outputStream.close();
    }
}

你可以使用以下方式调用上述代码:

public class Main {
    public static void main(String[] args) {
        try {
            CommitLogWriter writer = new CommitLogWriter("store/commitlog/00000000000000000000");
            writer.writeMessage("testTopic", "Hello, RocketMQ!");
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

读取流程

  1. 消费者请求消息:消费者向 Broker 发送拉取消息的请求。
  2. Broker 定位消息:Broker 根据消费者提供的消费偏移量(通常是在 ConsumeQueue 中的偏移量,通过 ConsumeQueue 可以定位到 CommitLog 中的物理偏移量),找到对应的 CommitLog 文件和文件内的偏移位置。
  3. 读取消息:从 CommitLog 文件中读取指定偏移量开始的消息数据,并按照消息格式进行反序列化,提取出消息的元数据和消息体内容,返回给消费者。

ConsumeQueue:消息索引

物理结构

ConsumeQueue 对应每个 Topic 和 QueueId 都有一个独立的文件目录,位于 store/consumequeue/{topic}/{queueId} 目录下。每个 ConsumeQueue 文件也采用分段存储的方式,默认每 30W 条消息为一个文件,文件大小固定。

每个 ConsumeQueue 文件的命名规则与 CommitLog 类似,文件名是该文件第一条消息在 ConsumeQueue 中的逻辑偏移量。例如,文件名 0000000000 表示该文件是第一个 ConsumeQueue 文件,从偏移量 0 开始存储消息索引。

写入流程

  1. 消息写入 CommitLog 后触发:当消息成功写入 CommitLog 后,RocketMQ 会异步构建 ConsumeQueue 索引数据。
  2. 生成索引信息:计算消息在 CommitLog 中的物理偏移量、消息大小等信息,并按照 ConsumeQueue 的格式进行序列化。ConsumeQueue 中每条索引记录包含 20 字节,分别是 8 字节的 CommitLog 物理偏移量、4 字节的消息长度和 8 字节的消息 Tag 的哈希值(用于快速过滤消息)。
  3. 写入 ConsumeQueue 文件:将序列化后的索引记录追加写入到对应的 ConsumeQueue 文件末尾。同时,更新文件的写入位置和相关元数据信息。

以下是一段简化的 Java 代码示例,模拟了 ConsumeQueue 索引写入的逻辑(实际 RocketMQ 代码更为复杂,此处仅作示意):

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;

public class ConsumeQueueWriter {
    private File consumeQueueFile;
    private FileOutputStream outputStream;
    private long currentOffset;

    public ConsumeQueueWriter(String filePath) throws IOException {
        this.consumeQueueFile = new File(filePath);
        this.outputStream = new FileOutputStream(consumeQueueFile, true);
        this.currentOffset = consumeQueueFile.length();
    }

    public void writeIndex(long commitLogOffset, int messageSize, long tagHash) throws IOException {
        // 构建索引记录
        ByteBuffer byteBuffer = ByteBuffer.allocate(20);
        byteBuffer.putLong(commitLogOffset);
        byteBuffer.putInt(messageSize);
        byteBuffer.putLong(tagHash);

        // 写入文件
        outputStream.write(byteBuffer.array());
        currentOffset += 20;
    }

    public void close() throws IOException {
        outputStream.close();
    }
}

调用示例如下:

public class Main {
    public static void main(String[] args) {
        try {
            ConsumeQueueWriter writer = new ConsumeQueueWriter("store/consumequeue/testTopic/0/0000000000");
            writer.writeIndex(100, 1000, 123456789L);
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

读取流程

  1. 消费者请求消息:消费者根据自己的消费进度,向 Broker 发送拉取消息请求,请求中包含了在 ConsumeQueue 中的偏移量。
  2. Broker 定位索引:Broker 根据请求中的偏移量,找到对应的 ConsumeQueue 文件和文件内的偏移位置,读取索引记录。
  3. 获取消息位置:从索引记录中提取出消息在 CommitLog 中的物理偏移量和消息大小等信息。
  4. 读取消息:根据物理偏移量从 CommitLog 中读取消息数据,反序列化后返回给消费者。

IndexFile:基于 Key 的消息索引

物理结构

IndexFile 用于支持根据消息的 Key 快速查询消息,位于 store/index 目录下。每个 IndexFile 也采用固定大小(默认 40M)的文件进行存储,文件名是该文件创建时的时间戳。

IndexFile 内部包含了一个哈希表结构,用于快速定位消息的索引位置。哈希表的桶数组长度固定为 500W,每个桶存储一个指向索引项的指针。

写入流程

  1. 消息写入 CommitLog 后触发:与 ConsumeQueue 类似,当消息成功写入 CommitLog 后,若消息包含 Key,则异步构建 IndexFile 索引数据。
  2. 生成索引信息:计算消息的 Key 的哈希值,根据哈希值确定在哈希表中的桶位置。创建索引项,索引项包含了消息的 Key 的哈希值、消息在 CommitLog 中的物理偏移量、下一个具有相同 Key 哈希值的消息的索引项位置等信息。
  3. 写入 IndexFile:将索引项追加写入到 IndexFile 文件末尾,并更新哈希表中对应桶的指针,使其指向最新的索引项。同时,更新文件的写入位置和相关元数据信息。

以下是一段简化的 Java 代码示例,模拟了 IndexFile 索引写入的逻辑(实际 RocketMQ 代码更为复杂,此处仅作示意):

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;

public class IndexFileWriter {
    private File indexFile;
    private FileOutputStream outputStream;
    private long currentOffset;
    private int[] hashTable;

    public IndexFileWriter(String filePath) throws IOException {
        this.indexFile = new File(filePath);
        this.outputStream = new FileOutputStream(indexFile, true);
        this.currentOffset = indexFile.length();
        this.hashTable = new int[5000000];
    }

    public void writeIndex(String key, long commitLogOffset) throws IOException {
        int hash = key.hashCode() & 0x7FFFFFFF;
        int bucketIndex = hash % hashTable.length;
        int prevIndex = hashTable[bucketIndex];

        // 构建索引项
        ByteBuffer byteBuffer = ByteBuffer.allocate(20);
        byteBuffer.putInt(hash);
        byteBuffer.putLong(commitLogOffset);
        byteBuffer.putInt(prevIndex);

        // 写入文件
        outputStream.write(byteBuffer.array());
        hashTable[bucketIndex] = (int) currentOffset;
        currentOffset += 20;
    }

    public void close() throws IOException {
        outputStream.close();
    }
}

调用示例如下:

public class Main {
    public static void main(String[] args) {
        try {
            IndexFileWriter writer = new IndexFileWriter("store/index/1609459200000.index");
            writer.writeIndex("testKey", 100);
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

读取流程

  1. 消费者请求查询:消费者向 Broker 发送根据 Key 查询消息的请求。
  2. Broker 定位索引:Broker 根据请求中的 Key 计算哈希值,确定在 IndexFile 哈希表中的桶位置。从桶位置获取索引项指针,读取索引项。
  3. 遍历索引项:根据索引项中的信息,如哈希值匹配则获取消息在 CommitLog 中的物理偏移量。若哈希值不匹配,则根据索引项中的下一个索引项位置,继续读取下一个索引项,直到找到匹配的索引项或遍历完所有相关索引项。
  4. 读取消息:根据物理偏移量从 CommitLog 中读取消息数据,反序列化后返回给消费者。

刷盘机制

RocketMQ 的刷盘机制决定了消息何时从内存刷写到磁盘,以保证消息的可靠性。刷盘方式主要分为同步刷盘和异步刷盘。

同步刷盘

同步刷盘是指在消息写入 CommitLog 后,Broker 会等待消息成功刷写到磁盘后才返回成功响应给生产者。这种方式可以确保消息在 Broker 崩溃时不会丢失,但会降低系统的写入性能。

在 RocketMQ 配置文件 broker.conf 中,可以通过设置 flushDiskType = SYNC_FLUSH 来启用同步刷盘。

异步刷盘

异步刷盘是指消息写入 CommitLog 后,Broker 立即返回成功响应给生产者,然后由后台线程将内存中的数据异步刷写到磁盘。这种方式可以提高系统的写入性能,但在 Broker 崩溃时可能会丢失少量未刷盘的消息。

broker.conf 中,可以通过设置 flushDiskType = ASYNC_FLUSH 来启用异步刷盘。同时,可以通过 flushIntervalCommitLog 参数设置刷盘的时间间隔(单位为毫秒),通过 commitLogCommitLeastPages 参数设置刷盘的最小页数,以平衡性能和可靠性。

高可用性与数据恢复

主从架构

RocketMQ 采用主从架构来保证高可用性。每个 Broker 可以配置为 Master 或 Slave 角色。Master 负责接收生产者发送的消息,并将消息同步给 Slave。Slave 主要用于在 Master 出现故障时接管服务,确保消息的持续可用性。

当 Master 发生故障时,系统可以通过手动或自动切换的方式将 Slave 提升为 Master。在切换过程中,RocketMQ 会尽量保证已存储消息的完整性和一致性。

数据恢复

在系统重启或故障恢复时,RocketMQ 会根据 CommitLog、ConsumeQueue 和 IndexFile 等文件进行数据恢复。

  1. CommitLog 恢复:RocketMQ 会从最后一个正常关闭的 CommitLog 文件开始,重新构建内存中的消息存储结构。通过读取文件中的消息数据,反序列化并重新组织,恢复到故障前的状态。
  2. ConsumeQueue 恢复:根据 ConsumeQueue 文件中的索引信息,重新建立消息索引,以便消费者能够快速定位消息。
  3. IndexFile 恢复:同样,IndexFile 也会被重新加载,恢复基于 Key 的消息索引功能,确保系统能够正常提供根据 Key 查询消息的服务。

总结

RocketMQ 的消息存储机制通过 CommitLog、ConsumeQueue 和 IndexFile 等组件的协同工作,实现了高效、可靠的消息存储与检索。刷盘机制和高可用性设计进一步保障了消息的可靠性和系统的稳定性。深入理解这些机制,对于优化 RocketMQ 的性能、确保消息的可靠传递以及在复杂场景下的应用开发都具有重要意义。在实际应用中,开发人员可以根据业务需求合理配置 RocketMQ 的存储参数和刷盘策略,以达到性能与可靠性的最佳平衡。同时,RocketMQ 不断演进和优化其存储机制,以适应日益增长的大数据和高并发场景的需求。

希望通过以上对 RocketMQ 消息存储机制的深入探索,能够帮助读者更好地理解和应用这一强大的消息队列系统,在后端开发中构建更加健壮和高效的消息处理架构。在实际使用过程中,开发人员还需要结合具体业务场景,对 RocketMQ 的存储配置、读写性能优化等方面进行深入研究和实践,以充分发挥其优势。

以上代码示例旨在帮助理解相关概念,实际应用中需考虑更多细节,如错误处理、并发控制等。同时,RocketMQ 内部实现涉及到复杂的多线程、网络通信等技术,感兴趣的读者可以深入研究其源代码以获取更全面的知识。