RocketMQ消息存储是整个系统的核心,直接决定着吞吐性能和高可用性。RocketMQ存储消息并没有借助oracle、mysql等关系型数据库,而是直接操作文件。借助java NIO的力量,使得I/O性能十分高。当消息来的时候,顺序写入CommitLog。为了Consumer消费消息的时候,能够方便的根据topic查询消息,在CommitLog的基础上衍生出了CosumerQueue文件,存放了某topic的消息在CommitLog中的偏移位置。此外为了支持根据消息key查询消息,还构建了index文件。这三个文件(逻辑上是三个),就是RocketMQ的主要存储内容,大致结构如下图所示(图片来自书籍《RocketMQ技术内幕》):
限于篇幅,这篇文章仅介绍CommitLog相关的一些东西。
CommitLog名字取得非常好,除了消息本身,它记录了消息的方方面面的信息,通过一条CommitLog我们可以还原出很多东西。例如消息是何时、由哪个producer发送的,被发送到了哪个消息队列,属于哪个topic,有哪些属性等等。RokcetMQ存储的消息其实存储的就是这个CommitLog记录。后面的叙述中,如果没有特别说明,我们可以将CommitLog记录等同于消息,而CommitLog特指存储消息的文件。
CommitLog类如下所示:
public class CommitLog {
// Message's MAGIC CODE daa320a7
public final static int MESSAGE_MAGIC_CODE = 0xAABBCCDD ^ 1880681586 + 8;
// End of file empty MAGIC CODE cbd43194
private final static int BLANK_MAGIC_CODE = 0xBBCCDDEE ^ 1880681586 + 8;
private final MappedFileQueue mappedFileQueue; // 存放的文件队列
// 省略代码
}
CommitLog类属性很多,但是最重要的是mappedFileQueue属性。之前我们一直说消息最终存储在CommitLog里,实际上CommitLog是一个逻辑上的概念。真正的文件是一个个MappedFile,然后组成了mappedFileQueue。一个MappedFile最多能存放1G的CommitLog,这个大小在MessageStoreConfi类里面定义了的:
当一个MappedFile写满了之后,就会创建第二MappedFile,然后继续存CommitLog,如下所示:
MappedFileQueue是存放MappedFile的队列结构,如下所示:
public class MappedFileQueue {
private static final int DELETE_FILES_BATCH_MAX = 10;
private final String storePath; // 文件存储路径,例如/home/gameloft9/store/commitlog
private final int mappedFileSize; // 单个MappedFile大小,默认1G
private final CopyOnWriteArrayList<MappedFile> mappedFiles = new CopyOnWriteArrayList<MappedFile>(); // mappedFile列表
private final AllocateMappedFileService allocateMappedFileService; // 分配mappedFile的service
// 省略代码
}
MappedFileQueue存放了一个个MappedFile,然后还记录了一些额外的信息,例如存储文件路径、单个MappedFile大小等等。
下面我们再来看MappedFile的结构:
public class MappedFile extends ReferenceResource {
protected int fileSize;
protected FileChannel fileChannel; // 读写通道
/**
* Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
*/
protected ByteBuffer writeBuffer = null; // 缓冲区,使用的是直接堆外内存
private String fileName;
private File file;
private MappedByteBuffer mappedByteBuffer; // 内存映射
}
如果之前没有接触过FileChannel、ByteBuffer、MappedByteBuffer,可以先补习一下这篇文章:ByteBuffer介绍
writeBuffer使用的是堆外内存,mappedByteBuffer是直接将文件映射到内存中,两者的使用是互斥的。如果启用了临时缓冲池(默认不启用),那么就会使用writeBuffer写commitlog,否则就是mappedBtyeBuffer写commitlog。
CommitLog、MappedFileQueue和MappedFile的大致关系如下所示:
一条CommitLog记录包括哪些内容呢?CommitLog要实现的功能,决定了它需要存储哪些内容。首先要实现消息的存储,肯定需要把消息存下来。其次,为了方便创建ConsumerQueue,需要记录topic、queueId等信息。为了能跟踪消息,需要记录消息发送方地址、发送时间等。。。
完整的CommitLog记录如下所示:
为什么会先跳过消息存储流程先讲存储内容结构呢?因为流程实在是太复杂、内容太多了,很容易晕头转向。如果先对存储的内容有一个大致的概念,后面再理解消息存储过程会好很多。骨头难啃,总要挑一处简单的下嘴嘛。下面从功能上解释为什么要存这些字段。不了解整个流程,理解这些字段也会有些困难,我会尽量保证通俗易懂。
TotalSize很好理解,就是整个CommitLog记录的大小,包括上面列出来的所有字段大小。TotalSize占用4个字节,在往byteBuffer写CommitLog的时候,首先就会写入这个CommitLog大小,如下所示:
// 1 TOTALSIZE
this.msgStoreItemMemory.putInt(msgLen);
MagicCode是一个特殊的字段,它可以标志ByteBuffer中的某个CommitLog是一个正常的CommitLog,还是因为ByteBuffer没有多余的空间存放该CommitLog,导致该CommitLog是一个空的CommitLog。
MagicCode有两个值,如下所示:
// Message's MAGIC CODE daa320a7
public final static int MESSAGE_MAGIC_CODE = 0xAABBCCDD ^ 1880681586 + 8;
// End of file empty MAGIC CODE cbd43194
private final static int BLANK_MAGIC_CODE = 0xBBCCDDEE ^ 1880681586 + 8;
MESSAGE_MAGIC_CODE表明该CommitLog记录是一条正常的记录,BLANK_MAGIC_CODE表明该CommitLog记录是一个空的CommitLog记录。
如果存储CommitLog发现空间不够,会马上开辟第二个文件重新存储CommitLog记录,但是之前的空的CommitLog也一样会保存下来。在Broker正常退出或者异常退出,重启之后需要恢复Broker的时候,就会根据这个MagicCode判断该条CommitLog是否是正常的。
学过计算机网络的人一定知道CRC,CRC即循环冗余校验码,是数据通信领域中最常用的一种查错校验码,通过CRC就可以知道数据的正确性和完整性。RocketMQ通过CRC来校验消息部分,如下所示:
if (checkCRC) {
int crc = UtilAll.crc32(bytesContent, 0, bodyLen);
if (crc != bodyCRC) {
log.warn("CRC check failed. bodyCRC={}, currentCRC={}", crc, bodyCRC);
return new DispatchRequest(-1, false/* success */);
}
}
queueId很熟悉的,就是消息发往哪个队列。queueId在producer发送消息时会选择出来,这个在文章RocketMQ发送消息时如何选择队列中已经很详细的讲过了,这里就不再赘述queueId是如何产生的了。
Topic下会有一堆消息队列(ConsumerQueue),RocketMQ在保存完消息后,会随后构建ConsumerQueue,里面存放着Topic下消息的在CommitLog文件中的偏移量,方便根据Topic查询消费消息。ConsumerQueue的构建、消息的消费都是重点内容,会在单独的文章中进行介绍。
暂时不知道有什么用,默认值是0。
我们之前讲过,为了方便Consumer能根据Topic快速的查询消息,在CommitLog的基础上构建了ConsumerQueue,里面存放了某个Topic下面的所有消息在CommitLog中的位置。
同样的,这里的QueueOffset存放了消息记录应该在ConsumerQueue中的位置,这样构建ConsumerQueue的时候,就知道该条记录在ConsummerQueue的位置顺序,在消费消息的时候很有用处。QueueOffset一般是是累加1的,如下所示:
case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
// The next update ConsumeQueue information
CommitLog.this.topicQueueTable.put(key, ++queueOffset);
break;
这个与ConsumerQueue的存储结构有关,后面介绍ConsumerQueue存储结构的时候会涉及到。
这个很简单了,就是消息在CommitLog中的物理位置。需要注意的是,我们CommitLog对应着磁盘上的多个文件,这里的偏移量不是从某个文件开始算的,而是从第一个文件偏移开始算起的。
SysFlag是RocketMQ内部使用的标记位,通过位运算进行标记。例如是否对消息进行了压缩、是否属于事务消息。SysFlag初始值为0,可与下面的标记进行位运算。
public final static int COMPRESSED_FLAG = 0x1;
public final static int MULTI_TAGS_FLAG = 0x1 << 1; // 2
public final static int TRANSACTION_NOT_TYPE = 0; // 不参与位运算,用作结果比较,表示无事务
public final static int TRANSACTION_PREPARED_TYPE = 0x1 << 2; // 4
public final static int TRANSACTION_COMMIT_TYPE = 0x2 << 2; // 8
public final static int TRANSACTION_ROLLBACK_TYPE = 0x3 << 2; // 12
例如对消息进行了压缩,那么SysFlag = 0 | 0x1。又例如flag & TRANSACTION_ROLLBACK_TYPE,可以判断消息是否是事务消息,如果等于0说明不是事务消息。
Producer发送消息的时间,如下所示:
requestHeader.setBornTimestamp(System.currentTimeMillis());
Producer发送消息使用的套接字地址
msgInner.setBornHost(ctx.channel().remoteAddress());
CommitLog存的时候是读取4个字节的rao ip + 4个字节的端口号:
byteBuffer.put(inetSocketAddress.getAddress().getAddress(), 0, 4);
byteBuffer.putInt(inetSocketAddress.getPort());
消息在broker上存储时间。
Broker的套接字地址,存储方式同BornHost。
重复消费次数,初始为0。我们消费消息的时候,如果发生异常,可以选择晚一点重新消费,如下所示:
consumer.registerMessageListener(new MessageListenerConcurrently() {
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try{
for(MessageExt msg : msgs){
if(msg.getTopic().equals("test")){
log.info("收到test类型消息:" + new String(msg.getBody()));
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; // 消费成功
}catch(Exception e){
return ConsumeConcurrentlyStatus.RECONSUME_LATER; // 稍后重新消费
}
}
});
Broker重试的时候,这个ReconsumeTimes就会+1,默认最大重试次数是16次。
事务消息相关的一个属性。RocketMQ事务消息基于两阶段提交,这里仅仅了解一点就够了,涉及到事务消息的时候会再提到。
消息体,没什么好说的。需要注意的是,Body前面其实会有4字节(int)的Body长度,这里没有画出来。
主题,没什么好说的。需要注意的是,Topic前面其实会有1字节(byte)的Topic长度,这里没有画出来。
消息属性。需要注意的是,Properties前面其实会有2字节(short)的Properties长度,这里没有画出来。
Properties既存放了RocketMQ内部用到的一些属性,也存放了用户的一些属性。例如发送消息的TAG就存放在Properties里面:
Message msg = new Message("test",// topic
"TagB",// tag就会存放在Properties里面
("我发了一条消息").getBytes());// body
Properties中的一些常用key都定义在了MessageConstant里面,如下所示:
public class MessageConst {
public static final String PROPERTY_KEYS = "KEYS";
public static final String PROPERTY_TAGS = "TAGS";
public static final String PROPERTY_WAIT_STORE_MSG_OK = "WAIT";
public static final String PROPERTY_DELAY_TIME_LEVEL = "DELAY";
public static final String PROPERTY_RETRY_TOPIC = "RETRY_TOPIC";
public static final String PROPERTY_REAL_TOPIC = "REAL_TOPIC";
public static final String PROPERTY_REAL_QUEUE_ID = "REAL_QID";
public static final String PROPERTY_TRANSACTION_PREPARED = "TRAN_MSG";
public static final String PROPERTY_PRODUCER_GROUP = "PGROUP";
public static final String PROPERTY_MIN_OFFSET = "MIN_OFFSET";
public static final String PROPERTY_MAX_OFFSET = "MAX_OFFSET";
public static final String PROPERTY_BUYER_ID = "BUYER_ID";
public static final String PROPERTY_ORIGIN_MESSAGE_ID = "ORIGIN_MESSAGE_ID";
public static final String PROPERTY_TRANSFER_FLAG = "TRANSFER_FLAG";
public static final String PROPERTY_CORRECTION_FLAG = "CORRECTION_FLAG";
public static final String PROPERTY_MQ2_FLAG = "MQ2_FLAG";
public static final String PROPERTY_RECONSUME_TIME = "RECONSUME_TIME";
public static final String PROPERTY_MSG_REGION = "MSG_REGION";
public static final String PROPERTY_TRACE_SWITCH = "TRACE_ON";
public static final String PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX = "UNIQ_KEY";
public static final String PROPERTY_MAX_RECONSUME_TIMES = "MAX_RECONSUME_TIMES";
public static final String PROPERTY_CONSUME_START_TIMESTAMP = "CONSUME_START_TIME";
}
通过了解CommitLog记录的一些属性,可以帮助我们更好的了解RocketMQ消息存储、消费的一些细节。