概要设计

RocketMQ 的存储文件,放在 ${ROCKET_HOME}/store 目录下

image.png

  • Commitlog:消息存储文件,所有消息主题的消息都存储在 Commitlog 文件中
  • ConsumeQueue:消息消费队列,消息到达 Commitlog 文件后,将异步转发到消息队列,供消息消费者消费
  • IndexFile:消息索引文件,主要存储消息 key 与 offset 对应关系

核心消息存储类:DefaultMessageStore

// 消息存储配置属性
private final MessageStoreConfig messageStoreConfig;

// CommitLog 文件的存储实现类
private final CommitLog commitLog;

// 消息队列存储缓存表
private final ConcurrentMap<String/* topic */, ConcurrentMap<Integer/* queueId */, ConsumeQueue>> consumeQueueTable;

// 消息队列文件 ComsumeQueue 刷盘线程
private final FlushConsumeQueueService flushConsumeQueueService;

// 清除 CommitLog 文件服务
private final CleanCommitLogService cleanCommitLogService;

// 清除 ConsumeQueue 文件服务
private final CleanConsumeQueueService cleanConsumeQueueService;

// 索引文件实现类
private final IndexService indexService;

// MappedFile 分配服务
private final AllocateMappedFileService allocateMappedFileService;

// CommitLog 消息分发,根据 CommitLog 文件构建 ConsumeQueue、IndexFile 文件
private final ReputMessageService reputMessageService;

// 存储 HA 机制
private final HAService haService;

// 消息堆内存缓存
private final TransientStorePool transientStorePool;

// 消息拉取长轮询模式消息达到监听器
private final MessageArrivingListener messageArrivingListener;

// broker 配置属性
private final BrokerConfig brokerConfig;

// 文件刷盘检测点
private StoreCheckpoint storeCheckpoint;

// CommitLog 文件转发请求
private final LinkedList<CommitLogDispatcher> dispatcherList;

消息发送存储流程

代码入口: DefaultMessageStore#putMessage

  1. 对消息的一些检查,如 当前如果是 SLAVE 则不写入。
  2. 如果当前延迟等级大于 0,那么会 将该消息放入 SCHEDULE_TOPIC_XXXX 主题中,队列为 延迟等级 - 1

image.png

CommitLog#putMessage

// 延迟等级大于 0
if (msg.getDelayTimeLevel() > 0) {
  if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
  }

  topic = ScheduleMessageService.SCHEDULE_TOPIC;
  
  // 这个方法,将 延迟等级 - 1
  queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

  // Backup real topic, queueId
  MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
  MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
  msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

  msg.setTopic(topic);
  msg.setQueueId(queueId);
}
}
  1. 每个 commitlog 大小为 1G,用第一个偏移量作为文件名;MappedFileQueue 可以看做是 ${ROCKET_HOME}/store/commitlog 目录,MappedFile 则是 文件;在写 commitlog 之前,会写获取锁,并追加写文件。

image.png

  1. 创建全局唯一消息 ID,消息有 16 个字节

image.png

  1. 计算消息长度

代码入口:CommitLog#calMsgLength

protected static int calMsgLength(int sysFlag, int bodyLength, int topicLength, int propertiesLength) {
        int bornhostLength = (sysFlag & MessageSysFlag.BORNHOST_V6_FLAG) == 0 ? 8 : 20;
        int storehostAddressLength = (sysFlag & MessageSysFlag.STOREHOSTADDRESS_V6_FLAG) == 0 ? 8 : 20;
        final int msgLen = 4 //TOTALSIZE
            + 4 //MAGICCODE
            + 4 //BODYCRC
            + 4 //QUEUEID
            + 4 //FLAG
            + 8 //QUEUEOFFSET
            + 8 //PHYSICALOFFSET
            + 4 //SYSFLAG
            + 8 //BORNTIMESTAMP
            + bornhostLength //BORNHOST
            + 8 //STORETIMESTAMP
            + storehostAddressLength //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. 计算长度,是为了根据 commitlog 当前的空闲空间,判断是否要重新创建。 如果 消息长度 + 8 大于 commitlog 空闲空间,则重新创建新的 CommitLog。高4节存储当前文件剩余空间,低4字节存储魔数
  2. 将消息写到 ByteBuffer 中,创建 AppendMessageResult 。只是将消息存储到 MappedFile 映射的 ByteBuffer 中,没有刷盘。

DefaultAppendMessageCallback#doAppend

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

AppendMessageResult result = new AppendMessageResult(AppendMessageStatus.PUT_OK, wroteOffset, msgLen, msgId, msgInner.getStoreTimestamp(), queueOffset, CommitLog.this.defaultMessageStore.now() - beginTimeMills);
  1. 根据刷盘策略,进行同步刷盘或者异步刷盘,序列化消息到磁盘。执行 HA 主从复制

CommitLog#putMessage

handleDiskFlush(result, putMessageResult, msg);
handleHA(result, putMessageResult, msg);

存储文件组织与内存映射

MappedFileQueue 映射文件队列

// 存储目录
private final String storePath;

// 单个文件的存储大小
private final int mappedFileSize;

// MappedFile 文件集合
private final CopyOnWriteArrayList<MappedFile> mappedFiles = new CopyOnWriteArrayList<MappedFile>();

// 创建 MappedFile 服务类
private final AllocateMappedFileService allocateMappedFileService;

// 当前刷盘指针,指针之前数据已完成刷盘
private long flushedWhere = 0;

// 当前数据提交指针,内存中 ByteBuffer 当前的写指针,该值大于等于 flushedWhere
private long committedWhere = 0;
根据 时间戳 查找 MappedFile
public MappedFile getMappedFileByTime(final long timestamp) {
        Object[] mfs = this.copyMappedFiles(0);

        if (null == mfs)
            return null;

        for (int i = 0; i < mfs.length; i++) {
            MappedFile mappedFile = (MappedFile) mfs[i];
            if (mappedFile.getLastModifiedTimestamp() >= timestamp) {
                return mappedFile;
            }
        }

        return (MappedFile) mfs[mfs.length - 1];
    }

流程:从 MappedFile 列表的第一个开始查找,找到第一个最后一次更新时间大于 timestamp 的文件,不存在,则返回最后一个文件。

根据 offset 查找 MappedFile
public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound) {
        try {
            MappedFile firstMappedFile = this.getFirstMappedFile();
            MappedFile lastMappedFile = this.getLastMappedFile();
            if (firstMappedFile != null && lastMappedFile != null) {
               ...
               ...  
                 // 因为 第一个 MappedFile 偏移量可能不是 0 (文件定期删除机制导致)。因此采用此算法。
                    int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
                    MappedFile targetFile = null;
                    try {
                        targetFile = this.mappedFiles.get(index);
                    } catch (Exception ignored) {
                    }              
                          // 找到的文件合法,直接返回
                    if (targetFile != null && offset >= targetFile.getFileFromOffset()
                        && offset < targetFile.getFileFromOffset() + this.mappedFileSize) {
                        return targetFile;
                    }
                                      
                    // 遍历所有 MappedFiles,如果 offset 在 MappedFile 的 起始区间,则返回
                    for (MappedFile tmpMappedFile : this.mappedFiles) {
                        if (offset >= tmpMappedFile.getFileFromOffset()
                            && offset < tmpMappedFile.getFileFromOffset() + this.mappedFileSize) {
                            return tmpMappedFile;
                        }
                    }
                }
                    
                // 未找到 直接返回第一个文件
                if (returnFirstOnNotFound) {
                    return firstMappedFile;
                }
            }
        } catch (Exception e) {
            log.error("findMappedFileByOffset Exception", e);
        }

        return null;
    }
  • 根据算法

    int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));

    查找 MappedFile。

  • 如果 index 查找的 MappedFile 无效。则遍历所有的 MappedFile,如果 offset 落在 MappedFile 的起始区间内。则直接返回
  • 如果还是找不到,返回第一个文件

MappedFile 内存映射文件

// 操作系统页大小
public static final int OS_PAGE_SIZE = 1024 * 4;

// 当前 JVM 实例中 MappedFile 虚拟内存
private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);

// 当前 JVM 实例中 MappedFile 个数
private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);

// 当前该文件写指针,从 0 开始(内存映射中的写指针)
protected final AtomicInteger wrotePosition = new AtomicInteger(0);

// 当前文件的提交指针,如果开启 transientStorePoolEnable, 则数据会存储在 TransientStorePool 中,然后提交到内存映射 ByteBuffer 中,再刷写到磁盘
protected final AtomicInteger committedPosition = new AtomicInteger(0);

// 刷写到磁盘指针,该指针之前的数据持久化到磁盘中
private final AtomicInteger flushedPosition = new AtomicInteger(0);

// 文件大小
protected int fileSize;

// 文件通道
protected FileChannel fileChannel;

// 堆内存 ByteBuffer,如果不为空,数据首先将存储在该 buffer,然后提交到 MappedFile 对应的内存映射文件 Buffer。transientStorePoolEnable 为 true 时不为空。 
protected ByteBuffer writeBuffer = null;

// 堆内存池,transientStorePoolEnable 为 true 时启动。
protected TransientStorePool transientStorePool = null;

// 文件名称
private String fileName;

// 该文件的初始偏移量
private long fileFromOffset;

// 物理文件
private File file;

// 物理文件对应的内存映射 Buffer
private MappedByteBuffer mappedByteBuffer;

// 文件最后一次写入时间
private volatile long storeTimestamp = 0;

// 是否是 MappedFileQueue 中第一个文件
private boolean firstCreateInQueue = false;
MappedFile 初始化

根据是否开启 transientStorePoolEnable 存在两种初始化情况。 transientStorePoolEnable 为 true 表示内存先存储在堆外内存,然后通过 Commit 线程将数据提交到内存映射 MappedByteBuffer 中,再通过 Flush 线程将内存映射 Buffer 中的数据持久化到磁盘中。

MappedFile#init 逻辑简单,不贴代码了。创建文件,将文件内容映射到 Buffer。

如果 transientStorePoolEnable 为 true,则初始化 MappedFile 的 writeBuffer。

MappedFile 提交

MappedFile#commit

public int commit(final int commitLeastPages) {
        // transientStorePoolEnable= true,该值不为null
        if (writeBuffer == null) {
            return this.wrotePosition.get();
        }
        // 是否可以被提交
        if (this.isAbleToCommit(commitLeastPages)) {
            if (this.hold()) {
                commit0(commitLeastPages);
                this.release();
            } else {
                log.warn("in commit, hold failed, commit offset = " + this.committedPosition.get());
            }
        }

        // All dirty data has been committed to FileChannel.
        if (writeBuffer != null && this.transientStorePool != null && this.fileSize == this.committedPosition.get()) {
            this.transientStorePool.returnBuffer(writeBuffer);
            this.writeBuffer = null;
        }

        return this.committedPosition.get();
}

// 执行提交逻辑,所以,其实所谓的提交,只是设置了 committedPosition 的位置,因为 MappedFile 还有个刷盘机制
protected void commit0(final int commitLeastPages) {
        int writePos = this.wrotePosition.get();
        int lastCommittedPosition = this.committedPosition.get();

        if (writePos - this.committedPosition.get() > 0) {
            try {
                // 共享数据,但是各自维护一套 position,mark,limit
                ByteBuffer byteBuffer = writeBuffer.slice();
                byteBuffer.position(lastCommittedPosition);
                byteBuffer.limit(writePos);
                this.fileChannel.position(lastCommittedPosition);
                this.fileChannel.write(byteBuffer);
                this.committedPosition.set(writePos);
            } catch (Throwable e) {
                log.error("Error occurred when commit data to FileChannel.", e);
            }
        }
}
MappedFile 刷盘

刷盘指将内存中的数据刷写到磁盘。这里有专门的刷盘线程在处理刷盘, 具体可查看 FlushCommitLogService,异步刷盘时,真正调用刷盘的类是:CommitRealTimeService,同步刷盘则由 GroupCommitService

MappedFile#flush

public int flush(final int flushLeastPages) {
  if (this.isAbleToFlush(flushLeastPages)) {
    if (this.hold()) {
      int value = getReadPosition();

      try {
        //We only append data to fileChannel or mappedByteBuffer, never both.
        if (writeBuffer != null || this.fileChannel.position() != 0) {
          // 刷盘
          this.fileChannel.force(false);
        } else {
          // 刷盘
          this.mappedByteBuffer.force();
        }
      } catch (Throwable e) {
        log.error("Error occurred when force data to disk.", e);
      }

      this.flushedPosition.set(value);
      this.release();
    } else {
      log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
      this.flushedPosition.set(getReadPosition());
    }
  }
  return this.getFlushedPosition();
}
MappedFile 销毁

MappedFile#destroy

public void shutdown(final long intervalForcibly) {
  // 第一次调用一定是 true
  if (this.available) {
    this.available = false;
    this.firstShutdownTimestamp = System.currentTimeMillis();
    // 尝试释放资源
    this.release();
  } else if (this.getRefCount() > 0) {
    //  RocketMQ 有 最大拒绝存活时间概念。只要超过了,会对资源的 引用 -100。再尝试释放资源
    if ((System.currentTimeMillis() - this.firstShutdownTimestamp) >= intervalForcibly) {
      this.refCount.set(-1000 - this.getRefCount());
      this.release();
    }
  }
}

// 资源释放
public void release() {
  long value = this.refCount.decrementAndGet();
  if (value > 0)
    return;
  synchronized (this) {
    // 将 cleanupOver 设置为 true, 判断是否清理完毕的判断参数。
    this.cleanupOver = this.cleanup(value);
  }
}

// 判断是否已经被清理干净
public boolean isCleanupOver() {
  return this.refCount.get() <= 0 && this.cleanupOver;
}

public boolean cleanup(final long currentRef) {
  if (this.isAvailable()) {
    log.error("this file[REF:" + currentRef + "] " + this.fileName
              + " have not shutdown, stop unmapping.");
    return false;
  }

  if (this.isCleanupOver()) {
    log.error("this file[REF:" + currentRef + "] " + this.fileName
              + " have cleanup, do not do it again.");
    return true;
  }

  clean(this.mappedByteBuffer);
  // 维护 总文件大小
  TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(this.fileSize * (-1));
  // 维护 文件个数
  TOTAL_MAPPED_FILES.decrementAndGet();
  log.info("unmap file[REF:" + currentRef + "] " + this.fileName + " OK");
  return true;
}

TransientStorePool

// availableBuffers 大小,默认5个。 broker 配置文件设置 transientStorePoolSize 
private final int poolSize;

// ByteBuffer 大小。默认为 mappedFileSizeCommitLog.表明 TransientStorePoolSize 为 commitlog 文件服务。
private final int fileSize;

// 双端队列
private final Deque<ByteBuffer> availableBuffers;

RocketMQ 存储文件

文件介绍 ${ROCKET_HOME}/store

  • commitlog:消息存储目录
  • config:运行期间一些配置信息,主要包括以下信息

    • consumerFilter.json:主题消息过滤信息
    • consumerOffset.json:集群消费模式消息消费进度
    • delayOffset.json:延迟消息队列拉取进度
    • subscriptionGroup.json:消息消费组配置信息
    • topic.json:topic 配置属性
  • consumequeue:消息消费队列目录
  • index: 消息索引文件存储目录
  • abort:存在 abort 文件说明 broker 非正常关闭,默认启动时创建,正常退出之前删除
  • checkpoint:文件检测点,存储 commitlog 文件最后一次刷盘时间戳、consumequeue 最后一次刷盘时间、index 索引文件最后一次刷盘时间戳

commitlog

消息查找

CommitLog#getMessage()

public SelectMappedBufferResult getMessage(final long offset, final int size) {
  // 默认是 1G 1024 * 1024 * 1024
  int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
  
  // 根据 offset 找到 MappedFile
  MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, offset == 0);
  if (mappedFile != null) {
    // 根据 pos(消息起点),size(消息长度) 从 ByteBuffer 中获取消息
    int pos = (int) (offset % mappedFileSize);
    return mappedFile.selectMappedBuffer(pos, size);
  }
  return null;
}

// MappedFile#selectMappedBuffer()
public SelectMappedBufferResult selectMappedBuffer(int pos, int size) {
        int readPosition = getReadPosition();
        if ((pos + size) <= readPosition) {
            if (this.hold()) {
                // 共享内存,但各自保存 pos,mark,limit 指针
                ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
                byteBuffer.position(pos);
                ByteBuffer byteBufferNew = byteBuffer.slice();
                byteBufferNew.limit(size);
                return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
            } else {
                log.warn("matched, but hold failed, request pos: " + pos + ", fileFromOffset: "
                    + this.fileFromOffset);
            }
        } else {
            log.warn("selectMappedBuffer request pos invalid, request pos: " + pos + ", size: " + size
                + ", fileFromOffset: " + this.fileFromOffset);
        }

        return null;
    }

consumequeue 文件

consumequeue 目录下的每个文件夹,代表一个 主题。二级目录,则是该主题下的队列(默认4个)。

image.png

Consumequeue条目,存储格式
image.png
consumeQueue 默认包含 30W 个条目,单个文件长度 30W * 20 字节 = 5 .7MB

image.png

根据 offset 查找消息
public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
  // 600w 5.7mb
  int mappedFileSize = this.mappedFileSize;
  // 获取文件物理偏移量
  long offset = startIndex * CQ_STORE_UNIT_SIZE;
  if (offset >= this.getMinLogicOffset()) {
    // ? 为什么 这里可以根据 offset 找到对应的 MappedFile
    MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
    if (mappedFile != null) {
      SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
      return result;
    }
  }
  return null;
}
根据时间戳查找消息
public long getOffsetInQueueByTime(final long timestamp) {
  // 定位 MappedFile
    MappedFile mappedFile = this.mappedFileQueue.getMappedFileByTime(timestamp);
  ...
  ...
  long minPhysicOffset = this.defaultMessageStore.getMinPhyOffset();
  ...
  ...
    // 二分查找法
    while (high >= low) {
      midOffset = (low + high) / (2 * CQ_STORE_UNIT_SIZE) * CQ_STORE_UNIT_SIZE;
      byteBuffer.position(midOffset);
      // 记住 consumequeue 条目的数据结构, 前8个字节是 commitlog offset 偏移量
      long phyOffset = byteBuffer.getLong();
      // 获取消息大小, 4个字节
      int size = byteBuffer.getInt();
      // 物理偏移量 小于 最新的物理偏移量,很明显这是一个无效的消息。
      if (phyOffset < minPhysicOffset) {
        low = midOffset + CQ_STORE_UNIT_SIZE;
        leftOffset = midOffset;
        continue;
      }

      long storeTime =
        this.defaultMessageStore.getCommitLog().pickupStoreTimestamp(phyOffset, size);
      if (storeTime < 0) {
        return 0;
      } else if (storeTime == timestamp) {
        targetOffset = midOffset;
        break;
      } else if (storeTime > timestamp) {
        high = midOffset - CQ_STORE_UNIT_SIZE;
        rightOffset = midOffset;
        rightIndexValue = storeTime;
      } else {
        low = midOffset + CQ_STORE_UNIT_SIZE;
        leftOffset = midOffset;
        leftIndexValue = storeTime;
      }
    }
    ...
    ...
}

查找消息的大体流程都是:先找到 MappedFile,在从 consumequeue 中获取 offset,然后根据 offset 到 MappedFile 获取消息

index 索引文件(为消息建立索引)

image.png

  • IndexHead(共 40 个字节)

    • beginTimestamp:索引文件包含消息的最小存储时间
    • endTimestamp:包含消息的最大存储时间
    • beginPhyoffset:包含消息的最小偏移量(commitlog文件偏移量)
    • endphyoffset:包含消息的最大物理偏移量(commitlog文件偏移量)
    • Hashslotcount:hashlot 个数
    • indexcount:Index 条目列表当前已使用个数
  • Hash 槽

IndexFile 默认有 500w 个 Hash槽,每个 Hash槽存储的是落在该 Hash 槽的 hashcode 最新的 index 下标。

  • Index条目列表

    • Hashcode:key 的 hashcode
    • Phyoffset:消息对应的物理偏移量
    • Timedif:该消息存储时间与第一条消息的时间戳差值,小于0该消息无效
    • preIndexNo:该条目的前一条 index 索引,当出现 hash 冲突时,构建的链表结构
消息索引写入

IndexFile#putKey()

// key:消息key ; phyoffset:消息物理偏移量;storeTimestamp:消息存储时间
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
  // 小于 最大 index 条目数量 2000W
  if (this.indexHeader.getIndexCount() < this.indexNum) {
    // 将 key hash
    int keyHash = indexKeyHashMethod(key);
    // keyHash % 500w
    int slotPos = keyHash % this.hashSlotNum;
    // 加上 indexHead 大小(20) 得到 hash槽 物理位置
    int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;

    FileLock fileLock = null;

    try {

      int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
      if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
        slotValue = invalidIndex;
      }

      // 获取 消息存储时间与第一条消息的时间戳差值(秒)
      long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
      timeDiff = timeDiff / 1000;
      if (this.indexHeader.getBeginTimestamp() <= 0) {
        timeDiff = 0;
      } else if (timeDiff > Integer.MAX_VALUE) {
        timeDiff = Integer.MAX_VALUE;
      } else if (timeDiff < 0) {
        timeDiff = 0;
      }

      // 写入 index 条目
      int absIndexPos =
        IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
        + this.indexHeader.getIndexCount() * indexSize;
      this.mappedByteBuffer.putInt(absIndexPos, keyHash);
      this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
      this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
      this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);
      
      // 往 hash 槽中写入 当前最新 index 条目下标
      this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());

      // 更新 indexHeader 信息
      if (this.indexHeader.getIndexCount() <= 1) {
        this.indexHeader.setBeginPhyOffset(phyOffset);
        this.indexHeader.setBeginTimestamp(storeTimestamp);
      }
      this.indexHeader.incHashSlotCount();
      this.indexHeader.incIndexCount();
      this.indexHeader.setEndPhyOffset(phyOffset);
      this.indexHeader.setEndTimestamp(storeTimestamp);
  // ....

}
消息查找

IndexFile#selectPhyOffset

代码比较简单,不贴了。流程大致如下:

根据偏移量,从 hash 槽中,找到对应的下标。 可能会有 hash 冲突,因此,还得根据 pre index no 查找上一个消息。知道找到为止。

checkpoint 文件

记录,commitlog,consumequeue,index 文件的刷盘点

image.png

  • pyysicMsgTimestamp:commitlog 文件刷盘时间点
  • logicMsgTimestamp:消息消费队列刷盘时间点
  • indexMsgTimestamp:index 刷盘时间点

实时更新消息消费队列与索引文件

当消息存放到 commitlog 时,ConsumeQueue,IndexFile 需要及时更新。否则消息无法被消费。通过 ReputMessageService 准实时转发 CommitLog 文件更新事件,由相应的任务处理器根据转发的消息及时更新 ConsumeQueue、IndexFile 文件。

更新 consumequeue

DefaultMessageStore 在启动时,会让 reputmesageService 先确保完成发送落后的消息。落后的消息是指, commitlog 的偏移量 落后于 comsumequeue 的最大偏移量。

DefaultMessageStore#start()

{
  // 获取消息的最大的物理偏移量
  long maxPhysicalPosInLogicQueue = commitLog.getMinOffset();
  for (ConcurrentMap<Integer, ConsumeQueue> maps : this.consumeQueueTable.values()) {
    for (ConsumeQueue logic : maps.values()) {
      if (logic.getMaxPhysicOffset() > maxPhysicalPosInLogicQueue) {
        maxPhysicalPosInLogicQueue = logic.getMaxPhysicOffset();
      }
    }
  }
  // ...
  // ... 
  // 启动 reputMessageService,完成 commitlog 消息转发
  this.reputMessageService.setReputFromOffset(maxPhysicalPosInLogicQueue);
  this.reputMessageService.start();

  // 等待完成发送落后消息
  while (true) {
    if (dispatchBehindBytes() <= 0) {
      break;
    }
    Thread.sleep(1000);
  }
}

ReputMessageService 启动时,每次任务推送休息 1 毫秒就继续推送消息。因此是准实时的,即基本可以理解为,只要 commitlog 文件被写入, consumequeue,index 文件就会立即被更新,消费者也能及时消费到消息

ReputMessageService#run()

public void run() {
  while (!this.isStopped()) {
    try {
      Thread.sleep(1);
      // 转发
      this.doReput();
    } catch (Exception e) {
      DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
    }
  }
}

ReputMessageService#doReput()

从 commitlog 中获取消息。一条一条的进行转发。将消息封装 DispatchRequest,由 CommitLogDispatcher 转发器对 DispatchRequest 进行转发。 由 CommitLogDispatcherBuildConsumeQueue 处理转发 consumequeue ,CommitLogDispatcherBuildIndex 处理转发 indexFile。

private void doReput() {
  for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
    // 获取可转发的数据 
    SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
    if (result != null) {
      try {
        this.reputFromOffset = result.getStartOffset();
              // 一条条转发
        for (int readSize = 0; readSize < result.getSize() && doNext; ) {
          // 将消息封装为 DispatchRequest
          DispatchRequest dispatchRequest = DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
          if (dispatchRequest.isSuccess()) {
            if (size > 0) {
              // ...
              // 转发
              DefaultMessageStore.this.doDispatch(dispatchRequest);

              this.reputFromOffset += size;
              readSize += size;
              // ...
            }
            }
        }
      } 
  }
}

CommitLogDispatcherBuildConsumeQueue 处理转发时,交由 DefaultMessageStore#putMessagePositionInfo() 方法进行转发。

根据主题和队列ID 找到对应的 队列。最终由 ConsumeQueue 自身处理消息

public void putMessagePositionInfo(DispatchRequest dispatchRequest) {
  ConsumeQueue cq = this.findConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
  cq.putMessagePositionInfoWrapper(dispatchRequest);
}

ConsumeQueue 在处理消息时,只完成了在内存中对消息的追加

ConsumeQueue#putMessagePositionInfo

// ...
// 消息追加 
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);
// ...
mappedFile.appendMessage(this.byteBufferIndex.array());

更新 index

index 的转发任务实现类:CommitLogDispatcherBuildIndex

如果 messageIndexEnable,则构建 索引。

如果唯一键,不为空,则构建。RocketMQ 支持为同一个消息构建多个索引,使用 空格 分割。

消息队列与索引文件恢复

加载 commitlog,consumeQueue,indexFile。rocketMQ 通过 abort 文件,判断 broker 是否正常退出。

如果是正常退出,那么 从 MappedFile 的倒数第 3 个文件开始加载,如果不足,则从第1个开始。找到最后一个 偏移量。 并用之设置 MappedFileQueue 的 flushedPosition,commitPosition。

如果非正常退出,则由后往前找到最后一个有效的文件,并将之所有消息 转发给 comsumequeue、indexFile。用最后一个有效文件的最后一个消息的偏移量 设置 MappedFileQueue 的 flushedPosition,commitPosition。因为重新 commitlog 消息,因为会存在 消息重复消费问题。

最后, rocketMQ 都会删掉 commitlog , consumequeue, indexFile 的脏数据。

代码位置:DefaultMessageStore#load()

文件刷盘机制

代码位置:Commitlog#handleDiskFlush

同步刷盘

将刷盘任务封装为 GroupCommitRequest,由 GroupCommitService 每隔 10 毫秒 对一批消息进行刷盘。默认 5 秒 内,没完成刷盘动作,则视为刷盘失败。

真正执行刷盘,则是由 MappedFile#flush 做刷盘动作。因此,如果是同步刷盘操作的话,不可能出现消息丢失

异步刷盘

异步刷盘分 2 种情况,

transientStorePoolEnable = true
  1. 消息直接追加到 ByteBuffer,wrotePosition 不断追加
  2. CommitRealTimeService 线程默认每 200ms 将 ByteBuffer 新追加的内容(wrotePosition - commitPosition) 写到 MappedByteBuffer。 commitPosition 向前移动到本次提交内容的长度,wrotePosition 继续向前。
  3. FlushRealTimeService 线程默认 每 500ms 将 MappedByteBuffer 刷写到磁盘。
transientStorePoolEnable = false

由 FlushRealTimeService 线程进行刷盘

过期文件删除

如果非当前写文件在一定时间间隔内没有再次被更新,则认为是过期文件,可以被删除。RocketMQ 不会关注这个文件上的消息是否全部被消费。默认每个文件的过期时间为72 个小时。默认每隔 10 秒扫描过期文件

代码位置: DefaultMessageStore#addScheduleTask


心无私天地宽
513 声望22 粉丝