一、消息中间件的存储方式
1.KV缓存存储
KV缓存的IO读写能力优于DB,但有限,需考虑整体可靠性——不推荐
2.文件系统
采用消息刷盘至所部署虚拟机或物理机的磁盘文件去做持久化——推荐
3.关系数据库——会存在瓶颈——不推荐
存储效率: 文件系统>KV缓存存储>关系型数据库DB
易于实现和快速集成:关系型数据库DB>KV缓存存储>文件系统
具体情况根据所需的业务场景适配合适的存储方式
二、RocketMQ的存储设计
1.RocketMQ所采用的存储方式就是消息刷盘至文件系统的方式进行存储,最大程度的保证数据的不丢失。
2.对于RocketMQ来说,用于消息存储最主要的三个文件为:
①CommitLog文件(消息存储的物理文件)
消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的
单个文件大小默认1G, 文件名长度为20位,左边补零,剩余为起始偏移量
00000000000000000000代表了第一个文件,起始偏移量为0,文件大小1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。
消息主要是顺序写入日志文件,当文件满了,写入下一个文件
存储路径:$HOME/store/commitLog/{mappedFile}
②ConsumeQueue(逻辑消息队列)
为了提高消息消费的性能而引入,利用MappedFileQueue进行记录偏移量信息,没有与消息进行耦合,而是设计成一个通用的存储功能,但其不存储消息
作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值
consumequeue文件采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode
单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M
20(字节)* 300000(条目)/ 1048576(字节)= 5.7220458984375 ≈ 5.72M
一个topic下默认有四个队列queueId={0,1,2,3}
存储路径:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}
③IndexFile(查询消息的工具)
提供了一种可以通过key或时间区间来查询消息的方法
存储位置:$HOME \store\index${fileName}
fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故rocketmq的索引文件其底层实现为hash索引
3.存储结构
单个Broker实例下所有的队列共用同一个CommitLog物理文件来存储消息的主体和元数据,在写入commitLog前会先申请消息锁PutMessageLock,串行写入,确保发送的消息顺序写入文件,并且最大程度的保证消息发送的高性能和高吞吐
生产——>存储——>消费:
Producer发送消息至Broker端,Broker端使用同步或者异步的方式对消息刷盘保存CommitLog文件中,同时,Broker会开启后台服务进程调用ReputMessageService不停地分发请求并异步构建(更新)ConsumeQueue(逻辑消费队列)和IndexFile(索引文件)数据
3.贴图镇楼
4.存储流程
调用链路
BrokerStartup.start()——> BrokerController.start()——> NettyRemotingServer.start()——> NettyRemotingServer.prepareSharableHandlers()——> new NettyServerHandler()——> NettyRemotingAbstract.processMessageReceived()——> NettyRemotingAbstract.processRequestCommand()——> SendMessageProcessor.processRequest()
①开始发送消息SendMessageProcessor.processRequest()
@Override
public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException {
RemotingCommand response = null;
try {
// TODO: 2021/8/30 开始发送消息
response = asyncProcessRequest(ctx, request).get();
} catch (InterruptedException | ExecutionException e) {
log.error("process SendMessage error, request : " + request.toString(), e);
}
return response;
}
②解析通信协议,消息发送前处理SendMessageProcessor.asyncProcessRequest()
public CompletableFuture<RemotingCommand> asyncProcessRequest(ChannelHandlerContext ctx,
RemotingCommand request) throws RemotingCommandException {
final SendMessageContext mqtraceContext;
switch (request.getCode()) {
// TODO: 2021/8/30 消费者发送请求消息失败后重试(暂时无关)
case RequestCode.CONSUMER_SEND_MSG_BACK:
return this.asyncConsumerSendMsgBack(ctx, request);
default:
// TODO: 2021/8/30 解析通信协议,包装成请求头,进行后续的通信
SendMessageRequestHeader requestHeader = parseRequestHeader(request);
if (requestHeader == null) {
return CompletableFuture.completedFuture(null);
}
// TODO: 2021/8/31 构建消息内容
mqtraceContext = buildMsgContext(ctx, requestHeader);
// TODO: 2021/8/31 利用hook处理消息发送前逻辑
this.executeSendMessageHookBefore(ctx, request, mqtraceContext);
// TODO: 2021/8/31 判断是否为批量发送消息
if (requestHeader.isBatch()) {
// TODO: 2021/8/31 批量发送
return this.asyncSendBatchMessage(ctx, request, mqtraceContext, requestHeader);
} else {
// TODO: 2021/8/31 非批量发送
return this.asyncSendMessage(ctx, request, mqtraceContext, requestHeader);
}
}
}
③批量存储消息SendMessageProcessor.asyncSendBatchMessage()
private CompletableFuture<RemotingCommand> asyncSendBatchMessage(ChannelHandlerContext ctx, RemotingCommand request,
SendMessageContext mqtraceContext,
SendMessageRequestHeader requestHeader) {
// TODO: 2021/8/31 发送前,初始化响应
final RemotingCommand response = preSend(ctx, request, requestHeader);
final SendMessageResponseHeader responseHeader = (SendMessageResponseHeader)response.readCustomHeader();if (response.getCode() != -1) {
return CompletableFuture.completedFuture(response);
}int queueIdInt = requestHeader.getQueueId();
// TODO: 2021/8/31 获取topic配置
TopicConfig topicConfig = this.brokerController.getTopicConfigManager().selectTopicConfig(requestHeader.getTopic());if (queueIdInt < 0) {
// TODO: 2021/8/31 如果队列小于0,从可用队列随机选择
queueIdInt = randomQueueId(topicConfig.getWriteQueueNums());
}
// TODO: 2021/8/31 topic长度校验
if (requestHeader.getTopic().length() > Byte.MAX_VALUE) {
response.setCode(ResponseCode.MESSAGE_ILLEGAL);
response.setRemark("message topic length too long " + requestHeader.getTopic().length());
return CompletableFuture.completedFuture(response);
}
// TODO: 2021/8/31 组装消息存储信息记录
MessageExtBatch messageExtBatch = new MessageExtBatch();
messageExtBatch.setTopic(requestHeader.getTopic());
messageExtBatch.setQueueId(queueIdInt);int sysFlag = requestHeader.getSysFlag();
if (TopicFilterType.MULTI_TAG == topicConfig.getTopicFilterType()) {
sysFlag |= MessageSysFlag.MULTI_TAGS_FLAG;
}
messageExtBatch.setSysFlag(sysFlag);messageExtBatch.setFlag(requestHeader.getFlag());
MessageAccessor.setProperties(messageExtBatch, MessageDecoder.string2messageProperties(requestHeader.getProperties()));
messageExtBatch.setBody(request.getBody());
messageExtBatch.setBornTimestamp(requestHeader.getBornTimestamp());
messageExtBatch.setBornHost(ctx.channel().remoteAddress());
messageExtBatch.setStoreHost(this.getStoreHost());
messageExtBatch.setReconsumeTimes(requestHeader.getReconsumeTimes() == null ? 0 : requestHeader.getReconsumeTimes());
String clusterName = this.brokerController.getBrokerConfig().getBrokerClusterName();
MessageAccessor.putProperty(messageExtBatch, MessageConst.PROPERTY_CLUSTER, clusterName);
// TODO: 2021/8/31 添加消息
CompletableFuture<PutMessageResult> putMessageResult = this.brokerController.getMessageStore().asyncPutMessages(messageExtBatch);
// TODO: 2021/8/31 返回存储响应处理结果
return handlePutMessageResultFuture(putMessageResult, response, request, messageExtBatch, responseHeader, mqtraceContext, ctx, queueIdInt);
}
④存储消息SendMessageProcessor.asyncSendBatchMessage()
private CompletableFuture<RemotingCommand> asyncSendMessage(ChannelHandlerContext ctx, RemotingCommand request,
SendMessageContext mqtraceContext,
SendMessageRequestHeader requestHeader) {// TODO: 2021/8/31 消息发送前,初始化响应
final RemotingCommand response = preSend(ctx, request, requestHeader);
final SendMessageResponseHeader responseHeader = (SendMessageResponseHeader)response.readCustomHeader();if (response.getCode() != -1) {
return CompletableFuture.completedFuture(response);
}final byte[] body = request.getBody();
int queueIdInt = requestHeader.getQueueId();
// TODO: 2021/8/31 获取topic配置
TopicConfig topicConfig = this.brokerController.getTopicConfigManager().selectTopicConfig(requestHeader.getTopic());if (queueIdInt < 0) {
// TODO: 2021/8/31 如果队列小于0,从可用队列随机选择
queueIdInt = randomQueueId(topicConfig.getWriteQueueNums());
}
// TODO: 2021/8/31 组装消息
MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
msgInner.setTopic(requestHeader.getTopic());
msgInner.setQueueId(queueIdInt);if (!handleRetryAndDLQ(requestHeader, response, request, msgInner, topicConfig)) {
return CompletableFuture.completedFuture(response);
}msgInner.setBody(body);
msgInner.setFlag(requestHeader.getFlag());
// TODO: 2021/8/31 解析请求头的属性
Map<String, String> origProps = MessageDecoder.string2messageProperties(requestHeader.getProperties());
MessageAccessor.setProperties(msgInner, origProps);
msgInner.setBornTimestamp(requestHeader.getBornTimestamp());
msgInner.setBornHost(ctx.channel().remoteAddress());
msgInner.setStoreHost(this.getStoreHost());
msgInner.setReconsumeTimes(requestHeader.getReconsumeTimes() == null ? 0 : requestHeader.getReconsumeTimes());
String clusterName = this.brokerController.getBrokerConfig().getBrokerClusterName();
MessageAccessor.putProperty(msgInner, MessageConst.PROPERTY_CLUSTER, clusterName);
// TODO: 2021/8/31判断解析来的请求头属性中是否包含wait状态if (origProps.containsKey(MessageConst.PROPERTY_WAIT_STORE_MSG_OK)) {
String waitStoreMsgOKValue = origProps.remove(MessageConst.PROPERTY_WAIT_STORE_MSG_OK);
msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgInner.getProperties()));
// Reput to properties, since msgInner.isWaitStoreMsgOK() will be invoked later
origProps.put(MessageConst.PROPERTY_WAIT_STORE_MSG_OK, waitStoreMsgOKValue);
} else {
msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgInner.getProperties()));
}CompletableFuture<PutMessageResult> putMessageResult = null;
String transFlag = origProps.get(MessageConst.PROPERTY_TRANSACTION_PREPARED);
// TODO: 2021/8/31 校验是否不允许发送事务消息
if (transFlag != null && Boolean.parseBoolean(transFlag)) {
if (this.brokerController.getBrokerConfig().isRejectTransactionMessage()) {
response.setCode(ResponseCode.NO_PERMISSION);
response.setRemark(
"the broker[" + this.brokerController.getBrokerConfig().getBrokerIP1()
+ "] sending transaction message is forbidden");
return CompletableFuture.completedFuture(response);
}
putMessageResult = this.brokerController.getTransactionalMessageService().asyncPrepareMessage(msgInner);
} else {
// TODO: 2021/8/31 添加消息
putMessageResult = this.brokerController.getMessageStore().asyncPutMessage(msgInner);
}// TODO: 2021/8/31 返回存储响应处理结果
return handlePutMessageResultFuture(putMessageResult, response, request, msgInner, responseHeader, mqtraceContext, ctx, queueIdInt);
}
三、消息刷盘
1.PageCache
系统的所有文件I/O请求,操作系统都是通过page cache机制实现
对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上
对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取
对于操作系统来说,磁盘文件都是由一系列的数据块顺序组成,数据块的大小由操作系统本身而决定,操作系统内核在处理文件I/O请求时,首先到page cache中查找(page cache中的每一个数据块都设置了文件以及偏移量地址信息),如果未命中,则启动磁盘I/O,将磁盘文件中的数据块加载到page cache中的一个空闲块,然后再copy到用户缓冲区中。
page cache本身也会对数据文件进行预读取,对于每个文件的第一个读请求操作,系统在读入所请求页面的同时会读入紧随其后的少数几个页面。因此,想要提高page cache的命中率(尽量让访问的页在物理内存中),从硬件的角度来说肯定是物理内存越大越好。从操作系统层面来说,访问page cache时,即使只访问1k的消息,系统也会提前预读取更多的数据,在下次读取消息时, 就很可能可以命中内存。
在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue的读性能会比较高近乎内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Noop”(此时块存储采用SSD的话),随机读的性能也会有所提升。
另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)。采用MappedByteBuffer这种内存映射的方式有几个限制,其中之一是一次只能映射1.5~2G 的文件至用户态的虚拟内存(RocketMQ默认单个CommitLog文件为1G的真正原因)
2.同步刷盘
只有在消息真正持久化至磁盘后RocketMQ的Broker端才会真正返回给Producer端一个成功的ACK响应,同步刷盘对MQ消息可靠性来说是一种不错的保障,但是性能上会有较大影响,一般适用于金融业务应用该模式较多。
3.异步刷盘
只要消息写入PageCache即可将成功的ACK返回给Producer端。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了MQ的性能和吞吐量。
四、总结