RocketMQ(4.8.0)——Broker 消息结构、PageCache和零拷贝技术
Broker 消息结构、PageCache和零拷贝技术
堆积能力是消息队列的一个重要考核指标。存储机制是 RocketMQ 中的核心,也是亮点设计,因为存储机制决定写入和查询的效率。
一、Broker 消息存储结构
1.1 Broker 存储概述
Broker 通过 CommitLog、ConsumeQueue、IndexFile 等来组织存储消息。
先介绍消息存储文件 CommitLog。org.apache.rocketmq.store.CommitLog 类负责处理全部消息的存储逻辑——普通消息、定时消息、顺序消息、未消费的消息和已消费的消息。
消息的保存结构如下表:
写入顺序 | 字段 | 说明 | 数据类型 | 字节数 |
1 | MsgLen | 消息总长度 | Int | 4 |
2 | MagicCode | MESSAGE_MAGIC_CODE | Int | 4 |
3 | BodyCRC | 消息内容CRC | Int | 4 |
4 | QueueId | 消息所在分区id | Int | 4 |
5 | QueueOffset | 消息所在分区的位置 | Int | 4 |
6 | PhysicalOffset | 消息所在 Commitlog 文件的物理位置 | Long | 8 |
7 |
SysFlag |
系统标志 | Int | 4 |
8 | Born Timestamp | 发送消息时间戳 | Long | 8 |
9 | Born Host | 发送消息主机 | Long | 8 |
10 | StoreTimestamp | 存储消息时间戳 | Long | 8 |
11 | StoreHost |
存储消息主机 | Long | 8 |
12 | ReconsumeTimes | 重试消息重试第几次 | Int | 4 |
13 | PreparedTransationOffset | 事务消息位点 |
Long | 8 |
14 | BodyLength | 消息体内容长度 | Int | 4 |
15 | Body | 消息体 | byte[] | 数组长度 |
16 | TopicLength | Topic 长度 | byte | 1 |
17 | Topic | Topic 名字 | byte[] | 数组长度 |
18 | PropertiesLength | 扩展信息长度 | short | 2 |
19 | Properties | 扩展信息 | byte[] | 数组长度 |
CommitLog 只有一个文件,为了方便保存和读写被切分为多个子文件,所有的子文件通过其保存的第一个和最后一个消息的物理位点进行连接。
Broker 按照时间和物理的 offset 顺序写 CommitLog 文件,每次写的时候需要加锁,代码路径:D:\rocketmq-master\store\src\main\java\org\apache\rocketmq\store\CommitLog.java,代码如下:
1 public PutMessageResult putMessage(final MessageExtBrokerInner msg) { 2 // Set the storage time 3 msg.setStoreTimestamp(System.currentTimeMillis()); 4 // Set the message body BODY CRC (consider the most appropriate setting 5 // on the client) 6 msg.setBodyCRC(UtilAll.crc32(msg.getBody())); 7 // Back to Results 8 AppendMessageResult result = null; 9 10 StoreStatsService storeStatsService = this.defaultMessageStore.getStoreStatsService(); 11 12 String topic = msg.getTopic(); 13 int queueId = msg.getQueueId(); 14 15 final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag()); 16 if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE 17 || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) { 18 // Delay Delivery 19 if (msg.getDelayTimeLevel() > 0) { 20 if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) { 21 msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()); 22 } 23 24 topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC; 25 queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel()); 26 27 // Backup real topic, queueId 28 MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic()); 29 MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId())); 30 msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties())); 31 32 msg.setTopic(topic); 33 msg.setQueueId(queueId); 34 } 35 } 36 37 InetSocketAddress bornSocketAddress = (InetSocketAddress) msg.getBornHost(); 38 if (bornSocketAddress.getAddress() instanceof Inet6Address) { 39 msg.setBornHostV6Flag(); 40 } 41 42 InetSocketAddress storeSocketAddress = (InetSocketAddress) msg.getStoreHost(); 43 if (storeSocketAddress.getAddress() instanceof Inet6Address) { 44 msg.setStoreHostAddressV6Flag(); 45 } 46 47 long elapsedTimeInLock = 0; 48 49 MappedFile unlockMappedFile = null; 50 MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(); 51 52 putMessageLock.lock(); //spin or ReentrantLock ,depending on store config 53 try { 54 long beginLockTimestamp = this.defaultMessageStore.getSystemClock().now(); 55 this.beginTimeInLock = beginLockTimestamp; 56 57 // Here settings are stored timestamp, in order to ensure an orderly 58 // global 59 msg.setStoreTimestamp(beginLockTimestamp); 60 61 if (null == mappedFile || mappedFile.isFull()) { 62 mappedFile = this.mappedFileQueue.getLastMappedFile(0); // Mark: NewFile may be cause noise 63 } 64 if (null == mappedFile) { 65 log.error("create mapped file1 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString()); 66 beginTimeInLock = 0; 67 return new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, null); 68 } 69 70 result = mappedFile.appendMessage(msg, this.appendMessageCallback); 71 switch (result.getStatus()) { 72 case PUT_OK: 73 break; 74 case END_OF_FILE: 75 unlockMappedFile = mappedFile; 76 // Create a new file, re-write the message 77 mappedFile = this.mappedFileQueue.getLastMappedFile(0); 78 if (null == mappedFile) { 79 // XXX: warn and notify me 80 log.error("create mapped file2 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString()); 81 beginTimeInLock = 0; 82 return new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, result); 83 } 84 result = mappedFile.appendMessage(msg, this.appendMessageCallback); 85 break; 86 case MESSAGE_SIZE_EXCEEDED: 87 case PROPERTIES_SIZE_EXCEEDED: 88 beginTimeInLock = 0; 89 return new PutMessageResult(PutMessageStatus.MESSAGE_ILLEGAL, result); 90 case UNKNOWN_ERROR: 91 beginTimeInLock = 0; 92 return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result); 93 default: 94 beginTimeInLock = 0; 95 return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result); 96 } 97 98 elapsedTimeInLock = this.defaultMessageStore.getSystemClock().now() - beginLockTimestamp; 99 beginTimeInLock = 0; 100 } finally { 101 putMessageLock.unlock(); 102 } 103 104 if (elapsedTimeInLock > 500) { 105 log.warn("[NOTIFYME]putMessage in lock cost time(ms)={}, bodyLength={} AppendMessageResult={}", elapsedTimeInLock, msg.getBody().length, result); 106 } 107 108 if (null != unlockMappedFile && this.defaultMessageStore.getMessageStoreConfig().isWarmMapedFileEnable()) { 109 this.defaultMessageStore.unlockMappedFile(unlockMappedFile); 110 } 111 112 PutMessageResult putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result); 113 114 // Statistics 115 storeStatsService.getSinglePutMessageTopicTimesTotal(msg.getTopic()).incrementAndGet(); 116 storeStatsService.getSinglePutMessageTopicSizeTotal(topic).addAndGet(result.getWroteBytes()); 117 118 handleDiskFlush(result, putMessageResult, msg); 119 handleHA(result, putMessageResult, msg); 120 121 return putMessageResult; 122 }
每个 CommitLog 子文件的大小默认是 1GB( 1024 * 1024 * 1024B),可以通过 mapedFileSizeCommitLog 进行配置。当一个 CommitLog 写满后,创建一个新的 CommitLog,继续上一个 CommitLog 的 Offset 写操作,直到写满换成下一个文件。所有 CommitLog 子文件之间的 Offset 是连接,所以最后一个 CommitLog 总是被写入的。
1.2 为什么写文件这么快
RocketMQ 的存储设计中,很大一部分是基于 Kafka 的设计进行优化的。RocketMQ 是基于 Java 编写的消息中间件,支持万亿级的消息扭转和保存,RocketMQ 写文件为什么会这么快呢?我们先了解下一些名词:
Page Cache:现代操作系统内核被设计为按照 Page 读取文件,每个 Page 默认为 4KB。因为程序一般符合局部性原理,所以操作系统在读取一段文件内容时,会将该段内容和附件的文件内容都读取到内核 Page 中(预读),下次读取的内容如果命中 Page Cache 就可以直接返回内容,不用再次读取磁盘。
Page Cache 机制也不是完全无缺点的,当遇到操作系统进行采用了多种优化技术,比如内存预分配、文件预热、mlock 系统调用等,以保证最大限度地发挥 Page Cache 机制的优点的同时,尽可能地减少消息读写延迟。所以在生产部署 RocketMQ 的时候,尽量采用 SSD 独享磁盘,这样可以最大限度地保证读写性能。
Virtual Memory(虚拟内存):为了保证每个程序有足够的运行空间和编程空间,可以将一些暂时不用的内存数据保存到交换区(其实是磁盘)中,这样就可以运行更多的程序,这种“内存”被称为虚拟内存(因为不是真的内存)。
操作系统的可分配内存大小 = 虚拟内存大小 + 物理内存大小
零拷贝和Java文件映射:从文件读取流程可以看到,读取到内核态的数据会经历两次拷贝,第一次从内核态内存拷贝到用户态内存,第二次从用户态内存拷贝到 Java 进程的某个变量地址,这样 Java 变量才能读取数据。
java.nio.MappedByteBuffer.java 文件中实现了零拷贝技术,即 java 进程映射到内核态内存,原来内核态内存与用户态内存的互相拷贝过程就消失了。
在消息系统中,用户关心的往往都是最新的数据,理论上,基本的操作都在 PageCache 中,Page Cache 的操作速度和内存基本持平,所以速度非常快。当然,也存在读取历史消息而历史消息不在 PageCache 中的的情况,比如在流处理和批处理中,经常将消费重置到历史消息位点,以重新计算全部结果。这种情况只发生在第一次拉取消息时会读取磁盘,以后可以利用磁盘预读,几乎可以做到不再直接读取磁盘,其性能与利用 PageCache 相比,只有在第一次有差异。