在设计消息队列中间件,最主要功能是解耦业务,填峰削谷,将业务拆分多个步骤,用消息的形式,将整个业务线串联起来,保证了各个功能模块服务能够处理高并发的能力。所以消息队列所要支持的能及时将生产的消息进行消化,保证消息信息及时可靠的送达到消费者手中,那么rocketMQ作为消息中间件,由于消息是需要存储在文件中,才能被消费者消费。在普通文件操作下,平凡的进行io操作,有时候处理的性能并不是很高。那么rocketMQ是通过什么技术方式,提升消息的存储效率,极大提升消息的接受能力?
MappedFile类
在rocketMQ的存储消息中,其中几种数据业务很重要。首先当然是存储基本消息元数据的管理类CommitLog和记录消费组的控制类ConsumeQueue,方便消息查询的索引文件。他们内部最重要的存储类型都是一样的,即MappedFile类。他的初始化代码块:
private void init(final String fileName, final int fileSize) throws IOException {
this.fileName = fileName;
this.fileSize = fileSize;
this.file = new File(fileName);
this.fileFromOffset = Long.parseLong(this.file.getName());// 文件的名称即为文件的起始偏移量
boolean ok = false;
ensureDirOK(this.file.getParent());
try {
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
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();
}
}
}
1.首先通过文件管道开放读写权限,获取到了mappedByteBuffer子节缓存类,在读写操作时,可以通过该类进行获取数据写入数据功能,以内存方式操作,提升了读写效率
2.由于rocketMQ的设计特点,MappedFile对应的文件名称,其实时一个文件的起始偏移地址。因为每个文件的大小时固定的,即内部设定的fileSize,所以文件充满后,就需要新增新的MapppedFile即创建新的文件。所以文件的文件名称即起始偏移量是固定增长的,这样也能保证文件的顺序性。还有一个好处,因为存储消息是我们需要记住该消息记录文件缓存种的具体位置,加入知道了消息的具体位置,那么怎么快速定位位置获取数据呢?就可以通过于文件的起始偏移量进行比较,然后定位到具体文件,最后通过该文件定位到具体的缓存position位置。
因为MappedFile是存储消息的最终类,了解一下如何将消息内容存储在文件中
public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
assert messageExt != null;
assert cb != null;
int currentPos = this.wrotePosition.get();
if (currentPos < this.fileSize) {
// slice方法,即获取buffer剩余部分(limit-pos部分),其实这里的本意是复制一份byteBuffer。
// 当对byteBuffer进行操作,例如写入等会改变pos值,但是不会影响原始的buffer。但是内部存储的数组都是同一份的
ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
byteBuffer.position(currentPos); // 定位到buffer中pos位置,开始写入就从这个位置开始写
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);
}
// 写入成功后,就会将wrotePosition累加,加上本次写的数据大小
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 即为消息的主体,AppendMessageCallback时添加消息的回调方法。在调用callBack时之前,先获取ByteBuffer字节缓存对象,该对象其实是复制了一份mappedByteBuffer对象一份,虽然mappedByteBuffer.slice() 本意是截取该缓存剩余没有使用的缓存,但是mappedByteBuffer在MappedFile中没有方法会去改变缓存对象的位置position,limit的控制值。但是slice方法其实内部载体的缓存对象任然指向同一个的,于mappedByteBuffer的载体一样,只是控制属性的position,limit等不一样而已,这样整个系统使用内存并没有增加。那么有什么好处呢?这样在操作子节缓存时,我们不需要修改最原始缓存对象mappedByteBuffer,而是通过修改他的复制品,也能达到间件修改mappedByteBuffer,保证了mappedByteBuffer的可控。
所以在代码中,复制了一份缓存,然后对byteBuffer设置了position位置,currentPos参数是通过wrotePosition属性获取的,该属性含义为已经写入多少字节数量的数据。所以在执行完callBack方法后,得到result结果,都会将result中的写入字节的数量进行增加,下一次消息写入的开始position就能准确。当然消息只是写入到了缓存中,其实并没正在写如到磁盘下,所以wrotePosition只是写入的位置,并不是文件的flush的位置或者commit的位置。
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();
}
} catch (Throwable e) {
log.error("Error occurred when force data to disk.", e);
}
this.flushedPosition.set(value);
this.release();
} else {
log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
this.flushedPosition.set(getReadPosition());
}
}
return this.getFlushedPosition();
}
所以flush方法中,有个参数flushLeastPages参数,即至少某页才能刷新。这里就需要了解知识,Page Cache 页缓存技术,简单来说能提升对磁盘上映像和数据的访问详情
https://www.ibm.com/developerworks/cn/linux/l-cache/index.html 该文件有具体的描述。
一页缓存大小为4K,所以当写入的位置于刷新的位置比较至少要大于flushLeastPages*4K的量,才能进行flush操作。所以调用mappedByteBuffer的强制更新方法。并且设置flush的位置。
既然MappedFile提供了写入的方法,那么在读取数据时,也是从该类中读取到数据的,所以需要提供读取数据的位置,和消息大小,才能提供准确的缓存的内容
public SelectMappedBufferResult selectMappedBuffer(int pos, int size) {
int readPosition = getReadPosition();
if ((pos + size) <= readPosition) {
if (this.hold()) {
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
byteBuffer.position(pos);
ByteBuffer byteBufferNew = byteBuffer.slice();
byteBufferNew.limit(size);
return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
} else {
log.warn("matched, but hold failed, request pos: " + pos + ", fileFromOffset: "
+ this.fileFromOffset);
}
} else {
log.warn("selectMappedBuffer request pos invalid, request pos: " + pos + ", size: " + size
+ ", fileFromOffset: " + this.fileFromOffset);
}
return null;
}
首先,要限制住读取的范围不能超过写入的位置,然后从mappedByteBuffer又复制了一份字节缓存对象,但是byteBuffer对象设置了position位置,并且对byteBuffer进行切割。参考下图
但是核心还是他们真正执行的内部缓存都是同一部分,不会出现内存的增大,只是控制了position和limit,从而达到读写的控制。
在返回的SelectMappedBufferResult对象中,该对象有个属性startOffset 即起始偏移量,该偏移量是通过文件的fileFromOffset起始偏移量加上了在该文件上的位置。
方法warmMappedFile
public void warmMappedFile(FlushDiskType type, int pages) {
long beginTime = System.currentTimeMillis();
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
int flush = 0;
long time = System.currentTimeMillis();
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put(i, (byte) 0);
// force flush when flush disk type is sync
if (type == FlushDiskType.SYNC_FLUSH) {
if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
// prevent gc
if (j % 1000 == 0) {
log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
time = System.currentTimeMillis();
try {
Thread.sleep(0);
} catch (InterruptedException e) {
log.error("Interrupted", e);
}
}
}
// force flush when prepare load finished
if (type == FlushDiskType.SYNC_FLUSH) {
log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",
this.getFileName(), System.currentTimeMillis() - beginTime);
mappedByteBuffer.force();
}
log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),
System.currentTimeMillis() - beginTime);
this.mlock();
}
从字面含义可以知道预热作用,主要在分配文件或者创建文件时使用,我们知道在读取写入缓存时,操作系统是通过应用所需分布部分的缓存,进行保存文件的一部分数据,假如没有命中,任然需要读取文件,然后存储到缓存中。所以他并没有将所有文件的大小全部映射到缓存中。该方法先时初始化mappedByteBuffer,保证缓存的清洁干净,如果是同步刷新,则需要在一定的缓存页数下进行刷新。最后又执行了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);
}
}
调用了操作系统的内部函数方法,该方法采用了mmap技术 即memory map 内存映射文件地址技术。
先将Libc方法中mlock,将锁住指定的内存区域避免被操作系统调到swap空间中。
然后方法madvise,将文件数据一次性全部写入到映射内存区域中。
该文章具体描述方法原理
https://www.jianshu.com/p/eaad6ec3f87c
AllocateMappedFileService类
既然熟悉了MappedFile的具体存储,具体的读写操作后。为了MappedFile的统一创建管理,引入了AllocateMappedFileService服务类。如果有需要创建新的文件,都需要经过该服务类负责,创建新的MappedFile,那么该服务类有什么优势呢?
public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) {
int canSubmitRequests = 2;
if (this.messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
if (this.messageStore.getMessageStoreConfig().isFastFailIfNoBufferInStorePool()
&& BrokerRole.SLAVE != this.messageStore.getMessageStoreConfig().getBrokerRole()) { //if broker is slave, don't fast fail even no buffer in pool
canSubmitRequests = this.messageStore.getTransientStorePool().availableBufferNums() - this.requestQueue.size();
}
}
AllocateRequest nextReq = new AllocateRequest(nextFilePath, fileSize);
// 尝试将nextreq对象放入到table中,如果失败?说明之前已经创建过了
boolean nextPutOK = this.requestTable.putIfAbsent(nextFilePath, nextReq) == null;
if (nextPutOK) {
// 如果放置成功,就将nextReq放入到queue中。
if (canSubmitRequests <= 0) {
log.warn("[NOTIFYME]TransientStorePool is not enough, so create mapped file error, " +
"RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
this.requestTable.remove(nextFilePath);
return null;
}
boolean offerOK = this.requestQueue.offer(nextReq);
if (!offerOK) {
log.warn("never expected here, add a request to preallocate queue failed");
}
canSubmitRequests--;
}
AllocateRequest nextNextReq = new AllocateRequest(nextNextFilePath, fileSize);
// 下个文件
boolean nextNextPutOK = this.requestTable.putIfAbsent(nextNextFilePath, nextNextReq) == null;
if (nextNextPutOK) {
if (canSubmitRequests <= 0) {
log.warn("[NOTIFYME]TransientStorePool is not enough, so skip preallocate mapped file, " +
"RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
this.requestTable.remove(nextNextFilePath);
} else {
// 下个文件,放入成功,放入到queue中
boolean offerOK = this.requestQueue.offer(nextNextReq);
if (!offerOK) {
log.warn("never expected here, add a request to preallocate queue failed");
}
}
}
if (hasException) {
log.warn(this.getServiceName() + " service has exception. so return null");
return null;
}
AllocateRequest result = this.requestTable.get(nextFilePath);
try {
if (result != null) {
// result需要通过countDownLatch通知,如果为0则可以继续执行。
// 如果当前拿到的result,countDownLatch为0无需等待,说明在上一次创建的时候,已经创建成功了
boolean waitOK = result.getCountDownLatch().await(waitTimeOut, TimeUnit.MILLISECONDS);
if (!waitOK) {
log.warn("create mmap timeout " + result.getFilePath() + " " + result.getFileSize());
return null;
} else {
this.requestTable.remove(nextFilePath);
return result.getMappedFile();
}
} else {
log.error("find preallocate mmap failed, this never happen");
}
} catch (InterruptedException e) {
log.warn(this.getServiceName() + " service has exception. ", e);
}
return null;
}
这段代码是最为核心的创建文件部分
从参数中,包含了下个文件地址nextFilePath,下下个文件地址nextNextFilePath,并且文件的大小fileSize。
首先将nextFilePath进行封装成AllocateRequest对象,可以理解为发起一次分配请求,并尝试将对象放入到requestTable中,如果成功,说明nextFilePath之前是没有存放过的。如果放置失败说明,之前已经有申请相同的路径请求创建文件了。为什么会出现相同文件地址呢?因为该方法中提供了nextNextFilePath参数,也会将这个地址对应的文件预先创建好。
nextPutOK=true。说明放置成功了,然后他只是将请求放入到一个requestQueue请求队列中,并且尝试申请创建以nextNextFilePath为路径的文件了请求了。该请求也是放入到requestQueue队列中。在这块代码中,并没有说明如何创建文件的。继续下面的代码。
从requestTable中取出了nextFilePath对应的分配请求,然后对内部属性countDownLatch进行等待,如果等待成功后,就会从request中拿到了MappedFile对象了。那么从该段代码中可以了解到,该等待过程就是在等待其他线程将MappedFile创建完成,然后将countDownLatch进行倒计时,然后当前线程就能唤醒,返回结果,并且移除了requestTable中的nextFilePath为key的数据。那么nextNextFilePath为地址的文件也去申请创建了,他什么时候返回成功呢?很简单,等下次同一个业务又需要申请创建文件的时候,此时的nextFilePath即为上一次的nextNextFilePath的路径了,如果上一次nextNextFilePath的路径申请完成了,此次就能很快的返回结果了。这就是为什么需要预创建下下个文件的原因了。
那么真正创建文件的线程做什么呢?
由于AllocateMappedFileService是继承于ServiceThread类,自身就是需要实现线程的run()方法
public void run() {
log.info(this.getServiceName() + " service started");
while (!this.isStopped() && this.mmapOperation()) {
}
log.info(this.getServiceName() + " service end");
}
实现的线程很简单,最核心的是调用mmapOperation方法
private boolean mmapOperation() {
boolean isSuccess = false;
AllocateRequest req = null;
try {
req = this.requestQueue.take();
AllocateRequest expectedRequest = this.requestTable.get(req.getFilePath());
if (null == expectedRequest) {
log.warn("this mmap request expired, maybe cause timeout " + req.getFilePath() + " "
+ req.getFileSize());
return true;
}
if (expectedRequest != req) {
log.warn("never expected here, maybe cause timeout " + req.getFilePath() + " "
+ req.getFileSize() + ", req:" + req + ", expectedRequest:" + expectedRequest);
return true;
}
if (req.getMappedFile() == null) {
long beginTime = System.currentTimeMillis();
MappedFile mappedFile;
if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
try {
mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
} catch (RuntimeException e) {
log.warn("Use default implementation.");
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
}
} else {
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
}
long elapsedTime = UtilAll.computeElapsedTimeMilliseconds(beginTime);
if (elapsedTime > 10) {
int queueSize = this.requestQueue.size();
log.warn("create mappedFile spent time(ms) " + elapsedTime + " queue size " + queueSize
+ " " + req.getFilePath() + " " + req.getFileSize());
}
// pre write mappedFile
if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig()
.getMappedFileSizeCommitLog()
&&
this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
}
req.setMappedFile(mappedFile);
this.hasException = false;
isSuccess = true;
}
} catch (InterruptedException e) {
log.warn(this.getServiceName() + " interrupted, possibly by shutdown.");
this.hasException = true;
return false;
} catch (IOException e) {
log.warn(this.getServiceName() + " service has exception. ", e);
this.hasException = true;
if (null != req) {
requestQueue.offer(req);
try {
Thread.sleep(1);
} catch (InterruptedException ignored) {
}
}
} finally {
if (req != null && isSuccess)
req.getCountDownLatch().countDown();
}
return true;
}
首先通过监听队列,通过take方法,取出分配请求AllocateRequest对象,然后于requestTable对象中的请求对象进行校验。再对MappedFile创建,并初始化,通过配置属性,确定是否可以进行文件预热,就是调用warmMappedFile方法。最后看一下finally方法,如果创建成功,会对内置的countDownLatch进行倒计时,唤醒申请分配的请求线程。
基本工具已经准备好了,就等着上层如何使用了。
首先有了MappedFile对象了,不能因为创建一个文件而把之前的对象随意丢弃了,肯定任然需要使用,那么需要有个类对MappedFile对象进行管理了。
MappedFileQueue
首先了解他的属性
private final String storePath;
private final int mappedFileSize;
private final CopyOnWriteArrayList mappedFiles = new CopyOnWriteArrayList();
private final AllocateMappedFileService allocateMappedFileService;
private long flushedWhere = 0;
private long committedWhere = 0;
private volatile long storeTimestamp = 0;
mappedFileSize,定义了文件的大小,文件存储因不同业务所需的文件大小是不一致的
storePath,文件的路径,不同业务存储文件数据需要区分,因为MappedFile文件名其实就是一串数字
mappedFiles,管理存储的映射文件集合
flushedWhere和committedWhere 刷新和提交的位置点。
那么该类如何来管理MappedFile的,首先是加载旧的文件数据
public boolean load() {
File dir = new File(this.storePath);
File[] files = dir.listFiles();
if (files != null) {
// ascending order
Arrays.sort(files);
for (File file : files) {
if (file.length() != this.mappedFileSize) {
log.warn(file + "\t" + file.length()
+ " length not matched message store config value, please check it manually");
return false;
}
try {
// load时数据,将wrote,flush,commit位置都设置成文件大小
// 那么如何修正?通过方法truncateDirtyFiles(),外部调用可以修正
MappedFile mappedFile = new MappedFile(file.getPath(), mappedFileSize);
mappedFile.setWrotePosition(this.mappedFileSize);
mappedFile.setFlushedPosition(this.mappedFileSize);
mappedFile.setCommittedPosition(this.mappedFileSize);
this.mappedFiles.add(mappedFile);
log.info("load " + file.getPath() + " OK");
} catch (IOException e) {
log.error("load file " + file + " error", e);
return false;
}
}
}
return true;
}
再加载的过程中,通过文件夹dir得到所有的文件files,然后对文件进行排序,因为文件名称是数字组成的,可以进行正序排列,然后遍历所有的文件。通过文件的具体路径创建了MappedFile对象,并且内部初始化,然后设定了wrote,flushed,commited的位置,目前设定的位置都是最大值,即文件的默认大小。那么知道,文件是有可能没有写完的,不可能全部是最大的值,该怎么修正呢?
那么MappedFileQueue提供了删除脏数据的方法,去修正写入,刷新,提交的位置。
public void truncateDirtyFiles(long offset) {
List willRemoveFiles = new ArrayList();
for (MappedFile file : this.mappedFiles) {
long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize;
if (fileTailOffset > offset) {
// 当文件最大的偏移量大于了需要修正的offset
if (offset >= file.getFileFromOffset()) {
// offset大于了文件起始的偏移量,需要重置wrote,commit,flush的位置
// 为什么这些位置只要offset % this.mappedFileSize,
// 因为一个文件大小都是mappedFileSize,offset是多个文件累加起来的
file.setWrotePosition((int) (offset % this.mappedFileSize));
file.setCommittedPosition((int) (offset % this.mappedFileSize));
file.setFlushedPosition((int) (offset % this.mappedFileSize));
} else {
// offset小于了 文件的起始偏移量,说明该文件需要删除,因为都是脏数据
file.destroy(1000);
willRemoveFiles.add(file);
}
}
}
this.deleteExpiredFile(willRemoveFiles);
}
可以通过给定 offset参数,将比offset大的数据都要重置的。那么如何重置呢,如果一个文件的起始偏移量都大于了offset,那么该文件就要删除销毁。如果offset存储某个文件的位置中,那么就可以通过设置更新wrote,flushed,committed位置就可以达到数据重置效果了,因为数据的写入和读取,都与这三点位置点有关。从而就修正了文件的几个重要的位点了。
在写入消息的时候,往往是获取最近的一个MappedFile,然后往后写入。那么如果文件不存在,或者文件写满了,该如何处理呢?该类也进行了对获取lastMappedFile方法进行封装
public MappedFile getLastMappedFile(final long startOffset, boolean needCreate) {
long createOffset = -1;
MappedFile mappedFileLast = getLastMappedFile();
if (mappedFileLast == null) {
// 修正创建的偏移量,该偏移量提现出来是mappedFileSize倍数
createOffset = startOffset - (startOffset % this.mappedFileSize);
}
if (mappedFileLast != null && mappedFileLast.isFull()) {
// 最后一个文件满了,需要创建文件
createOffset = mappedFileLast.getFileFromOffset() + this.mappedFileSize;
}
if (createOffset != -1 && needCreate) {
// 文件名就是偏移量,该偏移量是这个文件起始偏移量
String nextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset);
// 下一个文件,预先创建
String nextNextFilePath = this.storePath + File.separator
+ UtilAll.offset2FileName(createOffset + this.mappedFileSize);
MappedFile mappedFile = null;
if (this.allocateMappedFileService != null) {
// 通过allocateMappedFileService负责创建
mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
nextNextFilePath, this.mappedFileSize);
} else {
try {
mappedFile = new MappedFile(nextFilePath, this.mappedFileSize);
} catch (IOException e) {
log.error("create mappedFile exception", e);
}
}
if (mappedFile != null) {
if (this.mappedFiles.isEmpty()) {
// 标识第一个文件
mappedFile.setFirstCreateInQueue(true);
}
this.mappedFiles.add(mappedFile);
}
return mappedFile;
}
return mappedFileLast;
}
该方法时获取最近的一个映射文件,但是如果获取不到,或者文件满了,都可以创建新的映射文件。在创建的时候,定义了nextFilePath,并且也定义了nextNextFilePath,其中nextNextFilePath的文件名称刚好比nextFilePath 大mappedFileSize,所以也能间接反映出文件是固定大小累计增长起来的。然后可以通过allocateMappedFileService分配映射文件服务去创建MappedFile对象。
在查询消息的过程中,往往是给定了一个物理偏移量offset,然后在MappedFile集合中查找出符合条件的映射文件,首先MappedFile的文件大小是固定的,都是mappedFileSize,并且每个MappedFile都有起始的偏移量fileFromOffset。所以也能很快定位到符合要求的映射文件
public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound) {
try {
MappedFile firstMappedFile = this.getFirstMappedFile();
MappedFile lastMappedFile = this.getLastMappedFile();
if (firstMappedFile != null && lastMappedFile != null) {
if (offset < firstMappedFile.getFileFromOffset() || offset >= lastMappedFile.getFileFromOffset() + this.mappedFileSize) {
LOG_ERROR.warn("Offset not matched. Request offset: {}, firstOffset: {}, lastOffset: {}, mappedFileSize: {}, mappedFiles count: {}",
offset,
firstMappedFile.getFileFromOffset(),
lastMappedFile.getFileFromOffset() + this.mappedFileSize,
this.mappedFileSize,
this.mappedFiles.size());
} else {
// 得到offset是处于哪个文件
int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
MappedFile targetFile = null;
try {
targetFile = this.mappedFiles.get(index);
} catch (Exception ignored) {
}
if (targetFile != null && offset >= targetFile.getFileFromOffset()
&& offset < targetFile.getFileFromOffset() + this.mappedFileSize) {
return targetFile;
}
for (MappedFile tmpMappedFile : this.mappedFiles) {
if (offset >= tmpMappedFile.getFileFromOffset()
&& offset < tmpMappedFile.getFileFromOffset() + this.mappedFileSize) {
return tmpMappedFile;
}
}
}
if (returnFirstOnNotFound) {
return firstMappedFile;
}
}
} catch (Exception e) {
log.error("findMappedFileByOffset Exception", e);
}
return null;
}
该方法首先去验证offset是否符合条件,不可能比第一个文件小,或者比最后一个文件大。然后通过offset偏移量通过与mappedFileSize比较得到比例值,可以定位到文件集合中的位置。如果任然没有符合条件,则就遍历所有的mappedFile,查找符合条件的映射文件。
既然都MappedFileQueue负责查询,创建MappedFile,那肯定少不了删除过期的MappedFile的功能。他提供了可以根据偏移量删除文件,也可以根据文件的有效期删除
public int deleteExpiredFileByTime(final long expiredTime,
final int deleteFilesInterval,
final long intervalForcibly,
final boolean cleanImmediately) {
Object[] mfs = this.copyMappedFiles(0);
if (null == mfs)
return 0;
int mfsLength = mfs.length - 1;
int deleteCount = 0;
List files = new ArrayList();
if (null != mfs) {
for (int i = 0; i < mfsLength; i++) {
MappedFile mappedFile = (MappedFile) mfs[i];
// 每个文件都有一个过期时间,进行删除
long liveMaxTimestamp = mappedFile.getLastModifiedTimestamp() + expiredTime;
if (System.currentTimeMillis() >= liveMaxTimestamp || cleanImmediately) {
if (mappedFile.destroy(intervalForcibly)) {
files.add(mappedFile);
deleteCount++;
if (files.size() >= DELETE_FILES_BATCH_MAX) {
break;
}
if (deleteFilesInterval > 0 && (i + 1) < mfsLength) {
try {
Thread.sleep(deleteFilesInterval);
} catch (InterruptedException e) {
}
}
} else {
break;
}
} else {
//avoid deleting files in the middle
break;
}
}
}
deleteExpiredFile(files);
return deleteCount;
}
这段删除文件逻辑很简单,通过文件的最近修改时间加上有效期时间,并且与当前时间比较,是否过期而后进行删除。
CommitLog 存储消息元数据的管理类
构造器
public CommitLog(final DefaultMessageStore defaultMessageStore) {
this.mappedFileQueue = new MappedFileQueue(defaultMessageStore.getMessageStoreConfig().getStorePathCommitLog(),
defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog(), defaultMessageStore.getAllocateMappedFileService());
this.defaultMessageStore = defaultMessageStore;
if (FlushDiskType.SYNC_FLUSH == defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
this.flushCommitLogService = new GroupCommitService();
} else {
this.flushCommitLogService = new FlushRealTimeService();
}
this.commitLogService = new CommitRealTimeService();
this.appendMessageCallback = new DefaultAppendMessageCallback(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
batchEncoderThreadLocal = new ThreadLocal() {
@Override
protected MessageExtBatchEncoder initialValue() {
return new MessageExtBatchEncoder(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
}
};
this.putMessageLock = defaultMessageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ? new PutMessageReentrantLock() : new PutMessageSpinLock();
}
mappedFileQueue,声明了MappedFileQueue对象,
flushCommitLogService通过数据刷新方式配置,根据同步刷新或者异步刷新方式,创建了刷新的工作任务。
commitLogService 消息提交的定时任务
appendMessageCallback 消息的存放逻辑回调方法,与MappedFile中appendMessage方法结合
putMessageLock 添加消息时的锁
该类最重要的功能是添加具体消息,方法
public PutMessageResult putMessage(final MessageExtBrokerInner msg),当然这里需要熟悉rocketMQ中消息的结构体是如何设计的,这个后面再聊
先看添加功能部分代码
// Set the storage time
msg.setStoreTimestamp(System.currentTimeMillis());
// Set the message body BODY CRC (consider the most appropriate setting
// on the client)
msg.setBodyCRC(UtilAll.crc32(msg.getBody()));
// Back to Results
AppendMessageResult result = null;
StoreStatsService storeStatsService = this.defaultMessageStore.getStoreStatsService();
String topic = msg.getTopic();
int queueId = msg.getQueueId();
final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag());
if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
|| tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
// Delay Delivery
if (msg.getDelayTimeLevel() > 0) {
// 延迟消息,将topic进行转换
if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
}
topic = ScheduleMessageService.SCHEDULE_TOPIC;
queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
// Backup real topic, queueId
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
msg.setTopic(topic);
msg.setQueueId(queueId);
}
}
long elapsedTimeInLock = 0;
MappedFile unlockMappedFile = null;
MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();
首先又设置了msg的部分属性,例如存储的时间storeTimestamp,bodyCRC等值,其实从该片段中,从消息中获取到了topic和queueId,说明put消息时,已经确定好了消息存放的具体队列了。tranType是消息的事务类型,并且事务是提交状态,或者没有事务状态,但是消息存在delayTimeLevel>0,说明消息需要延迟发送。在rocketMQ中,支持消息延迟发送,但是有固定的延迟时间,每个延迟等级对应延迟的时间。在确实是延迟发送消息时,他将消息真实的topic,queueId存放在msg的property中,并且又重新更新了该消息的topic和queueId,目前该topic和queueId与延迟的发送有关系,就等于标记了消息是延迟消息,并且是对应了具体的队列。对消息进行调整后,通过mappedFileQueue得到了最近的也就是最后一个映射文件。
putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
try {
long beginLockTimestamp = this.defaultMessageStore.getSystemClock().now();
this.beginTimeInLock = beginLockTimestamp;
// Here settings are stored timestamp, in order to ensure an orderly
// global
msg.setStoreTimestamp(beginLockTimestamp);
if (null == mappedFile || mappedFile.isFull()) {
mappedFile = this.mappedFileQueue.getLastMappedFile(0); // Mark: NewFile may be cause noise
}
if (null == mappedFile) {
log.error("create mapped file1 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString());
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, null);
}
result = mappedFile.appendMessage(msg, this.appendMessageCallback);
switch (result.getStatus()) {
case PUT_OK:
break;
case END_OF_FILE:
unlockMappedFile = mappedFile;
// Create a new file, re-write the message
mappedFile = this.mappedFileQueue.getLastMappedFile(0);
if (null == mappedFile) {
// XXX: warn and notify me
log.error("create mapped file2 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString());
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, result);
}
result = mappedFile.appendMessage(msg, this.appendMessageCallback);
break;
case MESSAGE_SIZE_EXCEEDED:
case PROPERTIES_SIZE_EXCEEDED:
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.MESSAGE_ILLEGAL, result);
case UNKNOWN_ERROR:
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result);
default:
beginTimeInLock = 0;
return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result);
}
elapsedTimeInLock = this.defaultMessageStore.getSystemClock().now() - beginLockTimestamp;
beginTimeInLock = 0;
} finally {
putMessageLock.unlock();
}
这里在put消息时,通过putMessageLock锁进行锁定,说明在多线程请求存放消息时,只能线程竞争获取锁,那么在消息的生产存储时瓶颈在这里提现了,需要提高单线程下存储消息的效率了,所以才会有对MappedFile的处理,运用mmap,page cache等技术,用来提升存储速度。又重置了msg的storeTimestamp时间,然后判断mappedFile是否可用,如果mappedFile不存在,或者已经写满了,就需要创建新的映射文件。然后通过mappedFile中的方法appendMessage,将appendMessageCallback参数带入进去得到result结果。在消息如何转换成byte存储在mappedFile中的MappedByteBuffer,都在appendMessageCallback中实现逻辑。再考虑一个问题,一个MappedFile比较存储数据是有限的,那么在存储前MappedFile是没有满的,但是此时消息的大小并没有能存储进剩余MappedFile的容量,该如何处理呢?所以在result中出现了status 添加时的状态信息,其中PUT_OK 说明放置成功了,如果是END_OF_FILE状态 处理逻辑是又重新创建了MappedFile,然后继续调用mappedFile.appendMessage方法。这里要考虑清楚,上一个文件如何处理的,剩余部分的容量是如何处理的呢?
那么了解一下AppendMessageCallback中的具体实现了。
DefaultAppendMessageCallback 消息存储回调类
看一下 AppendMessageResult doAppend(final long fileFromOffset, final ByteBuffer byteBuffer, final int maxBlank, final MessageExtBrokerInner msgInner) 方法
// 物理偏移量的起始位置
long wroteOffset = fileFromOffset + byteBuffer.position();
this.resetByteBuffer(hostHolder, 8);
// msgid 可以分析出,地址+物理偏移量组成,16个子节
String msgId = MessageDecoder.createMessageId(this.msgIdMemory, msgInner.getStoreHostBytes(hostHolder), wroteOffset);
// Record ConsumeQueue information
keyBuilder.setLength(0);
keyBuilder.append(msgInner.getTopic());
keyBuilder.append('-');
keyBuilder.append(msgInner.getQueueId());
String key = keyBuilder.toString();
// 当前topic与queue组成key,得到队列逻辑便宜位置
Long queueOffset = CommitLog.this.topicQueueTable.get(key);
if (null == queueOffset) {
queueOffset = 0L;
CommitLog.this.topicQueueTable.put(key, queueOffset);
}
// Transaction messages that require special handling
// 存在事务消息
final int tranType = MessageSysFlag.getTransactionValue(msgInner.getSysFlag());
switch (tranType) {
// Prepared and Rollback message is not consumed, will not enter the
// consumer queuec
case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
queueOffset = 0L;
break;
case MessageSysFlag.TRANSACTION_NOT_TYPE:
case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
default:
break;
}
wroteOffset是消息写入的起始位置,他是有文件的起始位置加上了byteBuffer.position()的值,在MappedFile中已经说明过了这里的byteBuffer是通过复制得来的,但是设定了position位置,该位置就是MappedFile当前已经wrote的数量。所以wroteOffset值就是文件的起始偏移量加上当前MappedFile写入的数量
msgId 生成一个消息的编号,每个消息都有唯一的标识,他是有broker服务的ip(8个字节)与写入的偏移量wroteOffset(8个字节)组合起来,占用16个字节
queueOffset 是消息队列的偏移量,他存储在topicQueueTable中,key是由topic和queueId组合而成的,初始值为0,然后通过消息的事务类型,如果事务准备或者回滚下,重置queueOffset,说明这个参数也很重要。接下来时计算消息是如何转换成byte数组的,那么先要确定消息的是如何组成的。看一段计算消息的长度方法
protected static int calMsgLength(int bodyLength, int topicLength, int propertiesLength) {
/**
* 该方法可以得知,一个消息存储的组成,需要多少属性组成的
*/
final int msgLen = 4 //TOTALSIZE
+ 4 //MAGICCODE
+ 4 //BODYCRC
+ 4 //QUEUEID
+ 4 //FLAG
+ 8 //QUEUEOFFSET
+ 8 //PHYSICALOFFSET
+ 4 //SYSFLAG
+ 8 //BORNTIMESTAMP
+ 8 //BORNHOST
+ 8 //STORETIMESTAMP
+ 8 //STOREHOSTADDRESS
+ 4 //RECONSUMETIMES
+ 8 //Prepared Transaction Offset
+ 4 + (bodyLength > 0 ? bodyLength : 0) //BODY
+ 1 + topicLength //TOPIC
+ 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength
+ 0;
return msgLen;
}
其中消息有些属性固定长度,有些是无法固定的。其中消息体bodyLength,topicLength,propertiesLength这三个属性值是不固定的。因为topic,消息体内容body,还有消息中设置的properties是根据业务信息,确定内容,所以肯定是变化的。假设给定一个字符串的值,那么如何从byte[]中写入。首先字符串可用转换成byte数组,但是字符串的长度是变化的,导致byte数组长度不固定。所以我们要先确定字符串的长度(假设长度 int 类型 占4个字节),然后再写入byte数组。所以一个字符串存储需要记录长度,再是字符串数据。在读取的时候,就能够知道先读取4个字节,判别字符串长度,然后通过长度取出具体byte数组,这些数据就可用转换成字符串了。所以不确定长度的属性,是如何添加就能了解了。在计算长度有一些固定的属性,例如总消息的大小TOTALSIZE,MAGICCODE是什么呢?待会再将