KahaDB是ActiveMQ从版本5.4之后的默认消息存储引擎,消息存储机制是消息中间件最重要的核心部件和性能提升点。KahaDB的存储主要分为索引存储和消息存储部分。
KahaDB的索引是B+树,而其最基础的存储机制则是基于“随机存取文件”设计的。相对于连续存储的文件来说这种方式比较耗时,但是设计思路比较简单。
相对的,Kahadb的消息即数据存储机制则是基于“连续存储”文件设计的,这种存储方式的效率高,但是空间的重复利用率低。
Kahadb中磁盘文件介绍:
*.data文件,保存的是索引内容
*.redo文件,保存的是针对索引文件的恢复内容
*.free文件,保存的是索引文件中空闲页的页码
db-*.log文件,保存的是索引所指向的消息的信息内容
本篇主要分析KahaDB消息存储实现机制。Producer发送给Broker的消息会被封装成KahaAddMessageCommand对象,包括destination,messageId,transactionInfo,message等属性,最终KahaAddMessageCommand的数据结构如下:
transaction_info {
local_transaction_id {
connection_id: ID:jiangzhiqiangdeMacBook-Pro.local-49633-1556954797292-1:1
transaction_id: 1
}
}
destination {
type: QUEUE
name: FirstQueue
}
messageId: ID:jiangzhiqiangdeMacBook-Pro.local-49633-1556954797292-1:1:1:1:1
message: org.apache.activemq.protobuf.Buffer@ffffffda
priority: 4
prioritySupported: false
接着调用MessageDatabase的store方法,将这部分需要保存的数据转换成ByteSequence对象,保存在byte数组中。后面对数据的操作都转换成了对byte数组的操作。
数据准备好之后,就准备开始往磁盘中写数据了。AMQ中负责写数据的是DataFileAppender类。
storeItem()方法中创建了Location对象,该对象主要记录数据在磁盘上的存储位置。Location类有三个非常重要的属性:1、dataFileId表示消息所在的log文件,例如消息保存在db-1.log中,则dataFileId的值为db-1.log。2、offset表示在log文件中的偏移量。3、size表示数据大小。方法中首先根据byte数组计算出本次需要写入数据的大小,并将计算的size赋值给Location对象。接着将数据构造成WriteCommand对象,该对象是写入块中的最小单元,用户的每个写入动作都会生成一个writecommand对象。接着生成WriteBatch对象,将writecommand对象保存在链表writes中,用于批量写入磁盘,提升写入效率,同时设置Location的offset和dataFileId。
public void append(Journal.WriteCommand write) throws IOException {
this.writes.addLast(write);
write.location.setDataFileId(dataFile.getDataFileId());
write.location.setOffset(offset+size);
int s = write.location.getSize();
size += s;
dataFile.incrementLength(s);
journal.addToTotalLength(s);
}
接着将即将要写入磁盘的数据的索引缓存在inflightWrites这个Map中,以提高访问时的效率。
inflightWrites.put(new Journal.WriteKey(write.location), write);
inflightWrites中的索引此时为1:128927,表示dataFileId:offset。数据写入磁盘前的准备工作已基本完成,因为默认写入是异步的,因此数据不会立即写入。
真正负责异步写磁盘的是DataFileAppender类的processQueue方法。
protected void processQueue() {
DataFile dataFile = null;
RecoverableRandomAccessFile file = null;
WriteBatch wb = null;
try (DataByteArrayOutputStream buff = new DataByteArrayOutputStream(maxWriteBatchSize);) {
while (true) {
省略。。。。。。。
Journal.WriteCommand write = wb.writes.getHead();
// Write an empty batch control record.
buff.reset();
buff.write(EMPTY_BATCH_CONTROL_RECORD);
boolean forceToDisk = false;
while (write != null) {
forceToDisk |= write.sync | (syncOnComplete && write.onComplete != null);
buff.writeInt(write.location.getSize());
buff.writeByte(write.location.getType());
buff.write(write.data.getData(), write.data.getOffset(), write.data.getLength());
write = write.getNext();
}
// append 'unset', zero length next batch so read can always find eof
buff.write(Journal.EOF_RECORD);
ByteSequence sequence = buff.toByteSequence();
// Now we can fill in the batch control record properly.
buff.reset();
buff.skip(RECORD_HEAD_SPACE + Journal.BATCH_CONTROL_RECORD_MAGIC.length);
buff.writeInt(sequence.getLength() - Journal.BATCH_CONTROL_RECORD_SIZE - Journal.EOF_RECORD.length);
// Now do the 1 big write.
file.seek(wb.offset);
file.write(sequence.getData(), sequence.getOffset(), sequence.getLength());
ReplicationTarget replicationTarget = journal.getReplicationTarget();
if( replicationTarget!=null ) {
replicationTarget.replicate(wb.writes.getHead().location, sequence, forceToDisk);
}
if (forceToDisk) {
file.sync();
}
Journal.WriteCommand lastWrite = wb.writes.getTail();
journal.setLastAppendLocation(lastWrite.location);
signalDone(wb);
}
} catch (Throwable error) {
省略。。。。。。
}
方法中首先获取之前构造的WriteBatch对象,该对象中封装了一个WriteCommand对象的链表writes。获取writes的头结点wb.writes.getHead(),然后从pos为0处开始写入批写入标志数据EMPTY_BATCH_CONTROL_RECORD,接着写入数据的大小size,数据的类型type,最后写入数据的内容data。紧接着获取头结点的下一个结点write = write.getNext(),直到处理完writes的尾结点。最后写入结尾标志buff.write(Journal.EOF_RECORD); 注意,此处的写入是指将WriteBatch中封装的数据写入buff中,此时数据还在内存中,还没有写入磁盘。
从file.seek(wb.offset);这一句开始真正开始写磁盘,表示本次新增的数据从offset表示的位置开始写入,到file.sync()强制将数据写入文件,整个写盘流程结束。从这里我们可以看出KahaDB数据写入的方式为顺序写入,后面每次新增的数据都是在之前的数据后面新增,提高了写数据的效率,而且将writes中的数据一次性写入磁盘,实现了批量写入的功能,也提升了写磁盘的效率。
最终signalDone方法将inflightWrites中缓存的索引及数据清除,结束整个写入操作。