在什么场景下要开启TransientStorePool(技术为解决问题而出现)
在 RocketMQ 中,TransientStorePool
是一种优化磁盘 I/O 性能的机制。它通过预分配内存块,将消息写入预分配的内存块(直接内存),然后使用内存映射文件(Memory-Mapped File)将内存块中的数据刷到磁盘,从而提高写入性能。因此,当你需要提高 RocketMQ 的消息写入性能时,可以考虑开启 TransientStorePool
。
以下是开启 TransientStorePool
的一些具体场景:
-
高性能场景:对于具有较高消息吞吐量要求的场景,开启
TransientStorePool
可以减少磁盘 I/O,提高写入性能。 -
大量写入操作:在大量写入操作的场景下,使用
TransientStorePool
可以减少频繁的系统内存分配和回收操作,从而提高性能。 -
集群环境:在集群环境中,开启
TransientStorePool
可以帮助提高整个集群的性能和稳定性,尤其是在集群节点较多时。 -
高峰期:在系统高峰期(比如双十一、黑五等)时,消息流量和写入操作可能会急剧增加。开启
TransientStorePool
可以帮助应对这种临时的高负载压力,提高整个系统的处理能力。 -
异步刷盘:在异步刷盘的场景下,将消息先写入内存缓冲区可以提高响应速度。开启
TransientStorePool
可以使得消息先被写入直接内存,然后通过后台刷盘服务异步写入磁盘,从而降低延迟。 -
低延迟要求:对于对延迟有严格要求的场景,开启
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)。
-
内存池:TransientStorePool是一个预先分配的固定大小的内存池。它用来暂存消息数据,以减少频繁的系统内存分配和回收,从而提高性能。当生产者发送消息时,消息首先会被写入到这个内存池中,而不是直接写入到磁盘。内存池中的每个内存块大小与RocketMQ中CommitLog文件的一个映射内存块大小相同。
-
内存映射文件(MMAP):MMAP是一种将文件或者文件的一部分映射到进程内存地址空间的技术。RocketMQ使用MMAP技术将CommitLog文件映射到内存中,这样就可以通过直接操作内存来读写CommitLog文件,避免了磁盘IO操作的性能开销。
结合这两个技术,RocketMQ的TransientStorePool设计实现了以下工作流程:
- 当生产者发送消息时,消息首先被写入到内存池中的一个内存块。
- 当内存块被写满时,将整个内存块的数据通过MMAP的方式刷入到CommitLog文件对应的映射内存块中。
- 最后,通过调用操作系统的
msync
方法,将映射内存中的数据刷入磁盘。
这种设计可以有效地减少磁盘IO操作,提高RocketMQ的消息写入性能。同时,由于内存池和MMAP的使用,RocketMQ可以充分利用操作系统的缓存机制,进一步优化性能。
TransientStorePool的代码分析
RocketMQ的TransientStorePool设计主要通过内存池和MMAP技术将消息先写入内存池中的内存块,然后将内存块的数据刷入MMAP内存,最后再将MMAP内存的数据刷入磁盘。这种设计提高了RocketMQ的消息写入性能。RocketMQ的TransientStorePool实现主要位于MappedFileQueue.java
和MappedFile.java
两个文件中。
- MappedFileQueue.java
MappedFileQueue
管理着MappedFile
的集合,每个MappedFile
对应于一个CommitLog文件。使用TransientStorePool时,需要设置MappedFileQueue
的useTransientStorePool
属性为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()
方法的实现。
首先,MappedByteBuffer
是java.nio
包下的一个类,它继承自ByteBuffer
类。MappedByteBuffer
的force()
方法实际上是一个本地方法。在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()
的主要逻辑如下:
- 将
available
的值减 1,如果减少后的值为 0,则表示没有其他线程正在使用这个MappedFile
对象。 - 如果满足条件(没有其他线程使用该对象),则记录当前的时间戳作为
firstShutdownTimestamp
。 - 如果文件保留时间已过或者引用计数为 0,那么执行下面的资源释放操作:
- 调用clean(this.mappedByteBuffer); 清理
MappedByteBuffer
对象,将其置为null
。 - 关闭
FileChannel
,将其置为null
。 - 输出日志,表示该文件资源已经被释放。
- 调用clean(this.mappedByteBuffer); 清理
- 如果减少引用计数后的值小于 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()
方法的逻辑如下:
- 设置
shutdown
标志位为true
。 - 如果当前
MappedFile
对象被持有(引用计数大于 0),则调用release()
方法,释放映射内存块和文件通道等资源。 - 如果当前
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
方法的逻辑如下:
- 从内部的
ByteBufferPool
对象pool
中借用一个大小为size
的 ByteBuffer。ByteBufferPool
是一个队列,用于存放预分配的内存块。 - 检查从
pool
中获取的ByteBuffer
的剩余空间是否等于size
,如果不等于size
,则表明内存块分配有误,将内存块归还给pool
,并抛出运行时异常。 - 如果获取的
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()
方法的逻辑如下:
- 验证请求的内存块大小
size
是否超过了ByteBufferPool
的单个内存块最大容量chunkSize
。如果超过,则抛出异常。 - 从内部的队列
queue
中尝试获取一个内存块(ByteBuffer
对象)。queue
存放着预分配的内存块,通过poll()
方法可以获取并移除队列头部的一个内存块。 - 如果队列为空(即
buffer == null
),则手动创建一个新的直接内存块(使用ByteBuffer.allocateDirect()
方法)。新创建的内存块大小为chunkSize
,并将其添加到directMemoryCounter
中进行计数。
chunkSize默认是多少
在 RocketMQ 中,chunkSize
的默认值为一个固定的整数值,即 64KB(64 * 1024)。
在 org.apache.rocketmq.store.TransientStorePool
类中,可以看到 chunkSize
被定义为一个常量,其值为 64 * 1024;这个值是在 TransientStorePool
的构造函数中传递给 ByteBufferPool
的 chunkSize
参数的;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);
}