RocketMQ消息存储(二) - MappedFile

RocketMQ消息存储(二) - MappedFile

MappedFile 类是RocketMQ消息存储模块中最底层的类, 它是对 MappedByteBuffer(mmap) 的进一步封装,能够更方便的去操作和使用 mmap零拷贝(不理解的请移步上一篇《RocketMQ消息存储(一) - 零拷贝IO》),加快底层 IO的读写效率。

1. ReferenceResource 引用计数

首先来看下 MappedFile的继承体系图,如下:

继承了 ReferenceResource 引用计数类, 这个实现方式 在Netty的内存池技术中也有看到过。

由于MappedFile 底层使用的是MappedByteBuffer(mmap 零拷贝),自然涉及到了堆外内存,因此ReferenceResource存在的目的就是为了 控制管理好 MappedFile 对象的回收,防止内存泄漏

直接来看代码:

public abstract class ReferenceResource {

    // refCount 引用数量  初始值为1
    //    当 refCount <=0时 表示该资源可以释放了,没有任何其它程序依赖它了
    protected final AtomicLong refCount = new AtomicLong(1);

    // 是否存活, 默认值 true
    //    当 available = false时,表示资源处于非存活状态,不可用
    protected volatile boolean available = true;

    // 是否已经清理,默认值false. 
    //    当 执行完子类对象的cleanUp()后,该值会设置为true 表示资源已经全部释放了
    protected volatile boolean cleanupOver = false;
    
    // 第一次 尝试关闭资源的时间
    private volatile long firstShutdownTimestamp = 0;


    /**
     *  增加引用计数方法  refCount+1
     * @return  true 增加成功   false 增加失败
     */
    public synchronized boolean hold() {
        if (this.isAvailable()) {
            if (this.refCount.getAndIncrement() > 0) {
                return true;
            } else {
                this.refCount.getAndDecrement();
            }
        }

        return false;
    }
	
    // 判断资源是否存活
    public boolean isAvailable() {
        return this.available;
    }


    /**
     *  关闭资源
     * @param intervalForcibly 强制关闭资源的时间间隔
     */
    public void shutdown(final long intervalForcibly) {
        if (this.available) {
            this.available = false;
            // 初次关闭资源时 的系统时间
            this.firstShutdownTimestamp = System.currentTimeMillis();
            // 引用计数 -1  (资源有可能释放了 ,也有可能没释放)
            this.release();
        }

        // 执行到这  说明第一次关闭资源时,并没有释放完资源
        else if (this.getRefCount() > 0) {
            if ((System.currentTimeMillis() - this.firstShutdownTimestamp) >= intervalForcibly) {
                // 强制设置 引用计数为 负数
                this.refCount.set(-1000 - this.getRefCount());
                // 此时一定会释放资源
                this.release();
            }
        }
    }

	// 减少引用计数  refCount-1 
    public void release() {
        long value = this.refCount.decrementAndGet();
        if (value > 0)
            return;

        // 执行到这  说明 当前资源已经没有任何程序占用了, 可以调用cleanUp释放真正的资源了
        synchronized (this) {

            this.cleanupOver = this.cleanup(value);
        }
    }

    public long getRefCount() {
        return this.refCount.get();
    }

    // 子类实现cleanup 方法
    public abstract boolean cleanup(final long currentRef);

    public boolean isCleanupOver() {
        return this.refCount.get() <= 0 && this.cleanupOver;
    }
}

上述代码 不长,也就100多行, 逻辑也很通俗易懂。 实际上就是通过 refCount 字段来控制 子类资源的 使用情况。

如果我们之后自己业务上的代码 有相关 控制资源 的需求 可以将 该类拿来直接使用,并不需要做过多的修改。

2. MappedFile

1.属性

	
	// 内存页大小: 4K   常量
    public static final int OS_PAGE_SIZE = 1024 * 4;

  	// 当前进程下  所有的 mappedFile 占用的总虚拟内存大小
    private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);

  	// 当前进程下 所有 mappedFile对象的个数
    private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);


    /**
     *  0 ---------flushedPosition -------- wrotePosition --------- end
     *       |                        |
     *    安全数据                    脏页
     */

	
	// 数据写入位点
    protected final AtomicInteger wrotePosition = new AtomicInteger(0);
    protected final AtomicInteger committedPosition = new AtomicInteger(0);

	// 数据刷盘位点
    private final AtomicInteger flushedPosition = new AtomicInteger(0);

	// 文件大小
    protected int fileSize;

	// 文件通道
    protected FileChannel fileChannel;
	
	// 下面两个变量  不常用,用到了再说
    protected ByteBuffer writeBuffer = null;
    protected TransientStorePool transientStorePool = null;

	// 文件名
    private String fileName;

  	// 文件名转long值, 实际上就是当前文件的 物理偏移量
    private long fileFromOffset;

  	// 文件对象
    private File file;

   	// 内存映射缓冲区 (核心大佬)
    private MappedByteBuffer mappedByteBuffer;

 	// 记录保存最后一条消息的时间戳
    private volatile long storeTimestamp = 0;

 	//  是否是 首文件
	//   (当前mappedFile 如果是 MapppedFileQueue的首文件的话,该值为true)
    private boolean firstCreateInQueue = false;

2. 构造方法

    /**
     * @param fileName  文件名(绝对路径文件名)
     * @param fileSize  文件大小
     * @throws IOException
     */
    public MappedFile(final String fileName, final int fileSize) throws IOException {
        init(fileName, fileSize);
    }

内部又调用了 init() 方法,是为了根据该文件初始化出上述的属性值。

    /**
     * @param fileName  文件名(绝对路径文件名)
     * @param fileSize  文件大小
     * @throws IOException
     */
    private void init(final String fileName, final int fileSize) throws IOException {
        this.fileName = fileName;
        this.fileSize = fileSize;

        // 创建文件对象
        this.file = new File(fileName);

        // 文件名转Long
        this.fileFromOffset = Long.parseLong(this.file.getName());
        boolean ok = false;

        // 判断该文件的 父目录是否存在,若不存在则创建
        ensureDirOK(this.file.getParent());

        try {
            // 创建文件通道 (JDK NIO)
            this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();

            // 获取内存映射缓冲区
            this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);

            // 增加  当前进程下 所有的mappedFile 占用的总虚拟内存
            TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);

            // 增加 当前进程下 所有的 mappedFile 个数
            TOTAL_MAPPED_FILES.incrementAndGet();
            ok = true;
        } catch (FileNotFoundException e) {
            log.error("Failed to create file " + this.fileName, e);
            throw e;
        } catch (IOException e) {
            log.error("Failed to map file " + this.fileName, e);
            throw e;
        } finally {
            if (!ok && this.fileChannel != null) {
                this.fileChannel.close();
            }
        }
    }

逻辑很简单, 从这段代码中,能够学习下 如何生成 MappedByteBuffer 对象的。

  1. 通过RandAccessFile对象,获得文件通道 : this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
  2. 根据文件通道映射出内存映射缓冲区this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);

3. 核心方法

1. 追加字节数组

    /**
     *
     * @param data  需要写入到 文件的 字节数组
     * @return  true 写入成功     false 写入失败
     */
    public boolean appendMessage(final byte[] data) {
        int currentPos = this.wrotePosition.get();


        // 条件成立: 说明当前文件 可以写入 该data数据
        if ((currentPos + data.length) <= this.fileSize) {
            try {
                this.fileChannel.position(currentPos);
                this.fileChannel.write(ByteBuffer.wrap(data));
            } catch (Throwable e) {
                log.error("Error occurred when append message to mappedFile.", e);
            }
            // 更新 MappedFile对象的 数据写入位点
            this.wrotePosition.addAndGet(data.length);
            return true;
        }

        return false;
    }

该方法目的是 向文件中写入字节数组 实际上就是通过 FileChannel 文件通道的 write 方法实现的, 底层就是普通IO操作。

2. 追加消息对象

    /**
     *  像文件中写入消息对象数据
     * @param messageExt  消息对象数据
     * @param cb   追加消息回调 (消息具体哪些字段 需要追加到文件中 都由它控制)
     * @return
     */
    public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
        assert messageExt != null;
        assert cb != null;

        //  获取当前 内存映射文件的 写入位点
        int currentPos = this.wrotePosition.get();

        // 条件成立: 说明 文件还可以继续写
        if (currentPos < this.fileSize) {

            // 使用 内存映射buffer 创建切片
            ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();

            // 将 切片byteBuffer 的pos 设置为 写入位点
            byteBuffer.position(currentPos);


            AppendMessageResult result;


            if (messageExt instanceof MessageExtBrokerInner) {

                // 向内存映射中 追加数据
                result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
            } else if (messageExt instanceof MessageExtBatch) {
                result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
            } else {
                return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
            }

            // 更新数据写入位点: 原写入位点 + 刚刚写入的数据量
            this.wrotePosition.addAndGet(result.getWroteBytes());

            // 更新存储最后一条消息的 存储时间
            this.storeTimestamp = result.getStoreTimestamp();
            return result;
        }
        log.error("MappedFile.appendMessage return null, wrotePosition: {} fileSize: {}", currentPos, this.fileSize);
        return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
    }

该方法主要是 面向 **MessageExt **特定消息对象 的, 主要做了Buffer的切片拷贝,更新写入点等操作, 实际上的 核心追加逻辑 是在 AppendMessageCallback 对象中, 该对象会在后面的文章中来做详细分析。

3. 刷盘

    /**
     * @param  flushLeastPages 刷盘的最小页数 (等于0时:属于强制刷盘  >0时:需要脏页数据达到flushLeastPages才进行刷盘)
     * @return 当前最新的刷盘位点
     */
    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();  //mmap
                    }
                } catch (Throwable e) {
                    log.error("Error occurred when force data to disk.", e);
                }

                // 将数据写入位点 赋值给 刷盘点
                this.flushedPosition.set(value);

                // 引用计数 -1
                this.release();
            } else {
                log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
                this.flushedPosition.set(getReadPosition());
            }
        }

        // 返回最新的刷盘点
        return this.getFlushedPosition();
    }

其中 isAbleToFlush() 方法解析如下:

    /**
     *
     * @param flushLeastPages  刷盘的最小页数 (等于0时:属于强制刷盘  >0时:需要脏页数据达到flushLeastPages才进行刷盘)
     * @return
     */
    private boolean isAbleToFlush(final int flushLeastPages) {

        // 获取当前刷盘位点
        int flush = this.flushedPosition.get();

        // 获取当前写入位点
        int write = getReadPosition();

        // 文件写满 这种情况 一定返回true 执行刷盘
        if (this.isFull()) {
            return true;
        }

        // 条件成立: 需要根据 可刷盘页数进行限制 刷盘操作
        if (flushLeastPages > 0) {
            //  true: 脏页数据 大于 可刷盘页数
            return ((write / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE)) >= flushLeastPages;
        }

        // 执行到这里 说明 flushLeastPages == 0
        // 只要有脏页 就刷盘
        return write > flush;
    }

上述刷盘操作中, 核心的 代码 就一句 this.mappedByteBuffer.force();

这里 就引出一个问题: mmap 是 直接把 MappedByteBuffer 中的数据写入到磁盘的吗?

答案: 不是, 之前通过 追加消息的方法 实际上是将数据 写到了 虚拟内存中,而虚拟内存是依赖于操作系统的定时刷盘的, 但是也可以通过 mappedByteBuffer.force() 接口来手动控制刷盘。

4. 访问数据

   /**
     *  该方法以 pos 为开始位点,到有效数据为止, 创建出一个切片byteBuffer,供业务访问数据使用
     * @param pos  数据起始位点
     * @return
     */
	public SelectMappedBufferResult selectMappedBuffer(int pos) {

        // 获取 有效数据位点 (wrotePosition)
        int readPosition = getReadPosition();

        // 条件成立: 说明pos处于 有效数据区间
        if (pos < readPosition && pos >= 0) {

            if (this.hold()) { // 资源引用计数+1

                // 切片
                ByteBuffer byteBuffer = this.mappedByteBuffer.slice();

                // 设置 切片的position 为 pos位点
                byteBuffer.position(pos);

                // 从pos 开始 的有效数据大小
                int size = readPosition - pos;

                // 按照当前切片  再次 切出一个 新的切片
                ByteBuffer byteBufferNew = byteBuffer.slice();
                // 设置 新的切片的 limit 为数据大小
                byteBufferNew.limit(size);

                return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
            }
        }

        return null;
    }

上述代码的功能是 , 需要传入一个 访问起始位点pos

  1. 先通过切片的方式, 将整个 mappedByteBuffer 切出来 得到 byteBuffer
  2. byteBufferpostition 设置为 pos 起始位点 ,再进行切片, 此时就会从 pos起始位点的地方开始切到末尾 ,得到 byteBufferNew
  3. 再根据 有效数据量大小byteBufferNew 设置好 limit, 就得到最终的 内存区域buffer了。

4.总结

先上一张图:

上图为 MappedFile 内部的核心属性和方法。

总的来说, MappedFile 类 底层操作 就是 MappedByteBuffer

我相信 肯定有人会有疑问:
为什么不直接操作 MappedByteBuffer,而要将MappedByteBuffer再封装一层变为MappedFile 呢?

  1. MappedByteBuffer对于 java而言,其使用的是堆外内存。 因此很难控制其内存的回收,一但操作失误就会引起内存泄漏问题 , 因此通过MappedFile的父类ReferenceResource 来控制。
  2. MappedByteBuffer 在put数据后,实际上是将数据写到了虚拟内存上(可以近似理解成PageCache,但不是), 而虚拟内存是依赖于操作系统来定时刷盘的, 因此我们在写完数据后 需要通过 MappedFile #flush 方法(其内部仍是调用了 MappedByteBuffer.force() ) 来手动控制刷盘操作。
  3. 由于MappedByteBuffer 中 写入读取数据等 api 使用起来挺复杂的, 因此MappedFile 中维护了 flushedPosition(刷盘位点)wrotePosition(写入位点) ,能够更方便的进行读写操作。
  4. MappedFile 中 还维护了 文件的名称,文件的物理偏移量, 文件的对象,文件通道 等重要属性,会方便之后的 类来获取有用的信息。
posted @ 2022-03-02 16:43  s686编程传  阅读(403)  评论(0编辑  收藏  举报