• RocketMQ消息存储(四) CommitLog


    RocketMQ消息存储(四) - CommitLog

    之前几篇文章都对RocketMQ消息存储的底层基本类做详解。 从本篇开始,就来到了消息存储的上层层面的存储文件对象了。

    我们知道RocketMQ 主要的消息存储文件有三种: 1. commitlog文件 2. consumeQueue文件 3.indexFile文件

    1.概述

    本篇主要讲解的是CommitLog类 ,其对应的存储文件为: ../store/commitlog

    在RocketMQ中,commitlog文件是极其究极重要的文件,其它的 consumequeue文件 和 indexfile文件 中的数据内容都是通过commitlog文件来分发写入的, 并且 这两个文件中的数据内容必须要跟 commitlog 中的数据内容保持一致。

    为了更好的形象的去理解 commitlog 与 consumequeue,indexfile的关系, 可以把他们看成 总表与子表的关系。

    • 总表:commitlog (包含所有producer发送的消息)
    • 子表:consumequeue (对commitlog的消息 进行 分类:按照 topic 和 queueId 分类)
    • 子表:indexfile (对commitlog的消息 进行分类:按照消息的 keys 进行分类)

    当要查询时:

    1. 先去子表(consumequeue/indexfile)中按照分类找到对应的消息物理偏移量。
    
    2. 再拿着消息物理偏移量 去 commitlog中去找到真实的数据。
    

    这种查询方式 有些类似于 Mysql 中的回表查( 先去非聚簇索引中查找到目标索引,再根据查到的目标索引去聚簇索引中查到真实数据)。

    本篇文章主要讲解以下几种方法:

    1. 消息数据的写入
    2. 刷盘策略
    3. 启动生命周期

    2.重要属性

    在讲解方法前,先来简单看下CommitLog中的几个重要属性:

    1. MappedFileQueue : 用于管理 ../store/commitlog 目录下的文件, 该目录下的文件都被抽象成了MappedFile对象。
    2. FlushCommitLogService: 异步刷盘服务 (刷盘时使用)
    3. AppendMessageCallback: 使用该对象 将消息 写入到 MappedFile文件中
    4. topicQueueTable: 管理 key:主题-队列 value: 偏移量 的映射表
    5. putMessageLock: 写入消息到文件中时 使用的锁
    6. beginTimeInLock: 记录写入消息时加锁的开始时间
    7. MESSAGE_MAGIC_CODE: 正常消息魔法值
    8. BLANK_MAGIC_CODE: 文件尾消息魔法值

    这里先做个大概的介绍, 在脑子里有个印象,其具体的作用会在后面详解方法中会再进行详细说明。

    3.文件写入

    在讲解该文件写入方法之前,先详细说明一下 需要使用到的几个重要属性:MESSAGE_MAGIC_CODEBLANK_MAGIC_CODEputMessageLock

    3.1 消息协议

    RocketMQ中针对消息有一套复杂的消息协议编码:

    在向 文件中写入一条条消息时:

    1. 正常能够写入的消息其MAGICCODE 为 MESSAGE_MAGIC_CODE
    2. 当文件快要写满时,此时向文件中写入一条消息,会判断该消息的长度 不足以写到该文件剩余的空间中,因此会直接接着在文件尾写入 8byte的数据为: 文件还剩多少空间(4byte) + BLANK_MAGIC_CODE(4byte)

    BLANK_MAGIC_CODE 这个有什么用呢?

    在之后读取文件的一条条消息时,会先读消息的MAGICCODE ,若MAGICCODEBLANK_MAGIC_CODE ,则说明此时读到了该文件的末尾了,需要读下一个文件了。 如下图所示:

    3.2 写消息锁

    RocketMQ 中的消息写入是顺序写的, 因此为了保证顺序性,会在写消息时 进行 加锁 putMessageLock.

    而 此 putMessageLock 有两种加锁方式:

    1. 基于AQS的ReetrantLock (默认)
    2. 基于CAS的自旋锁

    这两种锁的区别 主要根据 并发场景下来进行选择, 在高并发的场景下选择 方式1 能够 保持系统的稳定性能。在并发度很低的场景下 选择方式2会更加提升性能, 因为持有方式1的锁过程对于系统来说是个重量级的操作。

    在官方文档中建议用户 在同步刷盘时采用独占锁, 异步刷盘时采用自旋锁.

    3.3 消息写入流程

    消息写入的方法入口为 asyncPutMessage(final MessageExtBrokerInner msg)

    1. 第一次消息整理。 (设置存储时间、CRC值)
    2. 加锁
    3. 获取当前正在顺序写的MappedFile 或 创建新的MappedFile(这一步在后面会详细讲解)
    4. 向上一步获取的MappedFile文件中 写入消息,这是消息写入的核心入口。 (这一步在后面也会详细讲解)
    5. 解锁
    6. 通知刷盘服务,进行刷盘操作 (这一步在后面详细讲解)
    7. HA同步
    8. 返回写入消息的结果

    3.4 MappedFile异步创建

    在消息写入流程的第3条,会获取当前正在顺序写的MappedFile

     mappedFile = this.mappedFileQueue.getLastMappedFile(0); 
    

    这个方法接口属于 MappedFileQueue 中的,之前讲过。 但是并没有针对于 其中通过 allocateMappedFileService 服务异步创建MappedFile的方法做详细的讲解。

    if (this.allocateMappedFileService != null) { 
      mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
      nextNextFilePath, this.mappedFileSize);
    }
    

    所谓异步创建文件,就是通过 allocateMappedFileService 服务中的线程来为我们创建新的MappedFile,如下图:

    异步创建MappFile

    putMsg线程 会先将创建文件的操作 委托给 allocateMappedFileService线程来做,自己进入阻塞态,当allocateMappedFileService线程将MappedFile文件创建完毕后,会唤醒putMsg线程继续后面的文件写入操作。

    而在 allocateMappedFileService线程中有两种 创建 MappedFile 的方式 ,这取决于一个配置变量 transientStorePoolEnable ,如果该配置项为 true,说明 启用堆外缓冲池, 自然false 就是不使用堆外缓冲池了。 下面会针对是否开启堆外缓冲池的两种创建MappedFile的方式做详细讲解

    if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
        // 方式一: 启用堆外缓冲池
        try {
            // SPI 反射创建 MappedFile
            mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
            mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
        } catch (RuntimeException e) {
           //...
        }
    } else {
        // 方式二: 不启用堆外缓冲池  普通方式创建
        mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
    }
    

    3.4.1 不开启堆外缓冲池

    此方式就是 普通创建 MappedFile ,底层使用的是 MappedByteBuffer 来控制文件的写入操作。

    • 第一次启动的时候,allocate线程会先后创建2个文件, 第一个文件创建完毕后,便会返回putMsg线程并唤醒它,然后allocate下次你哼进而继续异步创建下一个文件
    • 接下来请求allocate线程都会将已经创建好的文件直接返回给putMsg线程,然后继续异步创建下一个文件,这样便真正实现了异步创建文件的效果。

    注意:此方式仅仅是创建申请了 1G大小的 MappedByteBuffer 内存映射缓冲区, 还没有对缓冲区做 预热操作(耗时的操作)。

    这个预热操作,会在下一节详细讲解。

    3.4.2 开启堆外缓冲池

    堆外缓冲池,所谓池,就是可重复利用的堆外缓冲区。

    img

    在Broker启动阶段, 会根据配置决定是否创建 堆外缓冲池

            if (messageStoreConfig.isTransientStorePoolEnable()) {
                this.transientStorePool.init();
            }
    

    若开启堆外缓冲池,则会拉长Broker的启动时长。代码注释中也标明了 该方法是重量级初始化操作。

        /**
         * It's a heavy init method.   
         */
        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);
            }
        }
    

    实际上 主要耗时的操作并不是 开辟内存的代码 base = unsafe.allocateMemory(size); ,而是在开辟内存后的预热操作 unsafe.setMemory(base, size, (byte) 0); ,它会为内存区域附上0值。

    至此 就生成了 一个堆外缓冲池,默认里面有5个 1G 大小的 DirectByteBuffer缓冲区

    在创建 MappedFile时, 通过调用其 init 方法 ,让MappedFile从池中获取一个 直接内存缓冲区DirectByteBuffer, 在之后的文件写入过程中就会使用该缓冲区。

        public void init(final String fileName, final int fileSize,
            final TransientStorePool transientStorePool) throws IOException {
            init(fileName, fileSize);
            // 从池中 获取 堆外内存缓冲区 DirectByteBuffer
            this.writeBuffer = transientStorePool.borrowBuffer();
            this.transientStorePool = transientStorePool;
        }
    

    小总结: 开启堆外缓冲池的优点和缺点:

    • 优点: 缓冲区可重复利用, 且缓冲区都是预热过的
    • 缺点: 创建堆外缓冲池 很 耗时。

    3.1.3 文件预热

    什么是文件预热? 为什么要做文件预热?

    答: 可结合 《RocketMQ消息存储(一) - mmap内存映射原理》 来体会。

    因为 MappeFile底层是 通过 MappedByteBuffer 与 文件建立了 mmap内存映射关系。

    而此时 物理内存 上并没有 与 文件有实质上的 地址映射,如果就这样的方式 read() 或write() 就会产生缺页中断异常,会影响 read() 和 write()的效率。

    因此就需要通过 提前写入 一些 默认值(0) 来让物理内存与 文件建立好关系, 避免在 read() 和 write() 的过程中产生中段异常。

    说白了就是为后面的 读写 提升效率。

    allocateMappedFileService线程 创建完成 MappedFile 文件后,需要对该MappeFile文件做预热操作。

    是否需要文件预热通过配置 warmMappedFileEnable 来开启和关闭 (默认是开启状态)。

    allocateMappedFileService服务线程中,文件预热的代码入口:

    /**
     *  参数1: type  刷盘的方式(FlushDiskType.ASYNC_FLUSH)
     *  参数2: pages 一页的大小 4K
     */
    mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
    this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
    

    此时就会调用 MappedFilewarmMappedFile() 文件预热方法。

            for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
               // 每隔1页 往里面写一个0
                byteBuffer.put(i, (byte) 0);
            }
    		mappedByteBuffer.force();
    

    往每一页(4K) 写入一个0,通过该方式 完成 文件的预热。

    同时 会锁住 该文件在物理内存的地址空间,防止SWAP交换影响性能。

    this.mlock();
    
    public void mlock() {
        final long beginTime = System.currentTimeMillis();
        final long address = ((DirectBuffer) (this.mappedByteBuffer)).address();
        Pointer pointer = new Pointer(address);
        {
            int ret = LibC.INSTANCE.mlock(pointer, new NativeLong(this.fileSize));
            log.info("mlock {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
        }
    
        {
            int ret = LibC.INSTANCE.madvise(pointer, new NativeLong(this.fileSize), LibC.MADV_WILLNEED);
            log.info("madvise {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
        }
    }
    
  • 相关阅读:
    leetcode-62-Unique Paths
    [leetcode-64-Minimum Path Sum]
    [leetcode-198-House Robber]
    [leetcode-120-Triangle]
    [leetcode-53-Maximum Subarray]
    [leetcode-303-Range Sum Query
    [leetcode-123-Best Time to Buy and Sell Stock III]
    绑定下拉框
    解决URL参数中文乱码
    html 图片拖动不出来的脚本
  • 原文地址:https://www.cnblogs.com/s686zhou/p/15985967.html
Copyright © 2020-2023  润新知