RocketMQ 存储概要设计
目前的 MQ 中间件从存储模型来分,分为需要持久化和不需要持久化的两种模型,大多数的是支持持久化存储的,比如 ActiveMQ,RabbitMQ Kafka,RocketMQ,ZeroMQ 不支持持久化存储,而业务系统也大多需要 MQ 有持久存储的能力,这样可以大大增加系统的高可用性。
持久化能力从存储方式和效率来看,文件系统高于 KV 存储, KV 存储又高于关系型数据库,直接操作文件系统肯定是最快的,但如果从可靠性的角度出发,直接操作文件系统是最低的,而关系型数据库的可靠性是最高的。
RocketMQ 主要存储的文件包括 Commitlog 文件、ConsumeQueue 文件、IndexFile。RocketMQ 将所有主题的消息存储在同一文件,确保消息发送时顺序写文件,尽最大的能力确保消息发送的高性能与高吞吐量。但由于一般的消息中间件是基于消息主题的订阅机制,这样便给按照消息主题检索消息带来了极大的不便。为了提高消息消费的效率,RocketMQ 引入了 ConsumeQueue 消息队列文件,每个消息主题包含多个消息消费队列,每个消息队列有一个消息 IndexFile 索引文件,其主要设计理念就是为了加速消息的检索性能,可以根据消息的属性快速从 Commitlog 文件中检索消息。整体如下:
- CommitLog :消息存储文件,所有消息主题的消息都存储在 CommitLog 文件中
- ConsumeQueue :消息消费队列,消息到达 CommitLog 文件后,将异步转发到消息消费队列,供消息消费者消费
- IndexFile :消息索引文件,主要存储消息 Key 与 Offset 的对应关系
消息存储结构
CommitLog
CommitLog 以物理文件的方式存放,每台 Broker 上的 CommitLog 被本机器所有 ConsumeQueue 共享,文件地址:$ {user.home}\store\${ commitlog}\${ fileName}。在 CommitLog 中,一个消息的存储长度是不固定的,RocketMQ 采取一些机制,尽量向 CommitLog 中顺序写,但是随机读。commitlog 文件默认大小为 1G ,可通过在 broker 配置文件中设置 mapedFileSizeCommitLog 属性来改变默认大小。
CommitLog windows存储位置:
Linux 存储位置:/root/store/commitlog
Commitlog 文件存储的逻辑视图如下,每条消息的前面 4 个字节存储该条消息的总长度,但是一个消息的存储长度是不固定的。
ConsumeQueue
ConsumeQueue 是消息的逻辑队列,类似数据库的索引文件,存储的是指向物理存储的地址。每个 Topic 下的每个 Message Queue 都有一个对应的ConsumeQueue 文件, 文件地址在$ {$storeRoot} \consumequeue\$ {topicName} \$ { queueld} \$ {fileName}。
ConsumeQueue 中存储的是消息条目,为了加速 ConsumeQueue 消息条目的检索速度与节省磁盘空间,每一个 ConsumeQueue 条目不会存储消息的全量信息,消息条目如下:
ConsumeQueue 即为 Commitlog 文件的索引文件,其构建机制是当消息到达 Commitlog 文件后由专门的线程产生消息转发任务,从而构建消息消费队列文件(ConsumeQueue )与下文提到的索引文件。
这种存储机制的优点:
- CommitLog 顺序写,可以大大提高写入效率。(实际上,磁盘有时候会比你想象的快很多,有时候也比你想象的慢很多,关键在如何使用,使用得当,磁盘的速度完全可以匹配上网络的数据传输速度。目前的高性能磁盘, 顺序写速度可以达到 600MB/s ,超过了一般网卡的传输速度,这是磁盘比想象的快的地方,但是磁盘随机写的速度只有大概 100KB/s,和顺序写的性能相差 6000 倍!)
- 虽然是随机读,但是利用操作系统的 pagecache 机制,可以批量地从磁盘读取,作为 cache 存到内存中,加速后续的读取速度
- 为了保证完全的顺序写,需要 ConsumeQueue 这个中间结构 ,因为 ConsumeQueue 里只存偏移量信息,所以尺寸是有限的,在实际情况中,大部分的 ConsumeQueue 能够被全部读入内存,所以这个中间结构的操作速度很快,可以认为是内存读取的速度。此外为了保证 CommitLog 和 ConsumeQueue 的一致性, CommitLog 里存储了 Consume Queues 、Message Key、 Tag 等所有数据,即使 ConsumeQueue 丢失,也可以通过 commitLog 完全恢复出来
IndexFile
index 存的是索引文件,这个文件用来加快消息查询的速度。消息消费队列 RocketMQ 专门为消息订阅构建的索引文件,提高根据主题与消息检索消息的速度,使用 Hash 索引机制,具体是 Hash 槽与 Hash 冲突的链表结构。
Config
config 文件夹中存储着 Topic 和 Consumer 等相关信息。主题和消费者群组相关的信息就存在在此。
topics.json:topic 配置属性
subscriptionGroup.json:消息消费组配置信息。
delayOffset.json:延时消息队列拉取进度。
consumerOffset.json:集群消费模式消息消费进度。
consumerFilter.json :主题消息过滤信息。
其他
abort :如果存在 abort 文件说明 Broker 非正常闭,该文件默认启动时创建,正常退出之前删除
checkpoint :文件检测点,存储 commitlog 文件最后一次刷盘时间戳、 consumequeue 最后一次刷盘时间、 index 索引文件最后一次刷盘时间戳。
内存映射
内存映射文件,是由一个文件到一块内存的映射。文件的数据就是这块区域内存中对应的数据,读写文件中的数据,直接对这块区域的地址操作就可以,减少了内存复制的环节。所以说,内存映射文件比起文件 I/O 操作,效率要高,而且文件越大,体现出来的差距越大。
RocketMQ 通过使用内存映射文件来提高 IO 访问性能,无论是 CommitLog,ConsumeQueue 还是 IndexFile ,单个文件都被设计为固定长度,如果一个文件写满以后再创建一个新文件,文件名就为该文件第一条消息对应的全局物理偏移量。
文件刷盘机制
RocketMQ 存储与读写是基于 JDK NIO 的内存映射机制,具体使用 MappedByteBuffer(基于 MappedByteBuffer 操作大文件的方式,其读写性能极高),最终将消息存储到磁盘上,这样既能保证断电后恢复,又可以让存储的消息超出内存的限制,RocketMQ 为了提高性能,会尽可能地保证磁盘的顺序写。
消息存储时首先将消息追加到内存,再根据配置的不同的刷盘策略,在不同时间进行刷写磁盘。通过在 broker 配置文件中配置 flushDiskType 来设定刷盘方式,有两种刷盘方式,可选值为 ASYNC_FLUSH (异步刷盘), SYNC_FLUSH 同步刷盘) 默认为异步。
异步刷盘方式
在返回写成功状态时,消息可能只是被写入了内存的 pageCache ,写操作的返回快,吞吐量大;当内存里的消息积累到一定程度时,RocketMQ 会使用一个单独的线程按照某个设定的频统一触发写磁盘动作,快速写入。
同步刷盘方式
在返回写成功状态时,消息已经被写人磁盘,具体流程是,消息写入内存的 pageCache 后,将同步调用 MappedByteBuffer.force() 方法,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态
总结
实际应用中要结合业务场景,合理设置刷盘方式,尤其是同步刷盘的方式,由于频繁的触发磁盘写动作,会明显降低性能。通常情况下,应该把 Rocket 置成异步刷盘方式。
过期文件删除
由于 RocketMQ 操作 CommitLog,ConsumeQueue 文件是基于内存映射机制并在启动的时候会加载 commitlog,ConsumeQueue 目录下的所有文件,为了避免内存与磁盘的浪费,不可能将消息永久存储在消息服务器上,所以需要引入一种机制来删除己过期的文件。
删除过程分别执行清理消息存储文件( Commitlog )与消息消费 队列文件( ConsumeQueue 文件),消息消费队列文件与消息存储文件( Commitlog )共用一套过期文件处理机制。
RocketMQ 清除过期文件的方法是:如果非当前正在写的文件,在一定时间间隔内没有再次被更新,则认为是过期文件,可以被删除,RocketMQ 不会关注这个文件上的消息是否全部被消费。默认每个文件的过期时间为 42 小时(不同版本的默认值不同,这里以 4.4.0 为例),通过在 Broker 配置文件中设置 fileReservedTime 来改变过期时间,单位为小时。
触发文件清除操作的是一个定时任务,而且只有定时任务,文件过期删除定时任务的周期由该删除决定,默认每 10s 执行一次。
过期判断
文件删除主要是由这个配置属性:fileReservedTime:文件保留时间。也就是从最后一次更新时间到现在,如果超过了该时间,则认为是过期文件,可以删除。
另外还有其他两个配置参数:
deletePhysicFilesInterval:删除物理文件的时间间隔(默认是 100ms),在一次定时任务触发时,可能会有多个物理文件超过过期时间可被删除, 因此删除一个文件后需要间隔 deletePhysicFilesInterval 这个时间再删除另外一个文件,由于删除文件是一个非常耗费 IO 的操作,会引起消息插入消费的延迟(相比于正常情况下),所以不建议直接删除所有过期文件。
destroyMapedFileIntervalForcibly:在删除文件时,如果该文件还被线程引用,此时会阻止此次删除操作,同时将该文件标记不可用并且纪录当前时间戳,destroyMapedFileIntervalForcibly 表示文件在第一次删除拒绝后文件可保存的最大时间,在此时间内一直会被拒绝删除,当超过这个时间时,会将引用每次减少 1000,直到引用小于等于 0 为止,即可删除该文件.
删除条件
- 通过指定 deleteWhen 设置每天的固定时间删除过期文件,默认为凌晨 4 点。
- 磁盘空间是否充足,如果磁盘空间不充足(DiskSpaceCleanForciblyRatio。磁盘空间强制删除文件水位。默认是 85),会触发过期文件删除操作。
另外还有 RocketMQ 的磁盘配置参数:
- 物理使用率大于 diskSpaceWarningLevelRatio(默认 90%可通过参数设置),则会阻止新消息的插入
- 物理磁盘使用率小于 diskMaxUsedSpaceRatio(默认 75%),表示磁盘使用正常。
RocketMQ 中的事务消息
事务消息实现思想
RocketMQ 事务消息,是指发送消息事件和其他事件需要同时成功或同失败。比如银行转账,A 银行的某账户要转一万元到 B 银行的某账户。A 银行发送“B 银行账户增加一万元” 这个消息,要和“从 A 银行账户扣除一万元”这个操作同时成功或者同时失败。
RocketMQ 采用两阶段提交的方式实现事务消息,TransactionMQProducer 处理上面情况的流程是,先发一个“准备从 B 银行账户增加一万元”的消息,发送成功后做从 A 银行账户扣除一万元的操作,根据操作结果是否成功,确定之前的“准备从 B 银行账户增加一万元”的消息是做 commit 还是 rollback,RocketMQ 实现的具体流程如下:
1)发送方向 RocketMQ 发送“待确认”(Prepare)消息。
2 ) RocketMQ 将收到的“待确认”(一般写入一个 HalfTopic 主题<RMQ_SYS_TRANS_HALF_TOPIC>)消息持化成功后,向发送方回复消息已经发送成功,此时第一阶段消息发送完成。
发送方开始执行本地事件逻辑.
- 发送方根据事件执行结果向 RocketMQ 发送二次确认( Commit 还是 Rollback)消息,RocketMQ 收到 Commit 则将第一阶段消息标记为可投递(这些消息才会进入生产时发送实际的主题 RealTopic),订阅方将能够收到该消息;收到 Rollback 状态则删除第一阶段的消息,订阅方接收不到该消息。
- 如果出现异常情况,步骤 3 提交的二次确认最终未到达 RocketMQ,服务器在经过固定时间段后将对“待确认”消息、发起回查请求。
- 发送方收到消息回查请求后(如果发送一阶段消息的 Producer 不能工作,回查请求将被发送到和 Producer 在同一个 Group 里的其他 Producer ),通过检查对应消息的本地事件执行结果返回 Commit or Roolback 状态。
两阶段提交
提交半事务是一个阶段,提交全事务和事务回查是另外一个阶段,所以称之为两阶段提交。
事务状态回查机制
RocketMQ 通过 TransactionalMessageCheckService 线程定时去检测 RMQ_SYS_ TRANS_ HALF_TOPIC 主题中的消息,回查消息的事务状态
TransactionalMessageCheckService 的检测频率默认为 1 分钟,可通过在 broker.conf 文件中设置 transactionChecklnterval 来改变默认值,单位为毫秒。
代码实现(略)
LocalTransactionState 枚举类,
COMMIT_MESSAGE 提交消息,即 broker 确认了这条消息的正确性之后执行提交,标记这条消息可被消费,这样的话 consumer 就可以正常消费这条消息了;
ROLLBACK_MESSAGE 回滚消息,意思是当我们的本地主事务发生异常的时候,回滚本地事务的同时,同样需要一种方法通知到 rocketMq 不要继续发送消息了,当 broker 收到这个命令时候就会标记消息为 rollBack 的状态,consumer 就不能收到了