RocketMQ源码学习(三)—— 消息存储

89 阅读37分钟

3.RocketMQ消息存储

MQ中间件从存储模型来看,分为需要持久化和不需要持久化两种模型,持久化可以大大增加系统的高可用性。

从存储方式和效率来看,文件系统高于KV存储,KV存储又高于关系型数据库,直接操作文件系统最快,但可靠性最低

3.1消息存储概要

RocketMQ主要的存储文件包括Commitlog文件、ConsumeQueue文件、IndexFile文件。RocketMQ将所有主题的消息存储在同一个文件中,确保消息发送时顺序写文件,保证消息发送的高性能与高吞吐量。

由于消息中间件一般是基于消息主题的订阅机制,这样设计给按消息主题检索消息带来了极大的不便。为了提高消息消费的效率,RocketMQ引入了ConsumeQueue消息队列文件。每个消息主题包含多个消息消费队列,每一个消息队列有一个消息文件。

IndexFile索引文件,主要设计理念就是为了加速消息的检索性能,根据消息的属性快速从Commitlog文件中检索消息

RocketMQ数据流向

image.png

  • CommitLog:消息存储文件,所有消息主题的消息都存储在CommitLog文件中
  • ConsumeQueue:消息消费队列,消息到达CommitLog文件后,将异步转发到消息消费队列,供消息消费者消费
  • IndexFile:消息索引文件,主要存储消息Key与Offset的对应关系
  • 事务状态服务:存储每条消息的事务状态
  • 定时消息服务:每一个延迟级别对应一个消息消费队列,存储延迟队列的消息拉取进度
    // DefaultMessageStore类的核心属性
    private final MessageStoreConfig messageStoreConfig;                // 消息存储配置属性
    private final CommitLog commitLog;                                  // CommitLog文件的存储实现类
    private final ConcurrentMap<String/* topic */, ConcurrentMap<Integer/* queueId */, ConsumeQueue>> consumeQueueTable;                                                      // 消息队列存储缓存表,按消息主题分组 
    private final FlushConsumeQueueService flushConsumeQueueService;    // 消息队列文件ConsumeQueue刷盘线程
    private final CleanCommitLogService cleanCommitLogService;          // 清除CommitLog文件服务
    private final CleanConsumeQueueService cleanConsumeQueueService;    // 清除ConsumeQueue文件服务
    private final IndexService indexService;                            // 索引文件实现类
    private final AllocateMappedFileService allocateMappedFileService;  // MappedFile分配服务
    private final ReputMessageService reputMessageService;              // CommitLog消息分发,根据CommitLog文件构建ConsumeQueue、IndexFile文件
    private final HAService haService;                                  //存储HA机制
    private final TransientStorePool transientStorePool;                // 消息堆内存缓存
    private final MessageArrivingListener messageArrivingListener;      // 消息拉取长轮询模式消息达到监听器
    private final BrokerConfig brokerConfig;                            // Broker配置属性
    private StoreCheckpoint storeCheckpoint;                            // 文件刷盘监测点
    private final LinkedList<CommitLogDispatcher> dispatcherList;       // CommitLog文件转发请求

3.2消息发送存储流程

消息存储的入口:DefaultMessageStore#putMessage

  • Step1:如果当前Broker停止工作或者Broker为SLAVE角色或者当前Rocket不支持写入,则拒绝消息写入;如果消息主题长度超过127个字符、消息属性长度超过32767个字符将拒绝该消息写入
  • Step2:如果消息的延迟级别大于0,将消息的原主题名称与原消息队列ID存入消息属性中,用 延迟消息主题SCHEDULE_TOPIC、消息队列ID更新原来消息的主题与队列,这是并发消息消费重试关键的一步
  • Step3:获取当前可以写入的CommitLog文件,RocketMQ物理文件的组织方式:CommitLog文件存储在{ROCKET_HOME}/ store/commitlog目录,每个文件默认1G,一个文件写满后再创建另外一个,以该文件中的第一个偏移量作为文件名,长度为20位。MappedFileQueue可以看作是commitlog文件夹,MappedFile则对应该文件夹下的一个个文件
  • Step4:写入CommitLog之前,先申请putMessageLock,也就是将消息存储到CommitLog文件中是串行的
// CommitLog类中的属性
// PutMessageLock是一个接口,有两种实现方式,spin(实现自旋锁是有一个AtomicBoolean变量) or ReentrantLock
protected final PutMessageLock putMessageLock;
putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
  • Step5:设置消息的存储时间,如果mappedFile为空,表明commitlog目录下没有任何文件,说明本次消息是第一次消息发送,用偏移量0创建第一个commit文件,如果创建失败,抛出MAPPEDFILE_FAILED,很有可能是磁盘空间不足或权限不够
  • Step6:将消息追加到MappedFile中。
// MappedFile#appendMessagesInner
int currentPos = this.wrotePosition.get();  // 首先获取MappedFile当前写指针
if (currentPos < this.fileSize) {
    // 通过slice()方法创建一个与MappedFile的共享内存区
    ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
    byteBuffer.position(currentPos);        // 设置position为当前指针
    AppendMessageResult result;
    if (messageExt instanceof MessageExtBrokerInner) {
        result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos,
                        (MessageExtBrokerInner) messageExt, putMessageContext);
     } else if (messageExt instanceof MessageExtBatch) {
        result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos,
                        (MessageExtBatch) messageExt, putMessageContext);
     } else {
        return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
     }
        this.wrotePosition.addAndGet(result.getWroteBytes());
        this.storeTimestamp = result.getStoreTimestamp();
        return result;
}
  • Step7:创建全局唯一消息ID,消息ID有16字节,[4字节IP,4字节端口号,8字节消息偏移量]
  • Step8:获取该消息在消息队列的偏移量,CommitLog中保存当前所有消息队列的当前待写入偏移量
  • Step9:根据消息体的长度、主题的长度、属性的长度结合消息存储格式计算消息的总长度
// 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;
    }
  • Step10:如果消息长度+END_FILE_MIN_BLANK_LENGTH 大于 CommitLog文件的空闲空间,则返回AppendMessageStatus.END_OF_FILE,Broker会重新创建一个新的CommitLog文件来存储该消息
// 高4字节存储当前文件剩余空间,低4字节存储魔数
END_FILE_MIN_BLANK_LENGTH = 4 + 4; 
  • Step11:将消息内容存储到ByteBuffer中,然后创建AppendMessageResult。这里只是将消息存储在MappedFile对应的内存映射Buffer中,并没有刷写到磁盘
public class AppendMessageResult {
    private AppendMessageStatus status;     // 消息追加结果
    private long wroteOffset;               // 消息的物理偏移量
    private int wroteBytes;                 
    private String msgId;                   // 消息ID
    private Supplier<String> msgIdSupplier; 
    private long storeTimestamp;            // 消息存储时间戳
    private long logicsOffset;              // 消息消费队列逻辑偏移量
    private long pagecacheRT = 0;           // 当前未使用
    private int msgNum = 1;                 // 消息条数,批量消息发送时消息条数
}
​
public enum AppendMessageStatus {
    PUT_OK,                     // 追加成功
    END_OF_FILE,                // 超过文件大小
    MESSAGE_SIZE_EXCEEDED,      // 消息长度超过最大允许长度
    PROPERTIES_SIZE_EXCEEDED,   // 消息属性超过最大允许长度
    UNKNOWN_ERROR,              // 未知异常
}
  • Step12:更新消息队列逻辑偏移量
  • Step13:处理完消息追加逻辑之后将释放putMessageLock锁
putMessageLock.unlock();
  • Step14:DefaultAppendMessageCallback#doAppend只是将消息追加在内存中,需要根据是同步刷盘还是异步刷盘方式,将内存中的数据持久化到磁盘,然后执行HA主从同步复制

RocketMQ消息存储格式

CommitLog条目是不定长的,每一个条目的长度存储在前4个字节中

  • totalsize:该消息条目总长度,4字节
  • magiccode:魔数,4字节。固定值0xdaa320a7
  • bodycrc:消息体crc效验码,4字节
  • queueid:消息消费队列ID,4字节
  • flag:消息flag,RocketMQ不做处理,供应用程序使用,默认4字节
  • queueoffset:消息在消息消费队列的偏移量,8字节
  • physicaloffset:消息在CommitLog文件中的偏移量,8字节
  • sysflag:消息系统flag,例如是否压缩、是否是事务消息等,4字节
  • borntimestamp:消息存储时间戳,8字节
  • bornhost:消息发送者IP、端口号,8字节
  • storetimestamp:消息存储时间戳,8字节
  • storehostaddress:Broker服务器IP+端口号,8字节
  • reconsumetimes:消息重试次数,4字节
  • prepared transaction offset:事务消息物理偏移量,8字节
  • BodyLength:消息体长度,4字节
  • Body:消息体内容,长度为BodyLength中存储的值
  • TopicLength:主题存储长度,1字节,表示主题名称不能超过255个字符
  • Topic:主题,长度为TopicLength中存储的值
  • PropertiesLength:消息属性长度,2字节,表示消息属性长度不能超过65535个字符
  • Properties:消息属性,长度为PropertiesLength中存储的值

3.3存储文件组织与内存映射

RocketMQ通过使用内存映射文件来提高IO访问性能,无论是CommitLog、ConsumeQueue还是IndexFile,单个文件都被设计为固定长度,一个文件写满后再创建一个新文件,文件名为该文件第一条消息对应的全局物理偏移量。

RocketMQ使用MappedFile、MappedFIleQueue来封装存储文件,一个MappedFileQueue包含第一个MappedFIle

MappedFileQueue映射文件队列

MappedFileQueue是MappedFile的管理容器,是对存储目录的封装

public class MappedFileQueue {
    private final String storePath;         // 存储目录
    protected final int mappedFileSize;     // 单个文件的存储大小
    protected final CopyOnWriteArrayList<MappedFile> mappedFiles            // MappedFile文件集合
    private final AllocateMappedFileService allocateMappedFileService;      // 创建MappedFile服务类
    protected long flushedWhere = 0;        // 当前刷盘指针,表示该指针之前的所有数据全部持久化到磁盘
    private long committedWhere = 0;        // 当前数据提交指针,内存中ByteBuffer当前的写指针,>= flushedWhere
    private volatile long storeTimestamp = 0;
}

不同查询维度查找MappedFile:

  • 根据消息存储时间戳查找MappedFile。从MappedFile列表中第一个文件开始查找,找到第一个最后一次更新时间大于待查找时间戳的文件,如果不存在,则返回最后一个MappedFIle文件
public MappedFile getMappedFileByTime(final long timestamp) {
        Object[] mfs = this.copyMappedFiles(0); // 从 mappedFiles 拿到文件集合,转换成数组形式
        if (null == mfs)
            return null;
        for (int i = 0; i < mfs.length; i++) {  // 从第一个MappedFile文件开始查找
            MappedFile mappedFile = (MappedFile) mfs[i];
            if (mappedFile.getLastModifiedTimestamp() >= timestamp) {   // 比较当前文件最后一次更新的时间戳
                return mappedFile;
            }
        }
        return (MappedFile) mfs[mfs.length - 1];
    }
  • 根据消息偏移量offset查找MappedFile。由于RocketMQ采取定时删除存储文件的策略,所以第一个文件不一定是0,故根据offset定位MappedFIle的算法为:
// 要查找文件的索引 - 第一个文件的索引 = 当前真正要查找文件的索引下标
int index = (int) ((offset / this.mappedFileSize) - 
                    (firstMappedFile.getFileFromOffset() / this.mappedFileSize));

MappedFile内存映射文件

public class MappedFile extends ReferenceResource {
    public static final int OS_PAGE_SIZE = 1024 * 4;            // 操作系统每页大小,默认4K
    // 当前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,如果writeBuffer不为null,消息将首先放在此处,然后再放在FileChannel中。
    // transientStorePoolEnable为true时不为空
    protected ByteBuffer writeBuffer = null;
    // 堆外内存池,该内存池中的内存会提供内存锁定机制。transientStorePoolEnable为true时启用
    protected TransientStorePool transientStorePool = null;
    private String fileName;                    // 文件名称
    private long fileFromOffset;                // 该文件的初始偏移量
    private File file;                          // 物理文件
    private MappedByteBuffer mappedByteBuffer;  // 物理文件对应的内存映射Buffer
    private volatile long storeTimestamp = 0;   // 文件最后一次内容写入时间
    private boolean firstCreateInQueue = false; // 是否是MappedFileQueue队列中的第一个文件
}
1.MappedFile初始化

根据是否开启transientStorePoolEnable存在两种初始化方式:

// MappedFile#init
// transientStorePoolEnable 为 false
private void init(final String fileName, final int fileSize) throws IOException {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.file = new File(fileName);
        this.fileFromOffset = Long.parseLong(this.file.getName());  // 文件名就是该文件的起始偏移量
        ensureDirOK(this.file.getParent());
        this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();  // 创建读写文件通道
        // 将文件内容通过NIO的内存映射Buffer将文件映射到内存中
        this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
        TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
        TOTAL_MAPPED_FILES.incrementAndGet();
}
​
// transientStorePoolEnable 为 true
public void init(final String fileName, final int fileSize,
        final TransientStorePool transientStorePool) throws IOException {
        init(fileName, fileSize);
        this.writeBuffer = transientStorePool.borrowBuffer();   // 从transientStorePool中获取
        this.transientStorePool = transientStorePool;
}

transientStorePoolEnable为true表示内容先存储在堆外内存,然后通过Commit线程将数据提交到内存映射Buffer中,再通过Flush线程将内存映射Buffer中的数据持久化到磁盘中

2.MappedFIle提交(commit)

内存映射文件的提交动作由MappedFile的commit方法实现的

commit的作用就是将MappedFIle#writeBuffer中的数据提交到文件通道FileChannel中

// MappedFile#commit
/**
* commitLeastPages为本次提交最小的页数,如果待提交数据不满commitLeastPages,则不执行本次提交操作,待下次提交。
* writeBuffer如果为空,直接返回WrotePosition指针,无须执行commit操作,表明commit操作主体是writeBuffer
*/
public int commit(final int commitLeastPages) {
        if (writeBuffer == null) {
            // 不需要将数据提交到文件通道,所以只需将wrotePosition视为committedPosition即可。
            return this.wrotePosition.get();
        }
        if (this.isAbleToCommit(commitLeastPages)) {
            if (this.hold()) {
                commit0();
                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();
}
​
protected boolean isAbleToCommit(final int commitLeastPages) {
        int commit = this.committedPosition.get();
        int write = this.wrotePosition.get();
        if (this.isFull()) {        // 如果文件已满返回true
            return true;
        }
        if (commitLeastPages > 0) { // 比较当前写指针与上次提交指针的差值,除以操作系统页大小得到脏页数量
            return ((write / OS_PAGE_SIZE) - (commit / OS_PAGE_SIZE)) >= commitLeastPages;
        }
        return write > commit;
}
​
protected void commit0() {
        int writePos = this.wrotePosition.get();
        int lastCommittedPosition = this.committedPosition.get();
​
        if (writePos - lastCommittedPosition > 0) {
            try {
                ByteBuffer byteBuffer = writeBuffer.slice();    // 首先创建writeBuffer的共享缓存区
                byteBuffer.position(lastCommittedPosition);     // 将新创建的position回退到上次提交的位置
                byteBuffer.limit(writePos);                     // 设置当前最大有效数据指针
                this.fileChannel.position(lastCommittedPosition);
                this.fileChannel.write(byteBuffer);             // 将之间的数据写入到FileChannel中
                this.committedPosition.set(writePos);           // 更新position
            } catch (Throwable e) {
                log.error("Error occurred when commit data to FileChannel.", e);
            }
        }
}
3.MappedFile刷盘(flush)

刷盘指的是将内存中的数据刷写到磁盘,永久存储在磁盘中,通过MappedFile的flush方法实现

int value = getReadPosition();
if (writeBuffer != null || this.fileChannel.position() != 0) {
    this.fileChannel.force(false);
} else {
    this.mappedByteBuffer.force();
}
this.flushedPosition.set(value);
this.release();
​
4.获取MappedFile最大读指针(getReadPosition)

RocketMQ文件的一个组织方式是内存映射文件,预先申请一块连续的固定大小的内存,需要一套指针标识当前最大有效数据的位置,获取最大有效数据偏移量的方法由MappedFIle的getReadPosition方法实现。

在MappedFIle中,只有写入到MappedByteBuffer或FIleChannel中的数据才是安全的数据

// MappedFile#getReadPosition
public int getReadPosition() {
    // 如果transientStorePoolEnable为true,writeBuffer不为空,数据会存储在transientStorePool,使用committedPosition
    return this.writeBuffer == null ? this.wrotePosition.get() : this.committedPosition.get();
}
5.MappedFile销毁(destory)

MappedFile文件销毁的实现方法为public boolean destroy(final long intervalForcibly),intervalForcibly表示拒绝被销毁的最大存活时间

  • Step1:关闭MappedFIle
public void shutdown(final long intervalForcibly) {
        if (this.available) {
            this.available = false; // 表示该MappedFile文件不可用
            this.firstShutdownTimestamp = System.currentTimeMillis();   //设置初次关闭的时间戳为当前时间戳
            this.release(); //尝试释放资源,release只有在引用次数小于1时才会释放资源
        } else if (this.getRefCount() > 0) { // 如果引用次数大于0
            // 对比当前时间与firstShutdownTimestamp差值,
            // 如果已经超过了其最大拒绝存活期intervalForcibly,每执行一次,将引用数减少1000,调用release尝试释放资源
            if ((System.currentTimeMillis() - this.firstShutdownTimestamp) >= intervalForcibly) {
                this.refCount.set(-1000 - this.getRefCount());
                this.release();
            }
        }
}
  • Step2:判断是否清理完成
public boolean isCleanupOver() {
    // cleanupOver为true的条件是:release成功地将MappedByteBuffer资源释放
    return this.refCount.get() <= 0 && this.cleanupOver;
}
  • Step3:关闭文件通道,删除物理文件
this.fileChannel.close();
log.info("close file channel " + this.fileName + " OK");
long beginTime = System.currentTimeMillis();
boolean result = this.file.delete();

MappedFile文件销毁的前提是该文件的引用 <= 0,核心方法就是release

public void release() {
    // 将引用次数减1,如果引用数小于等于0,执行cleanup方法
    long value = this.refCount.decrementAndGet();
    if (value > 0)
        return;
    synchronized (this) {
        this.cleanupOver = this.cleanup(value);
    }
}
​
public boolean cleanup(final long currentRef) {
        if (this.isAvailable()) {   // 如果该MappedFile文件可用,无须清理
            return false;
        }
        if (this.isCleanupOver()) { // 如果该MappedFile文件已经被清除,返回true
            return true;
        }
        clean(this.mappedByteBuffer);                                   // 如果是堆外内存,调用其clean方法清除
        TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(this.fileSize * (-1));    // 维护当前JVM实例中MappedFile虚拟内存
        TOTAL_MAPPED_FILES.decrementAndGet();                           // 维护当前JVM实例中MappedFile对象个数
        log.info("unmap file[REF:" + currentRef + "] " + this.fileName + " OK");
        return true;
    }

TransientStorePool

TransientStorePool:短暂的存储池。RocketMQ单独创建一个MappedByteBuffer内存缓存池,用来临时存储数据,数据先写入该内存映射中,然后由commit线程定时将数据从该内存复制到与目的物理文件对应的内存映射中。

这种机制提供了一种内存锁定,将当前堆外内存一直锁定在内存中,避免被进程将内存交换到磁盘

public class TransientStorePool {
    private final int poolSize;     // availableBuffers个数,可通过broker中配置文件中设置,默认为5
    private final int fileSize;     // 每个ByteBuffer大小,默认为mappedFileCommitLog,表明为commitLog文件服务
    private final Deque<ByteBuffer> availableBuffers;   // ByteBuffer容器,双端队列
    private final MessageStoreConfig storeConfig;
}
    
    // 创建poolSize个堆外内存,并使用Library类库将该批内存锁定,避免被置换到交换区,提高存储性能
    public void init() {
        for (int i = 0; i < poolSize; i++) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);
​
            final long address = ((DirectBuffer) byteBuffer).address();
            Pointer pointer = new Pointer(address);
            LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));
​
            availableBuffers.offer(byteBuffer);
        }
    }

3.4RocketMQ存储文件

  • commitlog:消息存储目录

  • config:运行期间的一些配置信息,主要包括下列信息:

    • consumerFilter.json:主题消息过滤信息
    • consumerOffset.json:集群消息模式消息消费进度
    • delayOffset.json:延时消息队列拉取进度
    • subscriptionGroup.json:消息消费组配置信息
    • topics.json:topic配置属性
  • consumequeue:消息消费队列存储目录

  • index:消息索引文件存储目录

  • abort:如果存在abort文件说明Broker非正常关闭,该文件默认启动时创建,正常退出之前删除

  • checkpoint:文件检测点,存储commitlog文件最后一次刷盘时间戳、consumequeue最后一次刷盘时间、index索引文件最后一次刷盘时间戳

ConsumeQueue文件

由于同一主题的消息不连续存储在commitLog文件中,为了提高消息消费的检索需求,设计了ConsumeQueue文件,可以看作是CommitLog关于消息消费的“索引”文件,consumequeue的第一级目录为消息主题,第二级目录为主题的消息队列、

为了加速ConsumeQueue消息条目的检索速度与节省磁盘空间,每一个ConsumeQueue条目不会存储消息的全量信息:

[8字节commitlog offset,4字节size,8字节tag hashcode] ,单个ConsumeQueue文件默认包含30万个条目,单个文件的大小为30w*20字节

构建机制是当消息到达CommitLog文件后,由专门的线程产生消息转发任务,从而构建消息消费队列文件和索引文件

获取消息消费队列条目有两种方式:

  • 根据startIndex获取。首先startIndex*20得到在consumequeue中的物理偏移量offset,如果offset小于 minLogicOffset,返回null,说明该消息已被删除;如果大于,根据物理偏移量定位到具体的物理文件
  • 根据消息存储时间获取。首先从第一个文件开始找到第一个文件更新时间大于该时间戳的文件,采用二分查找加速检索通过比较最小物理偏移量,然后比较存储时间与待查找时间戳的大小进一步缩小范围。

Index索引文件

消息消费队列是RocketMQ专门为消息订阅构建的索引文件,提高根据主题与消息队列检索消息的速度,另外RocketMQ引入了Hash索引机制为消息创建索引

Hash冲突链式解决方案:Hash槽中存储的是该HashCode所对应的最新Index条目的下标,新的Index条目的最后4个字节存储该HashCode上一个条目的Index下标。

  • 如果Hash槽中存储的值为0或大于当前Index最大条目数或小于-1,表示该Hash槽当前并没有与之对应的Index条目
  • Index条目中存储的不是消息索引key而是key的哈希值,这么设计是为了将Index条目设计为定长结构,方便检索与定位条目

image.png

IndexFile总共包含IndexHeader、Hash槽、Hash条目(数据)

  • IndexHeader头部:包含40个字节,记录该IndexFile的统计信息,结构如下:

    • beginTimestamp:该索引文件中包含消息的最小存储时间
    • endTimestamp:该索引文件中包含消息的最大存储时间
    • beginPhyoffset:该索引文件中包含消息的最小物理偏移量(commitlog文件偏移量)
    • endPhyoffset:该索引文件中包含消息的最大物理偏移量(commitlog文件偏移量)
    • hashslotCount:hashslot个数,并不是hash槽使用的个数,意义不大
    • indexCount:Index条目列表目前已使用的个数,Index条目在列表中按顺序存储
  • Hash槽:一个IndexFile文件默认包含500万个Hash槽,每个Hash槽存储的是落在该槽的hashcode最新的Index的索引

  • Index条目列表:默认一个索引文件包含2000万个条目,每一个Index条目(20字节)结构如下:

    • hashcode:key的哈希值
    • phyoffset:消息对应的物理偏移量
    • timedif:该消息存储时间与第一条消息的时间戳的差值,小于0该消息无效
    • preIndexNo:该条目的前一条记录的Index索引,当出现哈希冲突时构建的链表结构
建立消息索引key

RocketMQ将消息索引键与消息偏移量映射关系写入到IndexFile的实现方法为:IndexFIle#putKey,入参含义分别为消息索引、消息物理偏移量、消息存储时间

  • Step1:如果当前已使用条目大于等于允许最大条目时,则返回false,表示索引文件已写满。如果未写满,则根据key算出key的hashcode,然后keyHash对hash槽数量取余定位对应的槽下标
  • Step2:读取hash槽的数据
  • Step3:计算待存储消息的时间戳与第一条消息时间戳的差值,并转换成秒
  • Step4:将条目信息存储在IndexFile中
  • Step5:更新索引头信息
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
    if (this.indexHeader.getIndexCount() < this.indexNum) {
        int keyHash = indexKeyHashMethod(key);
        int slotPos = keyHash % this.hashSlotNum;   // 找到对应的hash槽
        // 对应hash槽的物理地址为IndexHeader头部(40字节)+下标乘以每个hash槽的大小(4字节)
        int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
        try {
                // 根据计算得到的物理地址读取hash槽的数据
                int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
                // 如果槽中存储的数据小于等于0 或 大于当前索引文件中已使用的索引条目个数
                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;
                }
                // 计算新添加条目的起始物理偏移量:头部字节长度(40字节)+hash槽数量*单个hash槽大小(4字节)+当前Index条目个数*单个Index条目大小(20字节)
                int absIndexPos =
                    IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                        + this.indexHeader.getIndexCount() * indexSize;
            
                this.mappedByteBuffer.putInt(absIndexPos, keyHash); // 存储hashcode
                this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);  // 存储消息物理偏移量
                this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);  // 存储时间戳差值
                this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);   // 存储槽中数据
                // 将当前Index中包含的条目数量存入Hash槽中,将覆盖原来Hash槽的值
                this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
            
                // 如果当前文件只包含一个索引条目
                if (this.indexHeader.getIndexCount() <= 1) {
                    this.indexHeader.setBeginPhyOffset(phyOffset);      // 更新BeginPhyOffset
                    this.indexHeader.setBeginTimestamp(storeTimestamp); // 更新BeginTimestamp
                }
                if (invalidIndex == slotValue) {
                    this.indexHeader.incHashSlotCount();
                }
                this.indexHeader.incIndexCount();                       // 更新已使用索引条目数量
                this.indexHeader.setEndPhyOffset(phyOffset);            // 更新EndPhyOffset
                this.indexHeader.setEndTimestamp(storeTimestamp);       // 更新EndTimestamp
                return true;
            } catch (Exception e) {
                log.error("putKey exception, Key: " + key + " KeyHashCode: " + key.hashCode(), e);
            }
        } else {
            log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
                + "; index max num = " + this.indexNum);
        }
        return false;
    }
根据索引key查找消息

RocketMQ根据索引key查找消息的实现方式:IndexFile#selectPhyOffset,入参含义分别为查找到的物理偏移量、索引key、本次查找最大消息条数、开始时间戳、结束时间戳

  • Step1:根据key算出key的hashcode,然后对槽数量取余找到对应的槽下标
  • Step2:根据Hash槽中存储的数据定位该槽最新的一个Item条目,然后循环读取索引条目中的信息,比对存储时间戳与入参传入的时间差值,找到需要的数据放入phyOffsets链表中
public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,
                                final long begin, final long end) {
        if (this.mappedFile.hold()) {
            int keyHash = indexKeyHashMethod(key);      // 求key的哈希值
            int slotPos = keyHash % this.hashSlotNum;   // 找到key对应的槽下标
            int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;    // key的物理地址
​
            try {
                int slotValue = this.mappedByteBuffer.getInt(absSlotPos);   // 拿到槽中数据
                if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()
                    || this.indexHeader.getIndexCount() <= 1) { // 该槽没有与之对应的Index条目
                } else {
                    for (int nextIndexToRead = slotValue; ; ) {
                        if (phyOffsets.size() >= maxNum) {
                            break;
                        }
​
                        int absIndexPos =
                            IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                                + nextIndexToRead * indexSize;
​
                        int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
                        long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);
​
                        long timeDiff = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
                        int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);
​
                        if (timeDiff < 0) { // 如果存储时间差小于0,直接结束
                            break;
                        }
​
                        timeDiff *= 1000L;
​
                        long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
                        boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
​
                        if (keyHash == keyHashRead && timeMatched) {
                            phyOffsets.add(phyOffsetRead);
                        }
​
                        if (prevIndexRead <= invalidIndex
                            || prevIndexRead > this.indexHeader.getIndexCount()
                            || prevIndexRead == nextIndexToRead || timeRead < begin) {
                            break;
                        }
​
                        nextIndexToRead = prevIndexRead;
                    }
                }
            } catch (Exception e) {
                log.error("selectPhyOffset exception ", e);
            } finally {
                this.mappedFile.release();
            }
        }
    }

checkpoint文件

checkpoint的作用是记录CommitLog、ConsumeQueue、Index文件的刷盘时间点,文件固定长度为4k,其中只用该文件的前24个字节,存储格式为:[8字节physicMsgTimestamp,8字节logicsMsgTimestamp,8字节indexMsgTimestamp]

  • physicMsgTimestamp:commitlog文件刷盘时间点
  • logicsMsgTimestamp:消息消费队列文件刷盘时间点
  • indexMsgTimestamp:索引文件刷盘时间点

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

消息消费队列文件、消息属性索引文件都是基于CommitLog文件构建的,因此当消息生产者提交的消息存储在CommitLog文件中,ConsumeQueue、IndexFile需要及时更新,否则消息无法及时被消费。

RocketMQ通过开启一个线程ReputMessageService来准实时转发CommitLog文件更新事件,相应的任务处理器根据转发的消息及时更新ConsumeQueue、IndexFile文件:

  • Broker服务器在启动时会启动ReputMessageService线程,并初始化一个重要参数:ReputFromOffset,该参数的含义是ReputMessageService线程从哪个物理偏移量开始转发消息给ConsumeQueue和IndexFile
// DefaultMessageStore#start
long maxPhysicalPosInLogicQueue = commitLog.getMinOffset();
// maxPhysicalPosInLogicQueue:MappedFileQueue中的第一个MappedFile文件的起始偏移量
this.reputMessageService.setReputFromOffset(maxPhysicalPosInLogicQueue);
this.reputMessageService.start();

ReputMessageService线程每执行一次任务推送休息1毫秒就继续尝试推送消息到消息消费队列和索引文件,消息消费转发的核心实现在dpReput方法中实现

    // DefaultMessageStore#run,实现了Runnable接口,开启ReputMessageService线程
    public void run() {
            DefaultMessageStore.log.info(this.getServiceName() + " service started");
​
            while (!this.isStopped()) {
                try {
                    Thread.sleep(1);
                    this.doReput();
                } catch (Exception e) {
                    DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
                }
            }
​
            DefaultMessageStore.log.info(this.getServiceName() + " service end");
        }

Step1:返回reputFromOffset偏移量开始的全部有效数据(commitlog文件),然后循环读取每一条消息

// DefaultMessageStore#doReput
SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);

Step2:从result返回的ByteBuffer中循环读取消息,一次读取一条,创建DispatchRequest对象

// DefaultMessageStore#doReput
DispatchRequest dispatchRequest =
        DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : 
                                                            dispatchRequest.getBufferSize();
for (int readSize = 0; readSize < result.getSize() && doNext; ) {
    if (dispatchRequest.isSuccess()) {
        if (size > 0) { // 如果消息长度大于0
            /** 该方法最终会调用:
            *   CommitLogDispatcherBuildConsumeQueue(构建消息消费队列):将内容追加到内存映射文件中,异步刷盘
            *   CommitLogDispatcherBuildIndex(构建索引文件):首先确保开启消息索引机制,支持同一个消息建立多个索引
            **/ 
            DefaultMessageStore.this.doDispatch(dispatchRequest);
        }
    }
}

DispatchRequest核心属性:

  • String topic:消息主题名称
  • int queueId:消息队列ID
  • long commitLogOffset:消息物理偏移量
  • int msgSize:消息长度
  • long tagsCode:消息过滤tag hashcode
  • long storeTimestamp:消息存储时间戳
  • long consumeQueueOffset:消息队列偏移量
  • String keys:消息索引key。多个索引key用空格隔开
  • boolean success:是否成功解析到完整的消息
  • String uniqKey:消息唯一键
  • int sysFlag:消息系统标记
  • long preparedTransactionOffset:消息预处理事务偏移量
  • Map<String,String>propertiesMap:消息属性
  • byte[] bitMap:位图

3.6消息队列与索引文件恢复

RocketMQ首先会将消息全量存储在CommitLog文件中,然后异步生成转发任务更新ConsumeQueue、Index文件。如果消息成功存储到CommitLog文件中,转发任务未成功执行,此时消息服务器Broker由于某个原因宕机,会导致三种文件的数据不一致。

如果不达到最终一致性,即使部分消息存储在CommitLog文件中,也不会被消费者消费,怎么保证最终一致性的呢?

  • Step1:判断上一次退出是否正常
// DefaultMessageStore#load
boolean lastExitOK = !this.isTempFileExist();
private boolean isTempFileExist() { // 实现机制是判断store目录下的abort文件是否存在
    // Broker启动时会创建abort文件,退出时通过注册JVM钩子函数删除abort文件,如果仍存在,说明异常退出,需要消息恢复
    String fileName = StorePathConfigHelper.getAbortFile(this.messageStoreConfig.getStorePathRootDir());
    File file = new File(fileName);
    return file.exists();
}
  • Step2:加载延迟队列,RocketMQ定时消息相关
// DefaultMessageStore#load
if (null != scheduleMessageService) {
    result =  this.scheduleMessageService.load();
}
  • Step3:加载Commitlog文件,加载commitlog目录下所有文件并按照文件名排序,创建MappedFile对象
// MappedFile#load
 public boolean load() {
        File dir = new File(this.storePath);
        File[] ls = dir.listFiles();
        if (ls != null) {
            return doLoad(Arrays.asList(ls));
        }
        return true;
 }
​
    public boolean doLoad(List<File> files) {
        files.sort(Comparator.comparing(File::getName));
​
        for (File file : files) {
            if (file.length() != this.mappedFileSize) {
                return false;
            }
​
            try {
                // 每个commitlog文件都创建一个对应的MappedFile文件
                MappedFile mappedFile = new MappedFile(file.getPath(), mappedFileSize);
​
                mappedFile.setWrotePosition(this.mappedFileSize);
                mappedFile.setFlushedPosition(this.mappedFileSize);
                mappedFile.setCommittedPosition(this.mappedFileSize);
                this.mappedFiles.add(mappedFile);
                log.info("load " + file.getPath() + " OK");
            } catch (IOException e) {
                log.error("load file " + file + " error", e);
                return false;
            }
        }
        return true;
    }
  • Step4:加载消息消费队列,为每一个消息队列构建ConsumeQueue对象,初始化ConsumeQueue的topic、queueId、storePath、mappedFileSize属性
  • Step5:加载存储检测点,检测点主要记录commitlog文件、Consumequeue文件、index索引文件的刷盘点
// DefaultMessageStore#load
this.storeCheckpoint = new StoreCheckpoint(StorePathConfigHelper.
                            getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir()));
​
  • Step6:加载索引文件,如果上次异常退出,而且索引文件上次刷盘时间小于该索引文件最大的消息时间戳该文件立即销毁(说明该索引文件中存在未被持久化的数据)
// DefaultMessageStore#load
this.indexService.load(lastExitOK);
​
// IndexService#load
Arrays.sort(files); // 对索引文件进行排序
for (File file : files) {
    try {
        IndexFile f = new IndexFile(file.getPath(), this.hashSlotNum, this.indexNum, 0, 0);
        f.load();
​
        if (!lastExitOK) {  // 如果是异常退出
            // 如果索引文件的最大消息时间戳 > 存储检测点
            if (f.getEndTimestamp() > this.defaultMessageStore.getStoreCheckpoint()
                      .getIndexMsgTimestamp()) {
                f.destroy(0);   // 销毁该索引文件
                continue;
            }
        }
    }
}
  • Step7:根据Broker是否正常停止执行不同的恢复策略
// DefaultMessageStore#load
this.recover(lastExitOK);
​
// DefaultMessageStore#recover
private void recover(final boolean lastExitOK) {
    long maxPhyOffsetOfConsumeQueue = this.recoverConsumeQueue();
    if (lastExitOK) {   // 如果Broker正常停止
        this.commitLog.recoverNormally(maxPhyOffsetOfConsumeQueue);
    } else {            // 如果Broker异常停止
        this.commitLog.recoverAbnormally(maxPhyOffsetOfConsumeQueue);
    }
    this.recoverTopicQueueTable();
}
  • Step8:恢复ConsumeQueue文件后,将在CommitLog实例中保存每个消息消费队列当前的逻辑偏移量
// DefaultMessageStore#recoverTopicQueueTable
public void recoverTopicQueueTable() {
        HashMap<String/* topic-queueid */, Long/* offset */> table = new HashMap<String, Long>(1024);
        long minPhyOffset = this.commitLog.getMinOffset();
        for (ConcurrentMap<Integer, ConsumeQueue> maps : this.consumeQueueTable.values()) {
            for (ConsumeQueue logic : maps.values()) {
                String key = logic.getTopic() + "-" + logic.getQueueId();
                table.put(key, logic.getMaxOffsetInQueue());
                logic.correctMinOffset(minPhyOffset);
            }
        }
​
        this.commitLog.setTopicQueueTable(table);
}

1.Broker正常停止文件恢复

  • Step1:Broker正常停止再重启时,从倒数第三个文件开始恢复,如果不足3个,则从第一个文件开始恢复
  • Step2:创建共享内存区,并初始化processOffset和mappedFileOffset
  • ......
// CommitLog#recoverNormally
public void recoverNormally(long maxPhyOffsetOfConsumeQueue) {
        boolean checkCRCOnRecover = this.defaultMessageStore.getMessageStoreConfig().isCheckCRCOnRecover();
        final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
        if (!mappedFiles.isEmpty()) {
            // 从倒数第三个文件开始恢复
            int index = mappedFiles.size() - 3;
            if (index < 0)
                index = 0;
​
            MappedFile mappedFile = mappedFiles.get(index);
            ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
            // CommitLog文件已确认的物理偏移量 = 该映射文件的起始偏移量 + mappedFileOffset
            long processOffset = mappedFile.getFileFromOffset();
            // 当前映射文件已校验通过的偏移量
            long mappedFileOffset = 0;
            
            while (true) {
                // 进行CRC循环冗余校验
                DispatchRequest dispatchRequest = this.checkMessageAndReturnSize(byteBuffer,                                                                                                  checkCRCOnRecover);
                int size = dispatchRequest.getMsgSize();
               
                // 如果查找结果为true并且消息长度大于0表示消息正确
                if (dispatchRequest.isSuccess() && size > 0) {  
                    mappedFileOffset += size;
                }
                // 如果查找结果为true并且消息长度大于0表示已经到该文件的末尾
                else if (dispatchRequest.isSuccess() && size == 0) {
                    index++;    // 
                    if (index >= mappedFiles.size()) {
                        log.info("recover last 3 physics file over, last mapped file " +                                                                                                    mappedFile.getFileName());
                        break;
                    } else {    // 如果还有下一个文件,重置processOffset、mappedFileOffset
                        mappedFile = mappedFiles.get(index);
                        byteBuffer = mappedFile.sliceByteBuffer();
                        processOffset = mappedFile.getFileFromOffset();
                        mappedFileOffset = 0;
                        log.info("recover next physics file, " + mappedFile.getFileName());
                    }
                }
                // 如果查找结果为false,表明文件未填满所有消息,跳出循环,结束遍历文件
                else if (!dispatchRequest.isSuccess()) {
                    log.info("recover physics file end, " + mappedFile.getFileName());
                    break;
                }
            }
​
            processOffset += mappedFileOffset;
            this.mappedFileQueue.setFlushedWhere(processOffset);
            this.mappedFileQueue.setCommittedWhere(processOffset);
            // 删除processOffset之后的所有文件
            // 如果processOffset > 文件的起始偏移量,则更新刷新位置、提交位置;若小于,直接删除文件
            this.mappedFileQueue.truncateDirtyFiles(processOffset);
​
            // Clear ConsumeQueue redundant data
            if (maxPhyOffsetOfConsumeQueue >= processOffset) {
                log.warn("maxPhyOffsetOfConsumeQueue({}) >= processOffset({}), truncate dirty logic files",                                                           maxPhyOffsetOfConsumeQueue, processOffset);
                this.defaultMessageStore.truncateDirtyLogicFiles(processOffset);
            }
        } else {
            // Commitlog case files are deleted
            log.warn("The commitlog files are deleted, and delete the consume queue files");
            this.mappedFileQueue.setFlushedWhere(0);
            this.mappedFileQueue.setCommittedWhere(0);
            this.defaultMessageStore.destroyLogics();
        }
    }
文件进行恢复时调用的this.checkMessageAndReturnSize(byteBuffer,checkCRCOnRecover)方法的作用

在 RocketMQ 源码中,checkMessageAndReturnSize 方法用于在恢复文件时检查消息的正确性。该方法接收一个 ByteBuffer 对象和一个布尔值 checkCRCOnRecover 作为参数。

当参数 checkCRCOnRecovertrue 时,表示在恢复文件时需要进行 CRC 校验以验证消息的正确性。CRC(循环冗余校验)是一种常用的校验技术,通过计算消息内容的校验值,并与存储在消息中的校验值进行比较,以判断消息是否在传输过程中发生了错误或丢失。

因此,在 checkMessageAndReturnSize 方法中,会对消息内容进行 CRC 校验,并返回消息的大小(消息长度)。

如果消息的 CRC 校验结果与存储的校验值相符,那么就认为消息是正确的。反之,如果校验不通过,则可能意味着消息损坏或发生错误,需要进行相应的处理,例如丢弃该消息或进行重试等操作。

这里的“消息正确”指的是消息内容经过 CRC 校验后与存储的校验值一致,即消息在传输过程中没有发生错误或丢失。

2.Broker异常停止文件恢复

异常文件恢复与正常停止文件恢复的流程基本相同,主要差别有两个:

  • 正常停止默认从倒数第三个文件开始进行恢复,而异常停止则需要从最后一个文件往前走,找到第一个消息存储正常的文件
  • 如果commitlog目录没有消息文件,而在消息消费队列目录下存在文件,则需要销毁

异常停止文件恢复步骤:

  • Step1:首先会判断文件的魔数,判断是否符合commitlog消息文件的存储格式
  • Step2:如果文件中第一条消息的存储时间等于0,返回false,说明该消息存储文件中未存储任何消息
  • Step3:对比文件的第一条消息的时间戳与检测点,如果消息的时间戳小于检测点则说明该消息可靠,从该文件开始恢复
  • Step4:找到MappedFile,遍历MappedFile的消息,验证消息的合法性,并将消息重新转发到消息消费队列与索引文件
  • Step5:如果未找到有效MappedFIle文件,则设置commitlog目录的flushedWhere、committedWhere指针都为0,并销毁消息消费队列文件

3.文件恢复总结

文件恢复主要完成flushPosition、commitedWhere指针的设置、消息消费队列最大偏移量加载到内存,并删除flushPosition之后所有的文件。

如果Broker异常停止,RocketMQ会将最后一个有效文件的所有消息重新转发到消息消费队列与索引文件中,确保不丢失消息,但会导致消息重复,RocketMQ的设计思想就是保证消息不丢失但不保证消息不会重复消费,所以消息消费业务方需要实现消息消费的幂等设计

3.7文件刷盘机制

RocketMQ的存储与读写是基于JDK NIO的内存映射(MappedByteBuffer)的,消息存储时首先将消息追加到内存,再根据配置的刷盘策略在不同时间进行刷写磁盘。

  • 如果是同步刷盘,消息追加到内存后,将同步调用MappedByteBuffer的force()方法
  • 如果是异步刷盘,在消息追加到内存后立刻返回给消息发送端

RocketMQ使用一个单独的线程按照设定的频率执行刷盘操作,通过在broker配置文件中配置flushDiskType来设定刷盘方式,默认为异步刷盘。

RocketMQ的消息存储文件Commitlog文件刷盘的实现方式为CommitLog#submitFlushRequest()方法,刷盘流程作为消息发送、消息存储的子流程,但索引文件的刷盘不是采用定时刷盘机制,而是每一次更新索引文件就会将上一次的改动刷写到磁盘

// CommitLog#submitFlushRequest
public CompletableFuture<PutMessageStatus> submitFlushRequest(AppendMessageResult result, MessageExt                                                                                                     messageExt) {
        // 同步刷盘
        if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
            if (messageExt.isWaitStoreMsgOK()) {
                // 初始化GroupCommitRequest,超时时间默认5s
                GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                        this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                // flushDiskWatcher用来监视刷盘的情况,其中的属性:
                //      private final LinkedBlockingQueue<GroupCommitRequest> commitRequests;
                flushDiskWatcher.add(request);
                // 将该GroupCommitRequest存入同步刷盘任务暂存容器requestsWrite中,使用自旋锁保证了安全性
                // 然后如果GroupCommitService线程处于等待状态,将其唤醒
                service.putRequest(request);
                return request.future();    // 返回GroupCommitRequest中的CompletableFuture对象
            } else {
                service.wakeup();   // 唤醒等待状态的GroupCommitService线程
                // 创建一个已完成的 CompletableFuture 对象,将消息发送状态设置为成功(即 PUT_OK)作为结果返回
                return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
            }
        }
        // 异步刷盘
        else {
            if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                flushCommitLogService.wakeup();
            } else  {
                commitLogService.wakeup();
            }
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    }

1.Broker同步刷盘

在消息追加到内存映射文件的内存中后,立即将数据从内存刷写到磁盘文件,同步刷盘的实现流程如下:

  • Step1:构建GroupCommitRequest同步任务并提交到GroupCommitService
  • Step2:等待同步刷盘任务完成,如果超时就返回刷盘错误,刷盘成功后正常返回给调用方

GroupCommitRequest的核心属性:

  • long nextOffset:刷盘点偏移量
  • CompletableFuture flushOKFuture:异步编程
  • long deadLine:结束时间线

GroupCommitService的核心属性:

  • private volatile LinkedList requestsWrite:同步刷盘任务暂存容器
  • private volatile LinkedList requestsRead:GroupCommitService线程每次处理的request容器,设计亮点:避免了任务提交与任务执行的锁冲突
  • private final PutMessageSpinLock lock = new PutMessageSpinLock(); :自旋锁
// GroupCommitService#putRequest
public synchronized void putRequest(final GroupCommitRequest request) {
    lock.lock();    // 之前版本是使用的synchronized(this.requestWrite)
    try {
        this.requestsWrite.add(request);
    } finally {
        lock.unlock();
    }
    this.wakeup();
}
​
// GroupCommitService#wakeup
public void wakeup() {  // 客户端提交同步刷盘任务到GroupCommitService线程,如果线程处于等待状态将其唤醒
    if (hasNotified.compareAndSet(false, true)) {
        waitPoint.countDown(); // notify
    }
}
// GroupCommitService#swapRequests
// 为了避免同步刷盘消费任务与其它消息生产者提交任务直接的锁竞争,读容器和写容器每执行一次任务后,交互,继续消费任务
private void swapRequests() {
    lock.lock();
        try {
            LinkedList<GroupCommitRequest> tmp = this.requestsWrite;
            this.requestsWrite = this.requestsRead;
            this.requestsRead = tmp;
        } finally {
            lock.unlock();
        }
}

GroupCommitService线程中的核心方法:

// GroupCommitService#run
public void run() {
    while (!this.isStopped()) {
        try {
            this.waitForRunning(10);    // 该线程每处理一批同步刷盘请求后等待10ms
            this.doCommit();
        } catch (Exception e) {
            CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
        }
    }
​
    // 在正常情况下关闭,等待请求的到来,然后刷新
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        CommitLog.log.warn(this.getServiceName() + " Exception, ", e);
    }
    
    // 每完成一次任务请求后,交换两个容器,提高并发性能
    synchronized (this) {
        this.swapRequests();
    }
​
    this.doCommit();
}
​
​
private void doCommit() {
    if (!this.requestsRead.isEmpty()) {
        // 遍历同步刷盘列表,根据顺序逐一执行刷盘逻辑
        for (GroupCommitRequest req : this.requestsRead) {          
            boolean flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
            for (int i = 0; i < 2 && !flushOK; i++) {
                // 使用mappedFileQueue.flush方法执行刷盘操作,最终调用MappedByteBuffer#force()方法
                CommitLog.this.mappedFileQueue.flush(0);
                // 如果已刷盘指针 >= 提交的刷盘点,表示刷盘成功
                flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
            }
            // 每执行刷盘操作后,唤醒消息发送线程并通知刷盘结果
            req.wakeupCustomer(flushOK ? PutMessageStatus.PUT_OK : PutMessageStatus.FLUSH_DISK_TIMEOUT);
        }
​
        long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
        if (storeTimestamp > 0) {                                                                                       // 更新刷盘检测点,但检测点的刷盘操作是在刷写消息队列ConsumeQueue文件时触发
           CommitLog.this.defaultMessageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimestamp);
        }
​
        this.requestsRead = new LinkedList<>();
    } else {
        CommitLog.this.mappedFileQueue.flush(0);
    }
}

2.Broker异步刷盘

异步刷盘根据是否开启transientStorePoolEnable机制,刷盘实现会有细微差别:

  • 如果isTransientStorePoolEnable为true,RocketMQ会单独申请一个与目标物理文件(commitlog)同样大小的堆外内存,该堆外内存将使用内存锁定,消息首先追加到堆外内存中,CommitRealTimeService线程默认每200ms将新追加的内容后提交到与物理文件的内存映射内存中,FlushRealTimeService线程默认每500ms将内存映射中新追加的内容在flush到磁盘,通过调用MappedByte -Buffer#force()方法将数据刷写到磁盘
        // CommitLog$CommitRealTimeService#run
        @Override
        public void run() {
            CommitLog.log.info(this.getServiceName() + " service started");
            while (!this.isStopped()) {
                // CommitRealTimeService线程间隔时间,默认200ms
                int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                                                          getCommitIntervalCommitLog();
                // 一次提交任务最少包含页数,如果不足则忽略本次提交任务,默认4页
                int commitDataLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                                              getCommitCommitLogLeastPages();
                // 两次真实提交最大间隔,默认200ms
                int commitDataThoroughInterval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                                        getCommitCommitLogThoroughInterval();
​
                long begin = System.currentTimeMillis();
                // 如果距上次提交间隔超过commitDataThoroughInterval,则本次提交忽略commitDataLeastPages参数,即若本次待提交数据小于最小页数,也执行提交操作
                if (begin >= (this.lastCommitTimestamp + commitDataThoroughInterval)) {
                    this.lastCommitTimestamp = begin;
                    commitDataLeastPages = 0;
                }
​
                try {
                    // 将待提交数据提交到物理文件的内存映射内存区
                    boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
                    long end = System.currentTimeMillis();
                    if (!result) {
                        this.lastCommitTimestamp = end; // result = false 意味着提交一部分数据
                        // 唤醒刷盘线程
                        flushCommitLogService.wakeup();
                    }
​
                    if (end - begin > 500) {
                        log.info("Commit data to file costs {} ms", end - begin);
                    }
                    // 等待200ms再执行下一次提交任务
                    this.waitForRunning(interval);
                } catch (Throwable e) {
                    CommitLog.log.error(this.getServiceName() + " service has exception. ", e);
                }
            }
​
            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"));
            }
            CommitLog.log.info(this.getServiceName() + " service end");
        }
// CommitLog$FlushRealTimeService#run
public void run() {
            CommitLog.log.info(this.getServiceName() + " service started");
​
            while (!this.isStopped()) {
                // 默认false,表示await等待;如果为true,表示使用Thread.sleep方法等待
                boolean flushCommitLogTimed = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                         isFlushCommitLogTimed();
                // FlushRealTimeService线程任务执行间隔
                int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                                         getFlushIntervalCommitLog();
                // 一次刷写任务至少包含页数,如果待刷写数据不足,忽略本次刷鞋任务,默认4页
                int flushPhysicQueueLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().                                                       getFlushCommitLogLeastPages();
                // 两次真实刷写任务最大间隔,默认10s
                int flushPhysicQueueThoroughInterval = CommitLog.this.defaultMessageStore.                                                                   getMessageStoreConfig().getFlushCommitLogThoroughInterval();
​
                boolean printFlushProgress = false;
​
                // Print flush progress
                long currentTimeMillis = System.currentTimeMillis();
                // 如果距上次提交间隔超过flushPhysicQueueThoroughInterval,则本次刷盘忽略flushPhysicQueueLeastPages参数,即若本次待刷写数据小于最小页数,也执行刷盘操作
                if (currentTimeMillis >= (this.lastFlushTimestamp + flushPhysicQueueThoroughInterval)) {
                    this.lastFlushTimestamp = currentTimeMillis;
                    flushPhysicQueueLeastPages = 0;
                    printFlushProgress = (printTimes++ % 10) == 0;
                }
​
                try {           // Thread.sleep不会释放锁,结束后可以继续执行
                    if (flushCommitLogTimed) {
                        Thread.sleep(interval);
                    } else {    // wait需要在同步块中调用,释放锁,将线程置于等待状态,需要手动唤醒或等时间结束
                        this.waitForRunning(interval);
                    }
​
                    if (printFlushProgress) {
                        this.printFlushProgress();
                    }
​
                    long begin = System.currentTimeMillis();
                    // 调用flush方法将内存刷写到磁盘
                    CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages);
                    long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
                    if (storeTimestamp > 0) {    
                        // 更新存储检测点文件的commitlog文件的更新时间戳
                        CommitLog.this.defaultMessageStore.getStoreCheckpoint().                                                                              setPhysicMsgTimestamp(storeTimestamp);
                    }  
            }
        }
  • 如果isTransientStorePoolEnable为false,消息直接追加到与commitlog文件直接映射的内存中,然后刷写到磁盘中
        // GroupCommitService#putRequest
        // 异步刷盘
        else {
            if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                flushCommitLogService.wakeup(); //CommitRealTimeService继承FlushCommitLogService
            } else  {
                commitLogService.wakeup();
            }
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }

3.8过期文件删除机制

RocketMQ操作CommitLog、ConsumeQueue文件基于内存映射机制,并在启动时加载commitlog、consumequeue目录下的所有文件

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

RocketMQ存在一个定时任务每隔10s会调度一次cleanFilesPeriodically,检测是否需要清除过期文件,默认10s:

  • 该方法分别执行清除消息存储文件(Commitlog文件)与消息消费队列文件(ConsumeQueue文件)
private void deleteExpiredFiles() {
    int deleteCount = 0;
    // 文件保留时间
    long fileReservedTime = DefaultMessageStore.this.getMessageStoreConfig().getFileReservedTime();
    // 删除物理文件的间隔,指定删除过程中两次删除文件的间隔时间
    int deletePhysicFilesInterval = DefaultMessageStore.this.getMessageStoreConfig().                                                                                       getDeleteCommitLogFilesInterval();
    // 第一次拒绝删除之后能保留的最大时间
    // 在该时间间隔内,每拒绝删除一次,将引用数减少1000,超过该时间间隔后,文件将被强制删除
    int destroyMapedFileIntervalForcibly = DefaultMessageStore.this.getMessageStoreConfig().                                                                                 getDestroyMapedFileIntervalForcibly();
​
    boolean timeup = this.isTimeToDelete();
    /**
    * 如果当前磁盘分区使用率大于diskSpaceWarningLevelRatio,设置磁盘不可写,并立即启动过期文件删除操作
    * 如果当前磁盘分区使用率大于diskSpaceCleanForciblyRatio,建议立即执行过期文件清除,但不会拒绝新消息的写入
    * 如果当前磁盘分区使用率低于diskSpaceCleanForciblyRatio,将恢复磁盘可写
    * 如果当前磁盘分区使用率小于diskMaxUsedSpaceRatio则返回false,表示磁盘使用率正常,否则返回false,需要执行清除过期文件
    */
    boolean spacefull = this.isSpaceToDelete();
    boolean manualDelete = this.manualDeleteFileSeveralTimes > 0;
    /**
    * RocketMQ满足以下三种情况任意之一即可继续执行删除文件操作
    *   1、指定删除文件的时间点,通过deleteWhen设置一天的固定时间执行一次删除过期文件操作,默认凌晨4点
    *   2、磁盘空间是否充足
    *   3、预留,手工触发
    */
    if (timeup || spacefull || manualDelete) {
​
        if (manualDelete)
            this.manualDeleteFileSeveralTimes--;
​
            boolean cleanAtOnce = DefaultMessageStore.this.getMessageStoreConfig().isCleanFileForciblyEnable() && this.cleanImmediately;
            fileReservedTime *= 60 * 60 * 1000;
            deleteCount = DefaultMessageStore.this.commitLog.deleteExpiredFile(fileReservedTime, deletePhysicFilesInterval,
            destroyMapedFileIntervalForcibly, cleanAtOnce);
            if (deleteCount > 0) {
            } else if (spacefull) {
                log.warn("disk space will be full soon, but delete file failed.");
            }
        }
    }

执行文件销毁与删除,从倒数第二个文件开始遍历,计算文件的最大存活时间(文件的最后一次更新时间 + 文件存活时间(72小时)),如果当前时间大于文件的最大存活时间或者需要强制删除文件(当磁盘使用超过设定的阈值),则执行MappedFIle#destory方法,清除MappedFile占有的相关资源,如果执行成功,将该文件加入到待删除文件列表中(意思是先删除内存映射文件),然后统一执行FIle#delete方法将文件从物理磁盘中删除

3.9总结

RocketMQ主要存储文件包含消息文件(commitlog)、消息消费队列文件(ConsumeQueue)、Hash索引文件(IndexFile)、检测点(checkpoint):主要用于文件刷盘、文件恢复,abort(关闭异常文件):判断Broker是否正常停止,用于文件恢复

单个消息存储文件、消息消费队列文件、Hash索引文件长度固定以便使用内存映射机制进行文件的读写操作

当消息到达CommitLog文件后,会通过ReputMessageService线程接近实时地将消息转发给消息消费队列文件与索引文件。为了安全起见,RocketMQ引入abort文件,记录Broker的停机是正常关闭还是异常关闭,在重启Broker时为了保证Commitlog文件、消息消费队列文件与Hash索引文件的正确性,采取不同的策略恢复文件。

ROcketMQ不会永久存储消息文件、消息消费队列文件,而是启用文件过期机制并在磁盘空间不足或者默认凌晨4点删除过期文件,文件默认保存72小时并且在删除文件时并不会判断该消息文件上的消息是否被消费