TransientStorePool堆外缓存池技术

在什么场景下要开启TransientStorePool(技术为解决问题而出现)

在 RocketMQ 中,TransientStorePool 是一种优化磁盘 I/O 性能的机制。它通过预分配内存块,将消息写入预分配的内存块(直接内存),然后使用内存映射文件(Memory-Mapped File)将内存块中的数据刷到磁盘,从而提高写入性能。因此,当你需要提高 RocketMQ 的消息写入性能时,可以考虑开启 TransientStorePool

以下是开启 TransientStorePool 的一些具体场景:

  1. 高性能场景:对于具有较高消息吞吐量要求的场景,开启 TransientStorePool 可以减少磁盘 I/O,提高写入性能。

  2. 大量写入操作:在大量写入操作的场景下,使用 TransientStorePool 可以减少频繁的系统内存分配和回收操作,从而提高性能。

  3. 集群环境:在集群环境中,开启 TransientStorePool 可以帮助提高整个集群的性能和稳定性,尤其是在集群节点较多时。

  4. 高峰期:在系统高峰期(比如双十一、黑五等)时,消息流量和写入操作可能会急剧增加。开启 TransientStorePool 可以帮助应对这种临时的高负载压力,提高整个系统的处理能力。

  5. 异步刷盘:在异步刷盘的场景下,将消息先写入内存缓冲区可以提高响应速度。开启 TransientStorePool 可以使得消息先被写入直接内存,然后通过后台刷盘服务异步写入磁盘,从而降低延迟。

  6. 低延迟要求:对于对延迟有严格要求的场景,开启 TransientStorePool 可以减少磁盘 I/O 操作,降低消息写入的延迟。

既然MMP可以解决写入性能问题,为什么还会出现TransientStorePool

1.减少 GC 压力:

单独使用 MMP(内存映射文件)确实可以降低 GC 压力,因为 MMP 使用的是堆外内存。当文件被映射到内存地址空间时,文件的读写操作实际上是在内存中进行的,这部分内存位于堆外,不受 Java 垃圾回收(GC)机制的影响。

然而,在实际应用中,仅依赖 MMP 可能无法完全避免 GC 压力。这是因为在某些场景中,如高并发写入时,仍然需要在堆内存中创建临时对象,这些对象会受到 GC 的影响。

当我们结合使用 TransientStorePool 和 MMP 时,可以进一步降低 GC 压力。RocketMQ 将消息写入 TransientStorePool 中的 DirectByteBuffer(直接内存缓冲区),DirectByteBuffer 是一种堆外内存,不受 GC 影响。这样,写入操作实际上发生在堆外内存,既减少了堆内存的分配和回收操作,也降低了 GC 压力。

2.异步刷盘:使用 TransientStorePool 和 MMP 结合,可以实现异步刷盘。首先,将消息写入 TransientStorePool 中的内存块,然后,将这些内存块映射到 MMP。最后,通过后台刷盘服务将内存中的数据异步写入磁盘。这样可以降低磁盘 I/O 的延迟,提高响应速度。

TransientStorePool技术的设计原理

RocketMQ中的TransientStorePool设计是为了提高消息写入磁盘的性能。它的原理主要基于两个技术:内存池和内存映射文件(Memory Mapped File,简称MMAP)。

  1. 内存池:TransientStorePool是一个预先分配的固定大小的内存池。它用来暂存消息数据,以减少频繁的系统内存分配和回收,从而提高性能。当生产者发送消息时,消息首先会被写入到这个内存池中,而不是直接写入到磁盘。内存池中的每个内存块大小与RocketMQ中CommitLog文件的一个映射内存块大小相同。

  2. 内存映射文件(MMAP):MMAP是一种将文件或者文件的一部分映射到进程内存地址空间的技术。RocketMQ使用MMAP技术将CommitLog文件映射到内存中,这样就可以通过直接操作内存来读写CommitLog文件,避免了磁盘IO操作的性能开销。

结合这两个技术,RocketMQ的TransientStorePool设计实现了以下工作流程:

  1. 当生产者发送消息时,消息首先被写入到内存池中的一个内存块。
  2. 当内存块被写满时,将整个内存块的数据通过MMAP的方式刷入到CommitLog文件对应的映射内存块中。
  3. 最后,通过调用操作系统的msync方法,将映射内存中的数据刷入磁盘。

这种设计可以有效地减少磁盘IO操作,提高RocketMQ的消息写入性能。同时,由于内存池和MMAP的使用,RocketMQ可以充分利用操作系统的缓存机制,进一步优化性能。

TransientStorePool的代码分析 

RocketMQ的TransientStorePool设计主要通过内存池和MMAP技术将消息先写入内存池中的内存块,然后将内存块的数据刷入MMAP内存,最后再将MMAP内存的数据刷入磁盘。这种设计提高了RocketMQ的消息写入性能。RocketMQ的TransientStorePool实现主要位于MappedFileQueue.javaMappedFile.java两个文件中。

  1. MappedFileQueue.java

MappedFileQueue管理着MappedFile的集合,每个MappedFile对应于一个CommitLog文件。使用TransientStorePool时,需要设置MappedFileQueueuseTransientStorePool属性为true

private final boolean useTransientStorePool;

public MappedFileQueue(final String storePath, final int mappedFileSize, final TransientStorePool transientStorePool) {
    this.storePath = storePath;
    this.mappedFileSize = mappedFileSize;
    this.transientStorePool = transientStorePool;
    this.useTransientStorePool = transientStorePool != null;
}

 2. MappedFile.java

MappedFile类表示一个映射到内存的文件,其主要功能是通过内存映射文件(MMAP)的方式来读写CommitLog文件。

当启用TransientStorePool时,MappedFile会将消息先写入内存池中的内存块。为实现这个功能,MappedFile类定义了一个ByteBuffer类型的属性writeBuffer,该属性用于指向内存池中的内存块。

// If TransientStorePool enabled, first write the data to the buffer, then write the data to the file
protected ByteBuffer writeBuffer = null;

在构造函数中,如果启用了TransientStorePool,会从内存池中分配一个内存块,并将writeBuffer指向这个内存块。

public MappedFile(final String fileName, final int fileSize, final TransientStorePool transientStorePool) {
    init(fileName, fileSize);
    if (transientStorePool != null) {
        this.writeBuffer = transientStorePool.borrowBuffer(fileSize);
    }
}

MappedFile类的appendMessage方法负责将消息写入内存。如果启用了TransientStorePool,消息会被写入writeBuffer指向的内存块中;否则,消息会直接写入MMAP的内存。

public boolean appendMessage(final byte[] data) {
    int currentPos = this.wrotePosition.get();

    // Ensure remaining space is sufficient
    if ((this.fileSize - currentPos) >= data.length) {
        ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
        byteBuffer.position(currentPos);
        byteBuffer.put(data);
        this.wrotePosition.addAndGet(data.length);
        return true;
    }

    return false;
}

最后,在MappedFile类的flush方法中,会将writeBuffer的数据刷入MMAP内存,并调用msync方法将MMAP内存的数据刷入磁盘。

public int flush(final int flushLeastPages) {
    if (this.writeBuffer == null || !this.hold()) {
        return 0;
    }

    int value = getReadPosition();

    // Ensure there is data to be flushed
    if (value > 0) {
        ByteBuffer byteBuffer = writeBuffer.slice();
        byteBuffer.position(0);
        byteBuffer.limit(value);
        this.mappedByteBuffer.position(0);
        this.mappedByteBuffer.put(byteBuffer);
        this.mappedByteBuffer.force();
    }

    this.committedPosition.set(value);
    this.release();
    return value;
}

this.mappedByteBuffer.force(); 详细代码实现

this.mappedByteBuffer.force() 是Java NIO中MappedByteBuffer类的一个方法。它的作用是将内存映射文件中已修改的部分刷新到磁盘。下面我们深入了解一下force()方法的实现。

首先,MappedByteBufferjava.nio包下的一个类,它继承自ByteBuffer类。MappedByteBufferforce()方法实际上是一个本地方法。在MappedByteBuffer类中,你可以看到如下声明:

public abstract class MappedByteBuffer extends ByteBuffer {
    // ...
    public final native MappedByteBuffer force();
    // ...
}

force()方法是本地方法,这意味着它的实现是与平台相关的,并且是用C/C++编写的。在OpenJDK HotSpot虚拟机中,force()方法的实现可以在源码中的src/hotspot/share/prims/Unsafe.cpp文件中找到。

其实现如下:

UNSAFE_ENTRY(void, Unsafe_Force(JNIEnv *env, jobject unsafe, jobject obj)) {
  UnsafeWrapper("Unsafe_Force");
  if (UseDirectByteBuffer) {
    oop buf = JNIHandles::resolve(obj);
    jlong address = java_nio_Buffer::address_value(buf);
    size_t capacity = (size_t)java_nio_Buffer::capacity(buf);
    if (FlushViewOfFile((LPVOID)address, capacity) == 0) {
      DWORD err = GetLastError();
      const char* msg = os::strdup(env->GetStringUTFChars(JNU_NewStringPlatform(env, "FlushViewOfFile"), NULL));
      THROW_MSG_0(vmSymbols::java_io_IOException(), msg);
    }
  }
  return;
}
UNSAFE_END

Unsafe_Force 函数的主要逻辑是调用Windows API中的 FlushViewOfFile 函数,将映射文件的内存缓冲区的数据刷新到磁盘。这里需要注意的是,FlushViewOfFile 是Windows平台下的API。其他平台(如Linux)可能会使用不同的API,但实现的功能是一致的。

总之,this.mappedByteBuffer.force(); 在Java NIO中的实现其实是一个本地方法,它会调用对应平台的API来将内存映射文件中已修改的部分刷新到磁盘。这确保了数据的持久化和一致性。

分析一下 this.release(); 

this.release() 方法是 MappedFile 类中的一个方法,它用于减少 MappedFile 对象的引用计数。在 RocketMQ 中,MappedFile 对象采用引用计数的方式来管理其资源。如果引用计数变为 0,则表示该 MappedFile 对象没有被其他线程使用,可以安全地进行资源回收。

MappedFile 类中,定义了一个原子整型变量 available 作为引用计数。available 变量的初始值为 1,表示当前有 1 个引用在使用这个 MappedFile 对象。

// The number of available
private final AtomicInteger available = new AtomicInteger(1);

接下来,我们看 release() 方法的实现:

public boolean release() {
    int value = this.available.decrementAndGet();
    if (value == 0) {
        // No other threads are using this MappedFile, it can be safely closed and resources can be released
        this.firstShutdownTimestamp = System.currentTimeMillis();
        if (this.appendMessageCallback != null) {
            this.appendMessageCallback.shutdown();
        }

        // Wait a short time to see if the file is still being used by other threads
        if ((((System.currentTimeMillis() - this.firstShutdownTimestamp)) >= this.fileReservedTime)
            || (0 == this.available.get())) {
            if (this.mappedByteBuffer != null) {
                clean(this.mappedByteBuffer);
                this.mappedByteBuffer = null;
            }

            if (this.fileChannel != null) {
                try {
                    this.fileChannel.close();
                } catch (IOException e) {
                    log.error("close file channel error.", e);
                }
                this.fileChannel = null;
            }

            log.info("this file[REF:{}] {} {}", this.available.get(), this.fileName, (this.firstShutdownTimestamp + this.fileReservedTime - System.currentTimeMillis()));
            return true;
        }
    } else if (value < 0) {
        this.available.set(0);
    }
    return false;
}

release() 的主要逻辑如下:

  1. 将 available 的值减 1,如果减少后的值为 0,则表示没有其他线程正在使用这个 MappedFile 对象。
  2. 如果满足条件(没有其他线程使用该对象),则记录当前的时间戳作为 firstShutdownTimestamp
  3. 如果文件保留时间已过或者引用计数为 0,那么执行下面的资源释放操作:
    • 调用clean(this.mappedByteBuffer); 清理 MappedByteBuffer 对象,将其置为 null
    • 关闭 FileChannel,将其置为 null
    • 输出日志,表示该文件资源已经被释放。
  4. 如果减少引用计数后的值小于 0,将 available 的值设置为 0。

总之,this.release(); 方法用于减少 MappedFile 对象的引用计数,当引用计数为 0 时,表示该对象没有被其他线程使用,可以安全地进行资源回收。这样可以确保资源的有效利用,避免资源泄漏。

MappedFile什么时候归还内存块给内存池

MappedFile 类中的 clean() 方法只是负责清理和释放映射内存块,而非归还内存给内存池。归还内存给内存池的逻辑应该在 MappedFile 类中的 shutdown() 方法里实现。

public void shutdown(final long intervalForcibly) {
    this.shutdown.set(true);
    if (this.hold()) {
        this.release();
    } else {
        int value = this.available.get();
        if (value > 0) {
            // wait some time to try to shutdown again
            if (this.shutdown.get() && (System.currentTimeMillis() - this.firstShutdownTimestamp) > intervalForcibly) {
                this.release();
            }
        }
        if (this.writeBuffer != null && this.isTransientStorePoolEnable) {
            this.transientStorePool.returnBuffer(writeBuffer);
            this.writeBuffer = null;
        }
    }
}

shutdown() 方法的逻辑如下:

  1. 设置 shutdown 标志位为 true
  2. 如果当前 MappedFile 对象被持有(引用计数大于 0),则调用 release() 方法,释放映射内存块和文件通道等资源。
  3. 如果当前 MappedFile 对象未被持有(引用计数等于 0),则直接归还从内存池中借用的内存块:调用 transientStorePool.returnBuffer(writeBuffer) 方法,将 writeBuffer 归还给内存池,并将 writeBuffer 设置为 null

this.transientStorePool.returnBuffer(writeBuffer); 代码实现

this.transientStorePool.returnBuffer(writeBuffer) 是将之前从 TransientStorePool 借用的内存块归还回去。这个操作在 MappedFile 的资源回收过程中完成。具体的归还操作是在 TransientStorePool 类中实现的。

以下是 TransientStorePool 类中的 returnBuffer() 方法:

public void returnBuffer(ByteBuffer byteBuffer) {
    this.pool.returnBuffer(byteBuffer);
}

从代码中可以看出,TransientStorePool.returnBuffer() 方法实际上调用了内部的 ByteBufferPool 对象的 returnBuffer() 方法来完成归还操作。

接下来,我们看一下 ByteBufferPool 类中的 returnBuffer() 方法:

public void returnBuffer(ByteBuffer buffer) {
  // 检查 buffer 大小是否等于 chunkSize
  if (buffer.capacity() != this.chunkSize) {
    throw new IllegalArgumentException("The returned buffer capacity " + buffer.capacity() + " does not match the chunk size " + this.chunkSize);
  }

  // 清空 buffer,并将其添加回队列
  buffer.clear();
  this.queue.offer(buffer);
}


/**
* clear() 方法只是重置了缓冲区的标记、位置和限制,并没有真正清除缓冲区中的数据。实际上,数据仍然存在,但在后续的写入操作中可能会被覆盖。
*/
public final Buffer clear() {
    position = 0; // 将位置(position)设置为 0
    limit = capacity; // 将限制(limit)设置为容量(capacity)
    mark = -1; // 将标记(mark)设置为 -1
    return this;
}

transientStorePool.borrowBuffer(fileSize); 代码分析

transientStorePool.borrowBuffer(fileSize) 方法的作用是从 TransientStorePool 对象中借用一个大小为 fileSize 的内存块。以下是代码分析:

org.apache.rocketmq.store.TransientStorePool 类中,borrowBuffer 方法的定义如下:

public ByteBuffer borrowBuffer(int size) {
    ByteBuffer buffer = this.pool.borrowBuffer(size);
    if (buffer.remaining() != size) {
        log.error("The allocated buffer size not equals with request size {} {}", size, buffer.remaining());
        this.pool.returnBuffer(buffer);
        throw new RuntimeException("The allocated buffer size not equals with request size");
    }
    buffer.position(0);
    buffer.limit(size);
    return buffer;
}

borrowBuffer 方法的逻辑如下:

  1. 从内部的 ByteBufferPool 对象 pool 中借用一个大小为 size 的 ByteBuffer。ByteBufferPool 是一个队列,用于存放预分配的内存块。
  2. 检查从 pool 中获取的 ByteBuffer 的剩余空间是否等于 size,如果不等于 size,则表明内存块分配有误,将内存块归还给 pool,并抛出运行时异常。
  3. 如果获取的 ByteBuffer 正确,将其位置(position)设置为 0,限制(limit)设置为 size,然后返回这个 ByteBuffer 对象。

this.pool.borrowBuffer(size);代码分析

this.pool.borrowBuffer(size) 方法用于从 ByteBufferPool 对象中借用一个大小为 size 的内存块。以下是代码分析:

ByteBufferPool 类是一个内存池,用于预分配内存块并在需要时提供借用操作。它内部维护了一个队列 queue,存放预分配的内存块。borrowBuffer() 方法用于从该队列中获取一个内存块。

以下是 ByteBufferPool.borrowBuffer(int) 方法的实现:

public ByteBuffer borrowBuffer(int size) {
    // Validate the requested size
    if (size > this.chunkSize) {
        throw new IllegalArgumentException("Requested buffer size " + size + " cannot exceed the chunk size " + this.chunkSize);
    }

    // Try to get a buffer from the queue
    ByteBuffer buffer = this.queue.poll();
    if (buffer == null) {
        // If the queue is empty, create a new buffer
        buffer = ByteBuffer.allocateDirect(this.chunkSize);
        this.directMemoryCounter.addAndGet(this.chunkSize);
    }
    return buffer;
}

borrowBuffer() 方法的逻辑如下:

  1. 验证请求的内存块大小 size 是否超过了 ByteBufferPool 的单个内存块最大容量 chunkSize。如果超过,则抛出异常。
  2. 从内部的队列 queue 中尝试获取一个内存块(ByteBuffer 对象)。queue 存放着预分配的内存块,通过 poll() 方法可以获取并移除队列头部的一个内存块。
  3. 如果队列为空(即 buffer == null),则手动创建一个新的直接内存块(使用 ByteBuffer.allocateDirect() 方法)。新创建的内存块大小为 chunkSize,并将其添加到 directMemoryCounter 中进行计数。

chunkSize默认是多少

在 RocketMQ 中,chunkSize 的默认值为一个固定的整数值,即 64KB(64 * 1024)。

org.apache.rocketmq.store.TransientStorePool 类中,可以看到 chunkSize 被定义为一个常量,其值为 64 * 1024;这个值是在 TransientStorePool 的构造函数中传递给 ByteBufferPoolchunkSize 参数的;chunkSize 的大小决定了 ByteBufferPool 中每个预分配的内存块的大小。设置合适的 chunkSize 值可以避免频繁的系统内存分配和回收操作,从而提高性能。在 RocketMQ 中,默认的 chunkSize 为 64KB,这个值对于大多数场景来说是个比较合理的设置。当然,根据实际应用的需求,你可以根据需要修改这个值。

public static final int PoolChunkSize = 1024 * 64;

public TransientStorePool(final int poolSize, final int fileSize) {
    this.poolSize = poolSize;
    this.fileSize = fileSize;
    this.pool = new ByteBufferPool(poolSize, PoolChunkSize);
}

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java数据类型缓存是Java中一种用于提高性能和节省内存的机制。它通过缓存一些常用的数据类型对象,避免频繁地创建和销毁对象,从而减少了内存的开销和垃圾回收的压力。 Java中的数据类型缓存主要包括以下几种: 1. 整数缓存:Java中的整数类型(byte、short、int、long)都有一个固定的范围,对于某个范围内的整数值,会被缓存起来以供重复使用。这样可以避免频繁地创建新的整数对象,提高性能和节省内存。 2. 字符串常量:Java中的字符串是不可变的,为了提高字符串的重用性,Java使用了字符串常量。字符串常量是一块特殊的内存区域,用于存储字符串字面量。当创建一个字符串对象时,首先会在常量中查找是否已经存在相同内容的字符串,如果存在则直接返回引用,否则创建新的字符串对象并放入常量中。 3. 布尔值缓存:Java中的布尔类型(boolean)只有两个取值:true和false。为了提高性能,Java将这两个值缓存起来,使得每次使用时都可以直接引用缓存中的对象。 4. 字符缓存:Java中的字符类型(char)也有一个缓存,用于存储常用的字符对象。对于某些常用的字符,比如空格、换行符等,会被缓存起来以供重复使用。 通过使用数据类型缓存,可以减少对象的创建和销毁,提高程序的性能和内存的利用率。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值