【RocketMQ】RocketMq之CommitLog深入研究

目录

一:RocketMq 整体文件存储介绍

二:CommitLog 的文件结构

三:CommitLog 写入和查询流程

四: ⽂件同步刷盘与异步刷盘


一:RocketMq 整体文件存储介绍

存储⽂件主要分为三个部分:

    CommitLog:存储消息的元数据。所有消息都会顺序存⼊到CommitLog⽂件当中。CommitLog由多个⽂件组成,每个⽂件固定⼤⼩1G。以第⼀条消 息的偏移量为⽂件名。
    ConsumerQueue:存储消息在CommitLog的索引。⼀个MessageQueue⼀个⽂件,记录当前MessageQueue被哪些消费者组消费到了哪⼀条CommitLog。
    IndexFile:为了消息查询提供了⼀种通过key或时间区间来查询消息的⽅法,这种通过IndexFile来查找消息的⽅法不影响发送与消费消息的主流程。

这篇文章主要介绍CommitLog的研究,以rocketmq5.3.0版本作为研究。

二:CommitLog 的文件结构

整体的消息存储结构,如下图:

 CommitLog 的文件格式:

 

CommitLog 消息条目格式说明:

消息按以下字段顺序写入字节缓冲区(byteBuf),最终持久化到 CommitLog 文件:

字段名长度(字节)说明
TOTAL_SIZE4消息总长度(从 MAGIC_CODECRC32 的总字节数)
MAGIC_CODE4魔数(标识消息版本,例如 0xAABBCCDD 或更高版本)
BODY_CRC4消息体的 CRC32 校验码,用于校验消息体完整性
QUEUE_ID4消息所属的队列 ID(Queue ID),用于逻辑队列分片
FLAG4消息标志位(用户自定义,用于业务标记)
QUEUEOFFSET8消息在消费队列(ConsumeQueue)中的逻辑偏移量
PHYSICAL_OFFSET8消息在 CommitLog 中的物理偏移量(初始为 0,持久化后更新为实际值)
SYS_FLAG4系统标志位(表示消息特性,如压缩、事务、批量等)
BORN_TIMESTAMP8消息生产时间戳(Producer 生成消息的时间)
BORNHOST8生产者地址(4 字节 IP + 4 字节 Port,如 192.168.1.1:8888
STORE_TIMESTAMP8消息存储到 Broker 的时间戳
STOREHOSTADDRESS8Broker 地址(4 字节 IP + 4 字节 Port,如 10.0.0.1:10911
RECONSUMETIMES4消息重试消费次数(正常消息为 0,重试消息递增)
PREPARED_TRANSACTION_OFFSET8事务消息的预提交偏移量(非事务消息为 0)
BODY变长消息内容(实际数据或压缩后的数据)
TOPIC变长Topic 名称(长度由 topicLength 决定,支持短或长格式)
PROPERTIES变长消息属性(键值对,格式为 key1=value1\0key2=value2...
CRC324校验码(覆盖从 MAGIC_CODEPROPERTIES 的所有字段)

关键字段说明

1. MAGIC_CODE(魔数)

  • 作用:标识消息格式版本,不同版本可能扩展字段或调整编码方式。

  • 5.3.0 版本

    • MessageVersion.V1:魔数为 0xAABBCCDD

    • MessageVersion.V2:魔数为 0xAABBCCDE(可能支持更长的 Topic 或属性)。

2. QUEUEOFFSET 与 PHYSICAL_OFFSET

  • QUEUEOFFSET:消息在 ConsumeQueue 中的逻辑偏移量(由 Broker 分配),用于消费队列的索引构建。

  • PHYSICAL_OFFSET:消息在 CommitLog 中的物理偏移量,初始写入时为 0,持久化到磁盘后更新为实际值(全局唯一)。

3. SYS_FLAG(系统标志位)

  • 位运算标识消息特性

    int sysFlag = message.getSysFlag();
    // 示例:
    // 第 0 位:是否压缩(1 << 0)
    // 第 1 位:是否为批量消息(1 << 1)
    // 第 2 位:是否为事务消息(1 << 2)
    // 第 3 位:是否为重试消息(1 << 3)

4. TOPIC 格式

  • 版本差异

    • V1:Topic 长度用 1 字节 表示(最大 255 字符)。

    • V2:Topic 长度用 2 字节 表示(最大 65535 字符)。

    if (MessageVersion.MESSAGE_VERSION_V2.equals(msgInner.getVersion())) {
        this.byteBuf.writeShort((short) topicLength); // 2 字节
    } else {
        this.byteBuf.writeByte((byte) topicLength);   // 1 字节
    }

5. PROPERTIES(属性)

  • 编码规则:键值对以 \0 分隔,例如 TAGS=TagA\0KEYS=Order_123

  • 长度限制:属性总长度由 propertiesLength(2 字节)指定,最大 32767 字节。

6. CRC32 校验

  • 计算范围:从 MAGIC_CODEPROPERTIES 的所有字段(不含 TOTAL_SIZECRC32 自身)。

  • 写入方式:先预留 4 字节空间,最后回填计算结果:

// 预留 CRC32 位置
this.byteBuf.writerIndex(this.byteBuf.writerIndex() + crc32ReservedLength);
// 后续计算并回填

三:CommitLog 写入和查询流程

1. CommitLog 写入流程图

+---------------------+
| Producer 发送消息     |
+---------------------+
          |
          v
+---------------------+
| Broker 接收消息       |
| - 校验消息合法性        |
|   (Topic 是否存在等)   |
+---------------------+
          |
          v
+---------------------+
| 分配全局物理偏移量      |
| (PHYSICAL_OFFSET)     |
+---------------------+
          |
          v
+---------------------+  
| 顺序写入 CommitLog 文件 |
| - 若文件满(默认1GB)    |
|   滚动创建新文件        |
+---------------------+
          |
          v
+---------------------+
| 同步更新索引文件        |
| 1. ConsumeQueue      | → 记录逻辑偏移量 → 物理偏移量映射
| 2. IndexFile         | → 记录 Key/时间 → 物理偏移量映射
+---------------------+
          |
          v
+---------------------+
| 返回写入成功响应        |
| (包含消息ID)          |
+---------------------+

 CommitLog 文件初始化和替换:

  • 编码:org.apache.rocketmq.store.MessageExtEncoder#encode(MessageExtBrokerInner)

  • 替换:org.apache.rocketmq.store.CommitLog.DefaultAppendMessageCallback#doAppend(long, java.nio.ByteBuffer, int, org.apache.rocketmq.common.message.MessageExtBrokerInner, org.apache.rocketmq.store.PutMessageContext)

QUEUEOFFSET 字段获取和累加:

  • 获取: org.apache.rocketmq.store.DefaultMessageStore#assignOffset
  • 累加:org.apache.rocketmq.store.DefaultMessageStore#increaseOffset
public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
         ........
         ........
        //锁对列
        topicQueueLock.lock(topicQueueKey);
        try {

            boolean needAssignOffset = true;
            if (defaultMessageStore.getMessageStoreConfig().isDuplicationEnable()
                && defaultMessageStore.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE) {
                needAssignOffset = false;
            }
            if (needAssignOffset) {
                //QUEUEOFFSET 获取
                defaultMessageStore.assignOffset(msg);
            }
        .......
        .......
         // Increase queue offset when messages are successfully written
         // QUEUEOFFSET 累加   
         if (AppendMessageStatus.PUT_OK.equals(result.getStatus())) {
            this.defaultMessageStore.increaseOffset(msg, getMessageNum(msg));
         }
        .......
        .......

旧 CommitLog 文件的清理

RocketMQ 通过 后台线程 定期清理过期的 CommitLog 文件,释放磁盘空间。清理策略由以下参数控制:

(1) 消息保留时间

  • 参数fileReservedTime(默认 72 小时)。

  • 规则:删除早于当前时间减去保留时间的 CommitLog 文件。

  • 示例
    若当前时间为 2023-10-01 12:00:00,保留时间为 72 小时,则删除所有在 2023-09-28 12:00:00 之前写入的 CommitLog 文件。

(2) 磁盘水位控制

  • 参数diskMaxUsedSpaceRatio(默认 85%)。

  • 规则:若磁盘使用率超过阈值,强制删除最旧的 CommitLog 文件,直至低于阈值。

(3) 手动清理

  • 命令:通过 RocketMQ 提供的 cleanUnusedTopic 命令或直接删除物理文件(需谨慎操作)。

文件的清理代码⼊⼝: DefaultMessageStore.addScheduleTask -> DefaultMessageStore.this.cleanFilesPeriodically() 和 

DefaultMessageStore.this.cleanQueueFilesPeriodically()

在这个⽅法中会启动两个线程,cleanCommitLogService⽤来删除过期的CommitLog⽂件,cleanConsumeQueueService⽤来删除过期的ConsumeQueue和IndexFile⽂件。

在删除CommitLog⽂件时,Broker会启动后台线程,每60秒,检查CommitLog、ConsumeQueue⽂件。然后对超过72⼩时的数据进⾏删除。也就是说,默认 情况下, RocketMQ只会保存3天内的数据。这个时间可以通过fileReservedTime来配置。

触发过期⽂件删除时,有两个检查的纬度,⼀个是,是否到了触发删除的时间,也就是broker.conf⾥配置的deleteWhen属性。另外还会检查磁盘利⽤率,达到阈值也会触发过期⽂件删除。这个阈值默认是72%,可以在broker.conf⽂件当中定制。但是最⼤值为95,最⼩值为10。

然后在删除ConsumeQueue和IndexFile⽂件时,会去检查CommitLog当前的最⼩Offset,然后在删除时进⾏对⻬。

需要注意的是,RocketMQ在删除过期CommitLog⽂件时,并不检查消息是否被消费过。 所以如果有消息⻓期没有被消费,是有可能直接被删除掉,造成消息丢失的。

RocketMQ整个⽂件管理的核⼼⼊⼝在DefaultMessageStore的start⽅法中,整体流程总结如下:

2. CommitLog 查询流程图 

+---------------------+
| Consumer 拉取消息     |
| - Topic, QueueId     |
| - 起始逻辑偏移量       |
+---------------------+
          |
          v
+---------------------+
| 查询 ConsumeQueue     |
| - 根据逻辑偏移量计算位置 |
|   (文件序号 = 偏移量 / 30万) |
+---------------------+
          |
          v
+---------------------+
| 读取 ConsumeQueue 条目 |
| - 获取物理偏移量       |
| - 消息大小            |
| - Tag 哈希值          |
+---------------------+
          |
          v
+---------------------+
| 根据物理偏移量定位 CommitLog |
| - 计算文件位置          |
|   (文件序号 = 物理偏移量 / 1GB) |
+---------------------+
          |
          v
+---------------------+
| 从 CommitLog 读取消息体  |
| - 校验 CRC32          |
| - 解析消息属性          |
+---------------------+
          |
          v
+---------------------+
| 返回消息给 Consumer     |
+---------------------+

关键步骤说明:

  1. 消费者拉取请求

    • 指定 Topic、QueueId 和起始逻辑偏移量(如 QUEUEOFFSET=1000)。

  2. ConsumeQueue 查询

    • 根据逻辑偏移量计算 ConsumeQueue 文件位置(如 文件序号 = 1000 / 300,000)。

    • 读取条目,获取物理偏移量(如 PHYSICAL_OFFSET=1024)、消息大小和 Tag 哈希值。

  3. CommitLog 定位

    • 根据物理偏移量找到对应的 CommitLog 文件(如 00000000000000000000)。

  4. 读取消息内容

    • 从 CommitLog 文件读取消息体,校验 CRC32 和完整性。

    • 解析消息属性(如 Key、Tag、生产时间等)。

  5. 返回消息

    • 将消息内容封装后返回给消费者。


关键设计优势

  1. 写入高性能

    • 顺序写:所有消息追加到 CommitLog 末尾,避免随机 I/O。

    • 内存映射(MMAP):通过 MappedByteBuffer 加速文件写入。

    • 批量刷盘:异步或同步刷盘策略平衡性能与可靠性。

  2. 查询高效性

    • 逻辑偏移量映射:ConsumeQueue 提供 O(1) 时间复杂度的物理偏移量定位。

    • 索引分离:ConsumeQueue 和 IndexFile 独立于 CommitLog,避免读写竞争。

  3. 故障恢复

    • 文件滚动:单个文件大小可控,损坏时仅影响部分数据。

    • Reput 机制:通过 reputFromOffset 重新构建索引,保障一致性。

四: ⽂件同步刷盘与异步刷盘
       RocketMQ实现了⾮常简单⽅便的刷盘⽅式配置。 在broker配置⽂件⾥,flushDiskType参数就可以直接配置刷盘⽅式。这个参数有两个选项SYNC_FLUSH就是同步刷盘,ASYNC_FLUSH 。 
        关于同步刷盘和异步刷盘的区别,简单理解就是Broker写⼊消息时是不是⽴即进⾏刷盘。来⼀条消息就进⾏⼀次刷盘,这就是同步刷盘,这样数据安全性更 
⾼。过⼀点时间进⾏⼀次刷盘,这就是异步刷盘,这样操作系统的IO执⾏效率更⾼。那么事实到底是什么样的呢?最好的⽅法当然是到源码中验证⼀下。
⼊⼝:CommitLog.handleDiskFlush
org.apache.rocketmq.store.CommitLog下的GroupCommitService线程的run⽅法 
这⾥涉及到了对于同步刷盘与异步刷盘的不同处理机制。这⾥有很多极致提⾼性能的设计,对于我们理解和设计⾼并发应⽤场景有⾮常⼤的借鉴意义。
        @Override
        public CompletableFuture<PutMessageStatus> handleDiskFlush(AppendMessageResult result, MessageExt messageExt) {
            // Synchronization flush 同步刷盘
            if (FlushDiskType.SYNC_FLUSH == CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
                final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
                if (messageExt.isWaitStoreMsgOK()) {//构建request的时候从配置⽂件中读取了刷盘超时时间,默认5秒。
                    GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                            CommitLog.this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                    flushDiskWatcher.add(request);//这⾥只是监控刷盘是否超时。
                    service.putRequest(request);//实际进⾏刷盘,刷盘操作先排队,再执⾏。
                    return request.future();
                } else {
                    service.wakeup();
                    return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
                }
            }
            // Asynchronous flush
            else {
                if (!CommitLog.this.defaultMessageStore.isTransientStorePoolEnable()) {//默认false
                    flushCommitLogService.wakeup();
                } else {
                    commitRealTimeService.wakeup();
                }
                return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
            }
        }
先来看同步刷盘
同步刷盘通过GroupCommitService完成,同步刷盘的流程:
这种读写对列双缓存的设计,可以有效的提⾼⾼并发场景下的数据⼀致性问题。
之前在介绍同步刷盘和异步刷盘时,会简单的说同步刷盘就是来⼀条消息进⾏⼀次刷盘。但是从源码中分析出来的却并不是这么简单。因为你要知道刷盘对操作系统来说是⼀个很重的操作。过于频繁的调⽤刷盘操作,会给操作系统带来很⼤的IO负担。这⾥也需要思考两个问题:
  1. 传统⼋股⽂会说同步刷盘可以保证消息安全。因为消息尽快写到了磁盘当中,断电就不会丢失。但是,实际情况是,RocketMQ的同步刷盘在后台任务中同 样是要休眠的,意味着,消息写⼊PageCache缓存再到写⼊磁盘,这中间依然是会有时间差的。这意味着同样会有断电丢失的可能。那为什么普遍都认为配置同步刷盘就可以保证消息安全呢?
  2. 从RocketMQ中可以看到,对于刷盘操作,并不是简单的想怎么调⽤就怎么调⽤。当调⽤刷盘操作过于频繁时,是需要进⾏优化的。那么,是不是可以回顾下Kafka中的刷盘频率是怎么配置的?刷盘间隔时间log.flush.interval.ms可以设置成1吗?
然后看异步刷盘
默认情况下,是使⽤CommitRealTimeService线程来进⾏刷盘。
this.commitRealTimeService = new CommitLog.CommitRealTimeService(); 
这个异步刷盘的过程就相对简单⼀些。就是休眠⼀段时间,⼲⼀次活。休眠间隔由配置⽂件指定。
核⼼流程,简短出如下⼏个步骤: 
//获取配置的刷盘间隔时间。默认200毫秒
int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitIntervalCommitLog();
try{
    //主要是提交commitWhere参数
    boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
    //waitForRunning会阻塞线程,wakeup后会继续执⾏线程
    CommitLog.this.flushManager.wakeUpFlush(); 
    //休眠⼀次间隔时间
    this.waitForRunning(interval);
} catch(){

}
//提交失败,重试,最多⼗次。
boolean result = false;
for (int i = 0; i < RETRY_TIMES_OVER && !result; i++) {
    result = CommitLog.this.mappedFileQueue.commit(0);
    CommitLog.log.info(this.getServiceName() + " service shutdown, retry " + (i + 1) + " times " + (result ? "OK" : "Not OK"));
} 
这⾥⽐较好玩的是RocketMQ对于后台线程的并发控制。有兴趣可以关注⼀下。RocketMQ提供了⼀个⾃⼰实现的CountDownLatch2⼯具类来提供线程阻塞功能,使⽤CAS驱动CountDownLatch2的countDown操作。每来⼀个消息就启动⼀次CAS,成功后,调⽤⼀次countDown。⽽这个CountDonwLatch2在 Java.util.concurrent.CountDownLatch的基础上,实现了reset功能,这样可以进⾏对象重⽤。如果你对JUC并发编程感兴趣,那么这也是⼀个不错的学习 点。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

gorgor在码农

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值