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 对象的。
- 通过RandAccessFile对象,获得文件通道 :
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
- 根据文件通道映射出内存映射缓冲区 :
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 ,
- 先通过切片的方式, 将整个 mappedByteBuffer 切出来 得到 byteBuffer
- 将byteBuffer的 postition 设置为 pos 起始位点 ,再进行切片, 此时就会从 pos起始位点的地方开始切到末尾 ,得到 byteBufferNew
- 再根据 有效数据量大小 给 byteBufferNew 设置好 limit, 就得到最终的 内存区域buffer了。
4.总结
先上一张图:
上图为 MappedFile 内部的核心属性和方法。
总的来说, MappedFile 类 底层操作 就是 MappedByteBuffer。
我相信 肯定有人会有疑问:
为什么不直接操作 MappedByteBuffer,而要将MappedByteBuffer再封装一层变为MappedFile 呢?
- MappedByteBuffer对于 java而言,其使用的是堆外内存。 因此很难控制其内存的回收,一但操作失误就会引起内存泄漏问题 , 因此通过MappedFile的父类ReferenceResource 来控制。
- MappedByteBuffer 在put数据后,实际上是将数据写到了虚拟内存上(可以近似理解成PageCache,但不是), 而虚拟内存是依赖于操作系统来定时刷盘的, 因此我们在写完数据后 需要通过
MappedFile #flush
方法(其内部仍是调用了MappedByteBuffer.force()
) 来手动控制刷盘操作。 - 由于MappedByteBuffer 中 写入读取数据等 api 使用起来挺复杂的, 因此MappedFile 中维护了 flushedPosition(刷盘位点) 和 wrotePosition(写入位点) ,能够更方便的进行读写操作。
- MappedFile 中 还维护了 文件的名称,文件的物理偏移量, 文件的对象,文件通道 等重要属性,会方便之后的 类来获取有用的信息。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)