概述

上一章中,已经介绍了 Broker 的文件系统的各个层次与部分细节,本章将继续了解在逻辑存储层的三个文件 CommitLog、IndexFile、ConsumerQueue 的一些细节。文章最后,还会对比下 RocketMQ 和 Kafka 的持久化结构与设计的合理性。

CommitLog

现在,先从 CommitLog 的几个指针开始复习

在上一章 《RocketMQ源码详解 | Broker篇 · 其二:文件系统》 中,我们已经了解了 CommitLog 的缓存和刷盘的策略,现在来简单梳理一下。

上文介绍道, CommitLog 在开启 transientStorePool 时,会有一块 writeBuffer,这块 ByteBuffer 是分配的一块堆外内存,也就是图上的灰色部分。我们在上图中看见的 wrote 指针,指向了当前已写入 writeBuffer 但是没有 commit 的位置。

这块灰色部分只存在 Java 进程中,也就是说程序崩了则会丢失。且如果关闭 transientStorePool 选择,该指针将不会存在。

然后当我们定期将 writeBuffer 刷入 FileChannel 后,就变成了图中的红色块。其中的 commited 指针代表在这之前的消息都刷入了 page cache。

这部分的消息由于存放在 page cache 中,且 page cache 是操作系统内核中的一块内存,所以程序崩了不会丢失,但在宕机后依旧会丢失

不过 CommitLog 会根据具体的刷盘策略来异步或同步的进行刷盘,也就是说,在 flushed 指针之前的数据,已经完全的磁盘里了。

且这块数据除非介质被破坏,否则一般不会丢失

而在等待所有的 CommitLogDispatcher 处理完成后,reputed 指针就会前进。而这个 dispatch 做的事,就是我们之前在消息提交时没有发现的两件事:构建 IndexFile 和 ConsumerQueue。

需要注意的是,图中虽然画为 reputed 指针在 flushed 指针后面。但实际上 reputed 指针最快可以和 wrote 指针同步,

CommitLogDispatcher 实现类有:

  • CommitLogDispatcherBuildConsumeQueue
  • CommitLogDispatcherBuildIndex
  • CommitLogDispatcherCalcBitMap

这些类会在后文进行介绍

由上章我们知道,CommitLog 的文件结构如下:

它的长度默认是固定为 1G,文件名为开头的 offset,其中消息是不定长的,在尾部发现新的消息写不下的时候,会新开文件。且在旧的文件写入 当前文件的总长和魔数。

IndexFile

RocketMQ 通过建立 IndexFile 以提供一种能够通过 时间范围Key 值 来查询 Message 的方法。

IndexFile 的文件结构如下:

IndexFile 可以分为三部分

  • Header

    头部记录了记录消息的开始(最小)时间,结束(最大)时间,开始(最小)偏移量,结束(最大)偏移量,和槽的个数与节点个数

  • Slot Table

    table 的槽记录了指向当前槽中尾节点的指针

  • Index Linked List

    记录了所有节点的索引信息

由结构可以看出,IndexFile 是标准的 hash 索引,如果了解过 hash 索引的话,根据上文马上就能猜到到 IndexFile 的运行机制了。

接下来进入源码部分

CommitLogDispatcher

ReputMessageService 是在 DefaultMessageStore 类下启动的一个服务,也就是存储组件层。这个服务会每隔一秒就执行一次 deReput

  1. private void doReput() {
  2. if (this.reputFromOffset < DefaultMessageStore.this.commitLog.getMinOffset()) {
  3. // 重放队列落后太多导致 未重放的 commitLog 过期
  4. log.warn("The reputFromOffset={} is smaller than minPyOffset={}, this usually indicate that the dispatch behind too much and the commitlog has expired.",
  5. this.reputFromOffset, DefaultMessageStore.this.commitLog.getMinOffset());
  6. this.reputFromOffset = DefaultMessageStore.this.commitLog.getMinOffset();
  7. }
  8. for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
  9. /*
  10. * 当系统重启的时候,会根据 duplicationEnable 来决定是否从头开始处理
  11. * 消息还是只处理新来的消息。在其打开的情况下,还需要设置 CommitLog.confirmOffset
  12. * 才能从头开始处理消息,因为默认情况下系统启动以后 CommitLog.confirmOffset
  13. * 和ReputMessageService.reputFromOffset是相等的
  14. */
  15. if (DefaultMessageStore.this.getMessageStoreConfig().isDuplicationEnable()
  16. && this.reputFromOffset >= DefaultMessageStore.this.getConfirmOffset()) {
  17. break;
  18. }
  19. // 切片以当前 reputed 指针为起点的 ByteBuffer,长度为到当前 wrote 指针
  20. SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
  21. if (result != null) {
  22. try {
  23. this.reputFromOffset = result.getStartOffset();
  24. for (int readSize = 0; readSize < result.getSize() && doNext; ) {
  25. // 构建分发用的请求
  26. DispatchRequest dispatchRequest =
  27. DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
  28. int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
  29. if (dispatchRequest.isSuccess()) {
  30. if (size > 0) {
  31. // 进行分发,将请求分发到所有的处理器上
  32. DefaultMessageStore.this.doDispatch(dispatchRequest);
  33. // 对长轮询的处理
  34. if (BrokerRole.SLAVE != DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole()
  35. && DefaultMessageStore.this.brokerConfig.isLongPollingEnable()
  36. && DefaultMessageStore.this.messageArrivingListener != null) {
  37. DefaultMessageStore.this.messageArrivingListener.arriving(dispatchRequest.getTopic(),
  38. dispatchRequest.getQueueId(), dispatchRequest.getConsumeQueueOffset() + 1,
  39. dispatchRequest.getTagsCode(), dispatchRequest.getStoreTimestamp(),
  40. dispatchRequest.getBitMap(), dispatchRequest.getPropertiesMap());
  41. }
  42. // 完成,可以将重放指针的偏移量向前推进
  43. this.reputFromOffset += size;
  44. readSize += size;
  45. // 更新度量信息
  46. if (DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole() == BrokerRole.SLAVE) {
  47. DefaultMessageStore.this.storeStatsService
  48. .getSinglePutMessageTopicTimesTotal(dispatchRequest.getTopic()).incrementAndGet();
  49. DefaultMessageStore.this.storeStatsService
  50. .getSinglePutMessageTopicSizeTotal(dispatchRequest.getTopic()).addAndGet(dispatchRequest.getMsgSize());
  51. }
  52. } else if (size == 0) {
  53. // 当前文件已读完,跳到下一个文件
  54. this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
  55. readSize = result.getSize();
  56. }
  57. } else if (!dispatchRequest.isSuccess()) {
  58. /* pass */
  59. }
  60. }
  61. } finally {
  62. result.release();
  63. }
  64. } else {
  65. doNext = false;
  66. }
  67. }
  68. }

构建了分发数据后,就交给了每一个 CommitLogDispatcher 处理,而在 Index 中,则是调用了 IndexServicebuildIndex 方法。

我们主要关心的是 hash 索引的各种操作,所以接下来先看 put 方法

IndexFile#putKey

首先得到 Slot Table 中,Key 所在的槽号

  1. int keyHash = indexKeyHashMethod(key);
  2. // 得到哈希槽号
  3. int slotPos = keyHash % this.hashSlotNum;
  4. // 得到槽的物理偏移量
  5. int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;

然后获取目标槽的头节点的地址,和当前时间与在 Header 中的 beginTime 的差值

  1. // 获取桶的头节点
  2. int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
  3. if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
  4. slotValue = invalidIndex;
  5. }
  6. long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
  7. timeDiff = timeDiff / 1000;
  8. if (this.indexHeader.getBeginTimestamp() <= 0) {
  9. timeDiff = 0;
  10. } else if (timeDiff > Integer.MAX_VALUE) {
  11. timeDiff = Integer.MAX_VALUE;
  12. } else if (timeDiff < 0) {
  13. timeDiff = 0;
  14. }

在 Index Linked List 中的空桶上添加节点

  1. // 添加索引节点
  2. this.mappedByteBuffer.putInt(absIndexPos, keyHash);
  3. this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
  4. this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
  5. this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);

可以看到这里在时间戳上存放的是与开始时间的偏移值,是一个很好的节省空间的方法。然后将原来的头节点的地址存储

最后更新元信息

  1. this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
  2. if (this.indexHeader.getIndexCount() <= 1) {
  3. this.indexHeader.setBeginPhyOffset(phyOffset);
  4. this.indexHeader.setBeginTimestamp(storeTimestamp);
  5. }
  6. if (invalidIndex == slotValue) {
  7. this.indexHeader.incHashSlotCount();
  8. }
  9. this.indexHeader.incIndexCount();
  10. this.indexHeader.setEndPhyOffset(phyOffset);
  11. this.indexHeader.setEndTimestamp(storeTimestamp);

知道了 put 的原理以后,那 get 也不在话下了

IndexFile#selectPhyOffset

首先还是获取 Slot Table 中 Key 的头节点的位置

  1. int keyHash = indexKeyHashMethod(key);
  2. int slotPos = keyHash % this.hashSlotNum;
  3. int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;

然后在 Index Linked List 中遍历这条链表,直到尾节点

  1. if (phyOffsets.size() >= maxNum) {
  2. break;
  3. }
  4. int absIndexPos =
  5. IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
  6. + nextIndexToRead * indexSize;
  7. int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
  8. long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);
  9. long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
  10. int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);

不过和一般的 Hash 表不同的是,由于时间是有序的,所以在发现遍历到目标节点开始时间的前面的时候,就不会继续遍历了。

且由于相同 Key 是不会覆盖的,所以会把所有和 key 的 hash 相同的 CommitLog 偏移量返回。

  1. long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
  2. boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
  3. if (keyHash == keyHashRead && timeMatched) {
  4. phyOffsets.add(phyOffsetRead);
  5. }
  6. if (prevIndexRead <= invalidIndex
  7. || prevIndexRead > this.indexHeader.getIndexCount()
  8. || prevIndexRead == nextIndexToRead || timeRead < begin) {
  9. break;
  10. }
  11. nextIndexToRead = prevIndexRead;

至于如何在多个 IndexFile 中查找的方法也很简单,只需要在 Header 中根据时间戳来判断是否需要访问即可。

ConsumerQueue

ConsumerQueue 的文件结构较为简单,其由 30W 个的上图中的结构体组成。

通过 CommitLogDispatcherBuildConsumeQueue 分发的消息会在找到对应 Queue 后直接在 MappedFile 文件追加写入这个结构。

而在查找过程也比较简单,这里的查找分为按消息逻辑偏移量查找和按时间戳查找。

对于按消息逻辑偏移量查找,可以通过计算下标来进行随机读取。

而按时间戳查找则是一个比较有趣的部分,它使用了二分法来加速查找:

  1. public long getOffsetInQueueByTime(final long timestamp) {
  2. // 通过时间戳获取刚好在这之前的 MappedFile
  3. MappedFile mappedFile = this.mappedFileQueue.getMappedFileByTime(timestamp);
  4. if (mappedFile != null) {
  5. long offset = 0;
  6. // 低位为 消息队列最小偏移量 与 该文件最小偏移量 中的最小值
  7. int low = minLogicOffset > mappedFile.getFileFromOffset() ? (int) (minLogicOffset - mappedFile.getFileFromOffset()) : 0;
  8. int high = 0;
  9. int midOffset = -1, targetOffset = -1, leftOffset = -1, rightOffset = -1;
  10. long leftIndexValue = -1L, rightIndexValue = -1L;
  11. long minPhysicOffset = this.defaultMessageStore.getMinPhyOffset();
  12. SelectMappedBufferResult sbr = mappedFile.selectMappedBuffer(0);
  13. if (null != sbr) {
  14. ByteBuffer byteBuffer = sbr.getByteBuffer();
  15. high = byteBuffer.limit() - CQ_STORE_UNIT_SIZE;
  16. try {
  17. while (high >= low) {
  18. // ? 奇怪的写法,先除以 CQ_STORE_UNIT_SIZE 再乘以 CQ_STORE_UNIT_SIZE
  19. midOffset = (low + high) / (2 * CQ_STORE_UNIT_SIZE) * CQ_STORE_UNIT_SIZE;
  20. byteBuffer.position(midOffset);
  21. // 获取找到桶在 CommitLog 中的偏移量
  22. long phyOffset = byteBuffer.getLong();
  23. // 获取该消息大小
  24. int size = byteBuffer.getInt();
  25. if (phyOffset < minPhysicOffset) {
  26. low = midOffset + CQ_STORE_UNIT_SIZE;
  27. leftOffset = midOffset;
  28. continue;
  29. }
  30. long storeTime =
  31. this.defaultMessageStore.getCommitLog().pickupStoreTimestamp(phyOffset, size);
  32. // 根据持久化时间进行二分查找
  33. if (storeTime < 0) {
  34. return 0;
  35. } else if (storeTime == timestamp) {
  36. targetOffset = midOffset;
  37. break;
  38. } else if (storeTime > timestamp) {
  39. high = midOffset - CQ_STORE_UNIT_SIZE;
  40. rightOffset = midOffset;
  41. rightIndexValue = storeTime;
  42. } else {
  43. low = midOffset + CQ_STORE_UNIT_SIZE;
  44. leftOffset = midOffset;
  45. leftIndexValue = storeTime;
  46. }
  47. }
  48. if (targetOffset != -1) {
  49. offset = targetOffset;
  50. } else {
  51. if (leftIndexValue == -1) {
  52. offset = rightOffset;
  53. } else if (rightIndexValue == -1) {
  54. offset = leftOffset;
  55. } else {
  56. offset =
  57. Math.abs(timestamp - leftIndexValue) >
  58. Math.abs(timestamp - rightIndexValue) ? rightOffset : leftOffset;
  59. }
  60. }
  61. return (mappedFile.getFileFromOffset() + offset) / CQ_STORE_UNIT_SIZE;
  62. } finally {
  63. sbr.release();
  64. }
  65. }
  66. }
  67. return 0;
  68. }

但是我们发现,消息需要消费的时候,只靠 ComsumerQueue 是不够的,因为在这个结构中并没有记录每一个消费者组的消费进度。

这是因为 Broker 端是将消费进度维护在内存的一个 Map 中,同时会定时的将该 Map 转为 json 格式持久化到磁盘。

Kafka 与 RocketMQ 的对比

最后来对比下 Kafka 和 RocketMQ 的持久化方式。

在 Kafka 中,文件类型主要有:

  • log 文件

    消息的存储文件

  • index 文件

    位置索引。通过逻辑偏移量寻找到在 log 文件中的物理偏移量

  • timeindex 文件

    时间戳索引。可以通过时间戳寻找到在 log 文件中的物理偏移量

从索引上,Kafka 和 RocketMQ 都可以根据位置和时间戳来寻找消息。

但是在存储方法上,Kafka 是直接将每一个 Topic 的分区在物理上通过不同的文件来进行管理,而 RocketMQ 则选择了逻辑的将 Topic 和 Queue 进行划分,写入的位置则是一个单独的文件。

直觉上看,Kafka 的方案由于在物理上进行了划分,而 RocketMQ 还需要维护 Consumer 文件,而两者都是顺序写入,那毫无疑问前者更能减少额外维护的工作。

但实际上,RocketMQ 的底层设计方式是优于 Kafka 的。以下为两者在多个 Topic 的情况下的 TPS 的测量

产品 Topic数量 发送端并发数 发送端RT 发送端TPS 消费端TPS
RocketMQ 64 800 8 9w 8.6w
128 800 9 7.8w 7.7w
256 800 10 7.5w 7.5w
Kafka 64 800 5 13.6w 13.6w
128 256 23 8500 8500
256 256 133 2215 2352

数据来自 阿里中间件团队博客:Kafka vs RocketMQ—— Topic数量对单机性能的影响

可以看出,在 Topic 较少的情况下,Kafka 是可以击败 RocketMQ 的,但一旦 Topic 增加,Kafka 的 TPS 将会断崖式的下降。

原因在于, Kafka 的内存里的顺序写在多 Topic 多 Queue 下被转化为实际上的随机写。

我们都知道,RocketMQ 和 Kafka 都使用了 Page Cache 来加速文件的访问,同时如果在生产后立刻消费的话,消息都是在 Page Cache 中就被发送到网卡缓冲区中(这被称为零拷贝)。但是,内存是有限的, Page Cache 的大小也是有限的,但内存中的页过多,便会触发"换出"。

Topic 和分区多的情况下,打开的文件句柄也变多,被 mmap 映射到内存中的文件也会变多,因此在写入时,多个文件的页轮流被"换入"和"换出",当然就比不过直接顺序写入到内存中的速度了。

而对于读取,两者都需要将所需要的页换入到内存中,故都是随机读,区别不大。

RocketMQ源码详解 | Broker篇 · 其三:CommitLog、索引、消费队列的更多相关文章

  1. RocketMQ源码详解 | Broker篇 · 其四:事务消息、批量消息、延迟消息

    概述 在上文中,我们讨论了消费者对于消息拉取的实现,对于 RocketMQ 这个黑盒的心脏部分,我们顺着消息的发送流程已经将其剖析了大半部分.本章我们不妨乘胜追击,接着讨论各种不同的消息的原理与实现. ...

  2. RocketMQ源码详解 | Broker篇 · 其一:线程模型与接收链路

    概述 在上一节 RocketMQ源码详解 | Producer篇 · 其二:消息组成.发送链路 中,我们终于将消息发送出了 Producer,在短暂的 tcp 握手后,很快它就会进入目的 Broker ...

  3. RocketMQ源码详解 | Broker篇 · 其五:高可用之主从架构

    概述 对于一个消息中间件来讲,高可用功能是极其重要的,RocketMQ 当然也具有其对应的高可用方案. 在 RocketMQ 中,有主从架构和 Dledger 两种高可用方案: 第一种通过主 Brok ...

  4. RocketMQ源码详解 | Broker篇 · 其二:文件系统

    概述 在 Broker 的通用请求处理器将一个消息进行分发后,就来到了 Broker 的专门处理消息存储的业务处理器部分.本篇文章,我们将要探讨关于 RocketMQ 高效的原因之一:文件结构的良好设 ...

  5. RocketMQ源码详解 | Consumer篇 · 其一:消息的 Pull 和 Push

    概述 当消息被存储后,消费者就会将其消费. 这句话简要的概述了一条消息的最总去向,也引出了本文将讨论的问题: 消息什么时候才对被消费者可见? 是在 page cache 中吗?还是在落盘后?还是像 K ...

  6. RocketMQ源码详解 | Producer篇 · 其二:消息组成、发送链路

    概述 在上一节 RocketMQ源码详解 | Producer篇 · 其一:Start,然后 Send 一条消息 中,我们了解了 Producer 在发送消息的流程.这次我们再来具体下看消息的构成与其 ...

  7. RocketMQ源码详解 | Producer篇 · 其一:Start,然后 Send 一条消息

    概述 DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name"); ...

  8. Linux内核源码详解——命令篇之iostat[zz]

    本文主要分析了Linux的iostat命令的源码,iostat的主要功能见博客:性能测试进阶指南——基础篇之磁盘IO iostat源码共563行,应该算是Linux系统命令代码比较少的了.源代码中主要 ...

  9. [转]Linux内核源码详解--iostat

    Linux内核源码详解——命令篇之iostat 转自:http://www.cnblogs.com/york-hust/p/4846497.html 本文主要分析了Linux的iostat命令的源码, ...

随机推荐

  1. Java基础系列(8)- 数据类型

    数据类型 强类型语言 要求变量的使用合乎规定,所有的变量都必须先定义才能使用.Java是强类型语言. 弱类型语言 变量定义比较随意,比如"12"+3,可以是int型123,也可以是 ...

  2. Shell系列(9)- 用户自定义变量(2)

    定义变量 变量名=变量值 例如: x=123 mulu="当前目录下有 $(ls)" 备注: 变量名只能是字母.下划线.数字组成且不能以数字开头 变量等号两侧不能加空格 若变量值中 ...

  3. mybatis if else if 条件判断SQL片段表达式取值和拼接

    前言 最近在开发项目的时候涉及到复杂的动态条件查询,但是mybaits本身不支持if elseif类似的判断但是我们可以间接通过 chose when otherwise 去实现其中choose为一个 ...

  4. Java-基础-JDK动态代理

    1. 简介 代理模式的定义:为其他对象提供一种代理以控制对这个对象的访问.在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用. 比如:我们在调用 ...

  5. 解决 Asp.Net5 在视频文件下载预览时无法快进的问题

    前情提要 https://www.cnblogs.com/puzhiwei/p/15265005.html 在解决.Net5 如何修改Content-Disposition实现在线预览的功能后,我又遇 ...

  6. UDP用户数据报

    UDP 用户数据报协议UDP只在IP的数据报服务之上增加了很少的一个功能,就是复用,分用,差错检测功能.UDP的主要特点是: UDP是无连接的,即在发送数据报之前不需要建立连接(当然发送数据结束的时候 ...

  7. 【C++ Primer Plus】编程练习答案——第12章

    1 // chapter12_1_cow.h 2 3 4 #ifndef LEARN_CPP_CHAPTER12_1_COW_H 5 #define LEARN_CPP_CHAPTER12_1_COW ...

  8. Go语言核心36讲(Go语言基础知识二)--学习笔记

    02 | 命令源码文件 我们已经知道,环境变量 GOPATH 指向的是一个或多个工作区,每个工作区中都会有以代码包为基本组织形式的源码文件. 这里的源码文件又分为三种,即:命令源码文件.库源码文件和测 ...

  9. Knativa 基于流量的灰度发布和自动弹性实践

    作者 | 李鹏(元毅) 来源 | Serverless 公众号 一.Knative Knative 提供了基于流量的自动扩缩容能力,可以根据应用的请求量,在高峰时自动扩容实例数:当请求量减少以后,自动 ...

  10. 网络协议之:加密传输中的NPN和ALPN

    目录 简介 SSL/TLS协议 NPN和ALPN 交互的例子 总结 简介 自从HTTP从1.1升级到了2,一切都变得不同了.虽然HTTP2没有强制说必须使用加密协议进行传输,但是业界的标准包括各大流行 ...