共计 15149 个字符,预计需要花费 38 分钟才能阅读完成。
作者:斜阳
RocketMQ 实现了灵便的多分区和多正本机制,无效的防止了集群内单点故障对于整体服务可用性的影响。存储机制和高可用策略是 RocketMQ 稳定性的外围,社区上对于 RocketMQ 目前存储实现的剖析与探讨始终是一个热议的话题。近期我始终在负责 RocketMQ 音讯多正本和高可用能力的建设,和大家分享下一些乏味的想法。
本文想从一个不一样的视角,着重于谈谈我眼中的这种存储实现是在解决哪些简单的问题,因而我从本文最后的版本中删去了繁杂的代码细节剖析,由浅入深的剖析存储机制的缺点与优化方向。
RocketMQ 的架构模型与存储分类
先来简略介绍下 RocketMQ 的架构模型。RocketMQ 是一个典型的公布订阅零碎,通过 Broker 节点直达和长久化数据,解耦上下游。Broker 是实在存储数据的节点,由多个程度部署但不肯定齐全对等的正本组形成,单个正本组的不同节点的数据会达到最终统一。对于单个正本组来说同一时间最多只会有一个可读写的 Master 和若干个只读的 Slave,主故障时须要选举来进行单点故障的容错,此时这个正本组是可读不可写的。
NameServer 是独立的一个无状态组件,承受 Broker 的元数据注册并动静保护着一些映射关系,同时为客户端提供服务发现的能力。在这个模型中,咱们应用不同主题 (Topic) 来辨别不同类别信息流,为消费者设置订阅组 (Group) 进行更好的治理与负载平衡。
如下图两头局部所示:
- 服务端 Broker Master1 和 Slave1 形成其中的一个正本组。
- 服务端 Broker 1 和 Broker 2 两个正本组以负载平衡的模式独特为客户端提供读写。
RocketMQ 目前的存储实现能够分为几个局部:
- 元数据管理
- 具体指以后存储节点的主题 Topic,订阅组 Group,生产进度 ConsumerOffset。
- 多个配置文件 Config,以及为了故障复原的存储 Checkpoint 和 FileLock。
- 用来记录正本主备身份的 Epoch / SN (sequence number) 文件等(5.0-beta 引入,也能够看作 term)
- 音讯数据管理,包含音讯存储的文件 CommitLog,文件版定时音讯的 TimerLog。
- 索引数据管理,包含按队列的顺序索引 ConsumeQueue 和随机索引 IndexFile。
元数据管理与优化
为了晋升整体的吞吐量与提供跨正本组的高可用能力,RocketMQ 服务端个别会为单个 Topic 创立多个逻辑分区,即在多个正本组上各自保护局部分区 (Partition),咱们把它称为队列 (MessageQueue)。同一个正本组上同一个 Topic 的队列数雷同并从 0 开始间断编号,不同正本组上的 MessageQueue 数量能够不同。
例如 topic-a 能够在 broker-1 主正本上有 4 个队列,编号 (queueId) 是 0-3,在 broker-1 备正本上完全相同,然而 broker-2 上可能就只有 2 个队列,编号 0-1。在 Broker 上元数据的组织治理形式是与上述模型匹配的,每一个 Topic 的 TopicConfig,蕴含了几个外围的属性,名称,读写队列数,权限与许多元数据标识,这个模型相似于 K8s 的 StatefulSet,队列从 0 开始编号,扩缩队列都在尾部操作(例如 24 个队列缩分区到 16,是留下了编号为 0-15 的分区)。这使得咱们无需像 Kafka 一样对每个分区独自保护状态机,同时大幅度的简化了对于分区的实现。
咱们会在存储节点的内存中简略的保护 Map 的构造来将 TopicName 间接映射到它的具体参数。这个设计足够的简略,也隐含了一些缺点,例如它没有实现一个原生 Namespace 机制来实现存储层面上多租户环境下的元数据的隔离,这也是 RocketMQ 5.0 向云原生时代迈进过程中一个重要的演进方向。
当 Broker 接管到内部管控命令,例如创立或删除一些 Topic,这个内存 Map 中就会对应的更新或者删除一个 KV 对,须要立即序列化一次并向磁盘笼罩,否则就会造成失落更新。对于单租户的场景下,Topic (Key) 的数量不会超过几千个,文件大小也只有数百 KB,速度是十分快。
然而在云上大多租的场景下,一个存储节点的 Topic 能够达到十几 MB。每次变更一个 KV 就全量向磁盘笼罩写这个大文件,这个操作的开销十分高,尤其是在数据须要跨集群,跨节点迁徙,或者应急状况下扩容逃生场景下,同步写文件重大缩短了外围管控命令的响应工夫,也成为云上大共享模式下严厉的挑战之一。在这个背景下,两个解决方案很天然的就产生了,即批量更新接口和增量更新机制。
- 批量更新指每次服务端能够承受一批 TopicConfig 的更新,这样 Broker 刷写文件的频率就显著的升高。
- 增量更新指将这个 Map 的长久化换成逻辑替换成 KV 型的数据库或实现元数据的 Append 写,以 Compaction 的模式保护一致性。
除了最重要的 Topic 信息,Broker 还治理着 Group 信息,生产组的生产进度 ConsumerOffset 和多个配置文件。Group 的变更和 Topic 相似,都是只有新建或者删除时才须要长久化。而 ConsumeOffset 是用来保护每个订阅组的生产进度的,构造如 Map>。这里咱们从文件自身的作用和数据结构的角度进行剖析下,Topic Group 尽管数量多,然而变动的频率还是比拟低的,而提交与长久化位点时时刻刻都在进行,进而导致这个 Map 简直在实时更新,然而上一更新后的数据 (last commit offset) 对以后来说又没有什么用,并且容许丢大量更新。
所以这里 RocketMQ 没有像 Topic Group 那样采取数据变动时刷写文件,而是应用一个定时工作对这个 Map 做 CheckPoint。这个周期默认是 5 秒,所以当服务端主备切换或者失常公布时,都会有秒级的音讯反复。
那么这里还有没有优化的空间呢?事实上大部分的订阅组都是不在线的,每次咱们也只须要更新位点有变动的这部分订阅组。所以这里咱们能够采取一个差分优化的策略(加入过 ACM 的选手应该更相熟,搜寻差分数据传输),在主备同步 Offset 或者长久化的时候只更新变动的内容。如果此时咱们除了晓得以后的 Offset,还须要一个历史 Offset 的提交记录怎么办,这种状况下,应用一个内置的零碎 Topic 来保留每次提交(某种意义上的自举实现,Kafka 就是应用一个外部 Topic 来保留位点),通过回放或查找音讯来追溯生产进度。因为 RocketMQ 反对海量 Topic,元数据的规模会更加大,采纳目前的实现开销更小。
所以选用哪种实现齐全是由咱们所面对的需要决定的,实现也能够是灵便多变的。当然,在 RocketMQ 元数据管理上,如何在下层保障分布式环境下多个正本组上的数据统一又是另外一个令人头疼的难题,后续文章会更加具体的探讨这点。
音讯数据管理
很多文章都提到 RocketMQ 存储的外围是一个极致优化的程序写盘,以 append only 的模式一直的将新的音讯追加到文件开端。
RocketMQ 应用了一种称为 MappedByteBuffer 的内存映射文件的方法,将一个文件映射到过程的地址空间,实现文件的磁盘地址和过程的一段虚拟地址关联,实际上是利用了 NIO 中的 FileChannel 模型。在进行这种绑定后,用户过程就能够用指针(偏移量)的模式写入磁盘而不必进行 read / write 的零碎调用,缩小了数据在缓冲区之间来回拷贝的开销。当然这种内核实现的机制有一些限度,单个 mmap 的文件不能太大 (RocketMQ 抉择了 1G),此时再把多个 mmap 的文件用一个链表串起来形成一个逻辑队列 (称为 MappedFileQueue),就能够在逻辑上实现一个无需思考长度的存储空间来保留全副的音讯。
这里不同 Topic 的音讯间接进行混合的 append only 写,相比于随机写来说性能的晋升十分显著的。还有一个重要的细节,这里的混合写的写放大非常低。当咱们回头去看 Google 实现的 BigTable 的实践模型,各种 LSM 树及其变种,都是将原来的间接保护树转为增量写的形式来保障写性能,再叠加周期性的异步合并来缩小文件的个数,这个动作也称为 Compaction。
RocksDB 和 LevelDB 在写放大,读放大,空间放大都有几倍到几十倍的开销。得益于音讯自身的不可变性,和非沉积的场景下,数据一旦写入两头代理 Broker 很快就会被上游生产掉的个性,此时咱们不须要在写入时就保护 memTable,防止了数据的散发与重建。相比于各种数据库的存储引擎,音讯这样近似 FIFO 的实现能够节俭大量的资源,同时缩小了 CheckPoint 的复杂度。对于同一个正本组上的多个正本之间的数据复制都是全副由存储层自行治理,这个设计相似于 bigtable 和 GFS,azure 的 Partation layer,也被称为 Layered Replication 分层架构。
单条音讯的存储格局
RocketMQ 有一套绝对简单的音讯存储编码用来将音讯对象序列化,随后再将一个非定长的数据落到上述的实在的写入到文件中,值得注意的存储格局中包含了索引队列的编号和地位。
存储时单条音讯自身元数据占用的存储空间为固定的 91B + 局部属性,而音讯的 payload 通常大于 2K,也就是说元数据带来的额定存储开销只减少了 5%-10% 左右。很显著,单条音讯越大,存储自身额定的开销(比例)就绝对的越少。但如果有大音讯的诉求,例如想在 body 中保留一张序列化后的图片(二进制大对象),从目前的实现上说,在音讯中保留援用,将实在数据保留到到其余组件,生产时读取援用(比方文件名或者 uk)其实是一个更适合的设计。
多条音讯的间断写
上文提到,不同 Topic 的音讯数据是间接混合追加数据到 CommitLog 中 (也就是上文提到的 MappedFileQueue),再交由其余后端线程做散发。其实我感觉 RocketMQ 这种 CommitLog 与元数据的离开治理的机制也有一些 PacificaA (微软提出的复制框架) 的影子,从而以一种更简略的形式实现强统一。
这里的强统一指的是在 Master Broker (对应于 PacificA 的 Primary) 对所有音讯的长久化进行定序,再通过全序播送 (total order broadcast) 实现线性统一 (Linearizability)。这几种实现都会须要解决两个相似的问题,一是如何实现单机下的程序写,二是如何放慢写入的速度。
如果是正本组是异步多写的(高性能中可靠性),将日志非最新(水位最高)的备选为主,主备的数据日志可能会产生分叉。在 RocketMQ 5.0 中,主备会通过基于版本的协商机制,应用落后补齐,截断未提交数据等形式来保证数据的一致性。
顺便一提,RocketMQ 5.0 中实现了 logic queue 计划解决全局分区数变动的问题,这和 PacificaA 中通过 new-seal 新增正本组和分片 merge 给计算层读的一些优化策略有一些殊途同归之妙,具体能够参考这个设计方案。
- 独占锁实现程序写
如何保障单机存储写 CommitLog 的程序性,直观的想法就是对写入动作加独占锁爱护,即同一时刻只容许一个线程加锁胜利,那么该选什么样的锁实现才适合呢?RocketMQ 目前实现了两种形式。1. 基于 AQS 的 ReentrantLock 2. 基于 CAS 的 SpinLock。
那么什么时候选取 spinlock,什么时候选取 reentranlock?回顾下两种锁的实现,对于 ReentrantLock,底层 AQS 抢不到锁的话会休眠,然而 SpinLock 会始终抢锁,造成显著的 CPU 占用。SpinLock 在 trylock 失败时,能够预期持有锁的线程会很快退出临界区,死循环的忙期待很可能要比过程挂起期待更高效。这也是为什么在高并发下为了放弃 CPU 安稳占用而采纳形式一,单次申请响应工夫短的场景下采纳形式二可能缩小 CPU 开销。两种实现实用锁内操作工夫不同的场景,那线程拿到锁之后须要进行哪些动作呢?
- 预计算索引的地位,即 ConsumeQueueOffset,这个值也须要保障严格递增
- 计算在 CommitLog 存储的地位,physicalOffset 物理偏移量,也就是全局文件的地位。
- 记录存储工夫戳 storeTimestamp,次要是为了保障音讯投递的工夫严格保序
因而不少文章也会倡议在同步长久化的时候采纳 ReentrantLock,异步长久化的时候采纳 SpinLock。那么这个中央还有没有优化的空间?目前能够思考应用较新的 futex 取代 spinlock 机制。futex 保护了一个内核层的期待队列和许多个 SpinLock 链表。
当取得锁时,尝试 cas 批改,如果胜利则取得锁,否则就将以后线程 uaddr hash 放入到期待队列 (wait queue),扩散对期待队列的竞争,减小单个队列的长度。这听起来是不是也有一点点 concurrentHashMap 和 LongAddr 的滋味,其实核心思想都是相似的,即扩散竞争。
- 成组提交与可见性
受限于磁盘 IO,块存储的响应通常十分慢。要求所有申请立刻长久化是不可能的,为了晋升性能,大部分的零碎总是将操作日志缓存到内存中,比方在满足 ” 日志缓冲区中数据量超过肯定大小 / 间隔上次刷入磁盘超过肯定工夫 ” 的任一条件时,通过后盾线程定期长久化操作日志。
但这种成组提交的做法有一个很大的问题,存储系统意外故障时,会失落最初一部分更新操作。例如数据库引擎总是要求先将操作日志刷入磁盘 (优先写入 redo log) 能力更新内存中的数据,这样断电重启则能够通过 undo log 进行事务回滚与抛弃。
在音讯零碎的实现上有一些奥妙的不同,不同场景下对音讯的可靠性要求不同,在金融云场景下可能要求主备都同步长久化实现音讯才对上游可见,但日志场景心愿尽可能低的提早,同时容许故障场景大量失落。此时能够将 RocketMQ 配置为单主异步长久化来进步性能,降低成本。此时宕机,存储层会损失最初一小段没保留的音讯,而上游的消费者实际上曾经收到了。当上游的消费者重置位点到一个更早的工夫,回放至同样位点的时候,只能读取到了新写入的音讯,但读取不到之前生产过的音讯(雷同位点的音讯不是同一条),这是一种 read uncommitted。
这样会有什么问题呢?对于一般音讯来说,因为这条音讯曾经被上游解决,最坏的影响是重置位点时无奈生产到。然而对于 Flink 这样的流计算框架,以 RocketMQ 作为 Source 的时候,通过回放最近一次 CheckPoint 到以后的数据的 offset 来实现高可用,不可反复读会造成计算零碎没法做到准确的 excatly once 生产,计算的后果也就不正确了。相应的解决的计划之一是在正本组多数派确认的时候才构建被消费者可见的索引,这么做宏观上的影响就是写入的提早减少了,这也能够从另一个角度解读为隔离级别的晋升带来的代价。
对于衡量提早和吞吐量这个问题,能够通过放慢主备复制速度,扭转复制的协定等伎俩来优化,这里大家能够看下 SIGMOD 2022 对于 Kafka 运行在 RDMA 网络上显著升高提早的论文《KafkaDirect: Zero-copy Data Access for Apache Kafka over RDMA Networks》
长久化机制
对于这一块的探讨在社区里探讨是最多的,不少文章都把长久化机制称为刷盘。我不喜爱这个词,因为它不精确。在 RocketMQ 中提供了三种形式来长久化,对应了三个不同的线程实现,理论应用中只会抉择一个。
- 同步长久化,应用 GroupCommitService。
- 异步长久化且未开启 TransientStorePool 缓存,应用 FlushRealTimeService。
- 异步长久化且开启 TransientStorePool 缓存,应用 CommitRealService。
- 长久化
同步刷盘的落盘线程对立都是 GroupCommitService。写入线程仅仅负责唤醒落盘线程,将音讯转交给存储线程,而不会期待音讯存储实现之后就立即返回了。我集体对这个设计的了解是,音讯写入线程绝对与存储线程来说也能够看作 IO 线程,而实在存储的线程须要攒批长久化会陷入中断,所以才要大费周章的做转交。
从同步刷盘的实现看,落盘线程每隔 10 ms 会查看一次,如果有数据未长久化,便将 page cache 中的数据刷入磁盘。此时操作系统 crash 或者断电,那未落盘的数据失落会不会对生产者有影响呢?此时生产者只有应用了牢靠发送 (指非 oneway 的 rpc 调用),这时对于发送者来说还没有收到胜利的响应,此时客户端会进行重试,将音讯写入其余可用的节点。
异步长久化对应的线程是 FlushRealTimeService,实现上又分为固定频率和非固定频率,外围区别是线程是否响应中断。所谓的固定频率是指每次有新的音讯到来的时候不论,不响应中断,每隔 500ms(可配置)flush 一次,如果发现未落盘数据有余(默认 16K),间接进入下一个循环,如果数据写入量很少,始终没有填充斥 16K,就不会落盘了吗?这里还有一个基于工夫的兜底计划,即线程发现间隔上次写入曾经很久了(默认 10 秒),也会执行一次 flush。
但事实上 FileChannel 还是 MappedByteBuffer 的 force() 办法都不能准确管制写入的数据量,这里的写行为也只是对内核的一种倡议。对于非固定频率实现,即每次有新的音讯到来的时候,都会发送唤醒信号,当唤醒动作在数据量较大时,存在性能损耗,但音讯量较少且状况下实时性好,更省资源。在生产中,具体抉择哪种长久化实现由具体的场景决定。是同步写还是多正本异步写来保证数据存储的可靠性,实质上是读写提早和和老本之间的衡量。
- 读写拆散
狭义上来说,读写拆散这个名词有两个不同的含意:
- 像数据库一样主写从读,摊派读压力,就义提早可靠性更高,实用于音讯读写比十分高的场景。
- 存储写入将音讯暂存至 DirectByteBuffer,当数据胜利写入后,再归还给缓冲池,写不应用 page cache。
这里次要来探讨第二点,当 Broker 配置异步长久化且开启缓冲池,启用的异步刷盘线程是 CommitRealTimeService。咱们晓得操作系统自身个别是当 page cache 上积攒了大量脏页后才会触发一次 flush 动作(由一些 vm 参数管制,比方 dirty_background_ratio 和 dirty_ratio)。
这里有一个很有意思的说法是 CPU 的 cache 是由硬件保护一致性,而 page cache 须要由软件来保护,也被称为 syncable。这种异步的写入可能会造成刷脏页时磁盘压力较高,导致写入时呈现毛刺景象。为了解决这个问题,呈现了读写拆散的实现。
RocketMQ 启动时会默认初始化 5 块(参数 transientStorePoolSize 决定)堆外内存(DirectByteBuffer)循环利用,因为复用堆外内存,这个小计划也被成为池化,池化的益处及弊病如下:
- 益处:数据写堆外后便很快返回,缩小了用户态与内核态的切换开销。
- 弊病:数据可靠性降为最低级别,过程重启就会丢数据(当然这里个别配合多正本机制进行保障)。读取须要 load page cache,也会减少一些端到端的提早。
- 宕机与故障复原
宕机个别是因为底层的硬件问题导致,RocketMQ 宕机后如果磁盘没有永恒故障,个别只须要原地重启,Broker 首先会进行存储状态的复原,加载 CommitLog,ConsumeQueue 到内存,实现 HA 协商,最初初始化 Netty Server 提供服务。目前的实现是最初初始化对用户可见的网络层服务,实际上这里也能够先初始化网络库,分批将 Topic 注册到 NameServer,这样失常降级时能够对用户的影响更小。
在 recover 的过程中还有很多软件工程实现上的细节,比方从块设施加载的时候须要校验音讯的 crc 看是否产生谬误,对最初一小段未确认的音讯进行 dispatch 等操作。默认从倒数第三个文件 recover CommitLog 加载音讯到 page cache (假如未长久化的数据 < 3G),避免一上线因为客户端申请的音讯不在内存,导致疯狂的缺页中断阻塞线程。分布式场景下还须要对存储的数据保护一致性,这也就波及到日志的截断,同步和回发等问题,后续我将在高可用篇再具体探讨这一点。
文件的生命周期
聊完了音讯的生产保留,再来探讨下音讯的生命周期,只有磁盘没有满,音讯能够长期保留。后面提到 RocketMQ 将音讯混合保留在 CommitLog,对于音讯和流这样近似 FIFO 的零碎来说,越近期的音讯价值越高,所以默认以滚动的模式从前向后删除最长远的音讯,而不会关注文件上的音讯是否全副被生产。触发文件革除操作的是一个定时工作,默认每 10s 执行一次。在一次定时工作触发时,可能会有多个物理文件超过过期工夫可被删除,因而删除一个文件岂但要判断这个文件是否还被应用,还须要距离肯定工夫(参数 deletePhysicFilesInterval)再删除另外一个文件,因为删除文件是一个十分消耗 IO 的操作,可能会引起存储抖动,导致新音讯写入和生产的提早。所以又新增了一个定时删除的能力,应用 deleteWhen 配置操作工夫(默认是凌晨 4 点)。
咱们把因为磁盘空间有余导致的删除称为被动行为,因为高速介质通常比拟贵(傲腾 ESSD 等),出于老本思考,咱们还会异步的被动的将热数据转移到二级介质上。在一些非凡的场景下,删除的同时可能还须要对磁盘做平安擦除来避免数据恢复。
防止存储抖动
- 疾速失败
音讯被服务端 Netty 的 IO 线程读取后就会进入到阻塞队列中排队,而单个 Broker 节点有时会因为 GC,IO 抖动等因素造成短时存储写失败。如果申请来不及解决,排队的申请就会越积越多导致 OOM,客户端视角看从发送到收到服务端响应的工夫大大缩短,最终发送超时。RocketMQ 为了缓解这种抖动问题,引入了疾速失败机制,即开启一个扫描线程,一直的去查看队列中的第一个排队节点,如果该节点的排队工夫曾经超过了 200ms,就会拿出这个申请,立刻向客户端返回失败,客户端会重试到其余正本组(客户端还有一些熔断与隔离机制),实现整体服务的高可用。
存储系统不止是被动的感知一些上层起因导致的失败,RocketMQ 还设计了很多简略无效的算法来进行被动估算。例如音讯写入时 RocketMQ 想要判断操作系统的 page cache 是否忙碌,然而 JVM 自身没有提供这样的 Monitor 工具来评估 page cache 忙碌水平,于是利用零碎的解决工夫来判断写入是否超过 1 秒,如果超时的话,让新申请会疾速失败。再比方客户端生产时会判断以后主的内存使用率比拟高,大于物理内存的 40% 时,就会倡议客户端从备机拉取音讯。
- 预调配与文件预热
为了在 CommitLog 写满之后疾速的切换物理文件,后盾应用一个后盾线程异步创立新的文件并进行对进行内存锁定,还大费周章的设计了一个额定文件预热开关(配置 warmMapedFileEnable),这么做次要有两个起因:
- 申请分配内存并进行 mlock 零碎调用后并不一定会为过程齐全锁定这些物理内存,此时的内存分页可能是写时复制的。此时须要向每个内存页中写入一些假的值,有些固态的主控可能会对数据压缩,所以这里不会写入 0。
- 调用 mmap 进行映射后,OS 只是建设虚拟内存地址至物理地址的映射表,而理论并没有加载任何文件至内存中。这里可能会有大量缺页中断。RocketMQ 在做 mmap 内存映射的同时进行 madvise 调用,同时向 OS 表明 WILLNEED 的志愿。使 OS 做一次内存映射后对应的文件数据尽可能多的预加载至内存中,从而达到内存预热的成果。
当然,这么做也是有弊病的。预热后,写文件的耗时缩短了很多,但预热自身就会带来一些写放大。整体来看,这么做能在肯定水平上进步响应工夫的稳定性,缩小毛刺景象,但在 IO 自身压力很高的状况下则不倡议开启。
RocketMQ 是实用于 Topic 数量较多的业务音讯场景。所以 RocketMQ 采纳了和 Kafka 不一样的零拷贝计划,Kafka 采纳的是阻塞式 IO 进行 sendfile,实用于系统日志音讯这种高吞吐量的大块文件。而 RocketMQ 抉择了 mmap + write 非阻塞式 IO (基于多路复用) 作为零拷贝形式,这是因为 RocketMQ 定位于业务级音讯这种小数据块 / 高频率的 IO 传输,当想要更低的提早的时候抉择 mmap 更适合。
当 kernal 把可用的内存调配后 free 的内存就不够了,如果过程一下产生大量的新调配需要或者缺页中断,还须要将通过淘汰算法进行内存回收,此时可能会产生抖动,写入会有短时的毛刺景象。
- 冷数据读取
对于 RocketMQ 来说,读取冷数据可能有两种状况。
- 申请来自于这个正本组的其余节点,进行正本组内的数据复制,也可能是离线转储到其余零碎。
- 申请来自于客户端,是消费者来生产几个小时以前的数据,属于失常的业务诉求。
对于第一种状况,在 RocketMQ 低版本源码中,对于须要大量复制 CommitLog 的状况(例如备磁盘故障,或新上线一个备机),主默认应用 DMA 拷贝的模式将数据间接通过网络复制给备机,此时因为大量的缺页中断阻塞了 io 线程,此时会影响 Netty 解决新的申请,在实现上让一些组件之间的外部通信应用 fastRemoting 提供的第二个端口,解决这个问题的长期计划还包含先用业务线程将数据 load 回内存而不应用零拷贝,但这个做法没有从实质上解决阻塞的问题。对于冷拷贝的状况,能够应用 madvice 倡议 os 读取防止影响主的音讯写入,也能够从其余备复制数据。
对于第二种状况,对各个存储产品来说都是一个挑战,客户端生产一条音讯时,热数据全副存储在 page cache,对于冷数据会进化为随机读(零碎会有一个对 page cache 间断读的预测机制)。须要生产超过几个小时之前的数据的场景下,消费者个别都是做数据分析或者离线工作,此时上游的指标都是吞吐量优先而非提早。对于 RocketMQ 来说有两个比拟好的解决方案,第一是同 redirect 的形式将读取申请转发给备进行摊派读压力,或者是从转储后的二级介质读取。在数据转储后,RocketMQ 自身的数据存储格局会发生变化,详见后文。
索引数据管理
在数据写入 CommitLog 后,在服务端当 MessageStore 向 CommitLog 写入一些音讯后,有一个后端的 ReputMessageService 服务 (dispatch 线程) 会异步的构建多种索引,满足不同模式的读取诉求。
队列维度的有序索引 ConsumeQueue
在 RocketMQ 的模型下,音讯自身存在的逻辑队列称为 MessageQueue,而对应的物理索引文件称为 ConsumeQueue。从某种意义上说 MessageQueue = 多个间断 ConsumeQueue 索引 + CommitLog 文件。
ConsumeQueue 绝对与 CommitLog 来说是一个更加轻量。dispatch 线程会源源不断的将音讯从 CommitLog 取出,再拿出音讯在 CommitLog 中的物理偏移量 (绝对于文件存储的 Index),音讯长度以及 Tag Hash 作为单条音讯的索引,散发到对应的生产队列。偏移 + 长度形成了对 CommitLog 的援用 (Ref)。这种 Ref 机制对于单条音讯只有 20B,显著升高了索引存储开销。ConsumeQueue 理论写入的实现与 CommitLog 不同,CommitLog 有很多存储策略能够抉择且混合存储,一个 ConsumeQueue 只会保留一个 Topic 的一个分区的索引,长久化默认应用 FileChannel,实际上这里应用 mmap 的话对小数据量的申请更加敌对,不必陷入中断。
客户端的 pull 申请到服务端执行了如下流程来查问音讯:
- 查问 ConsumeQueue 文件 -> 2. 依据 cq 拿到 physicOffset + size -> 3. 查问 CommitLog 取得音讯
RocketMQ 中默认指定每个生产队列的文件存储 30 万条索引,而一个索引占用 20 个字节,这样每个文件的大小是 300 1000 20 / 1024 / 1024 ≈ 5.72M。为什么生产队列文件存储音讯的个数要设置成 30 万呢?这个经验值适宜音讯量比拟大的场景,事实上这个值对于大部分场景来说是偏大的,无效数据的实在占用率很低,导致 ConsumeQueue 空载率高。
先来看看如果过大或者过小会带来什么问题。因为音讯总是有生效期的,例如 3 天生效,如果生产队列的文件设置过大的话,有可能一个文件中蕴含了过来一个月的音讯索引,但这个时候原始的数据曾经滚动没了,白白浪费了很多空间。但也不宜太小,导致 ConsumeQueue 也有大量小文件,升高读写性能。上面给出一个非谨严的空载率推导过程:
假如此时单机的 Topic = 5000,单节点单个 Topic 的队列数个别是 8,分区数量 = 4 万。以 1T 音讯数据为例,每条音讯大小是 4KB,索引数量 = 音讯数量 = 1024 1024 * 1024 / 4 = 2.68 亿。起码须要的 ConsumeQueue = 索引数量 / 30 万 = 895 个,理论使用率 (无效数据量) 约等于 2.4%。随着 ConsumeQueue Offset 的原子自增滚动,cq 头部是有效数据导致占用的磁盘空间会变大。依据私有云线上的状况来看,非 0 数据约占 5%,理论无效数据只占 1%。对于 ConsumeQueue 这样的索引文件,咱们能够应用 RocksDB 或者傲腾这样的长久化内存来存储,或者对 ConsumeQueue 独自实现一个用户态文件系统,几个计划都能够缩小整体索引文件大小,进步拜访性能。这一点在后文对于存储机制的优化中,咱们再详聊。
因为 CommitLog – ConsumerQueue – Offset 的关系从音讯写入的那一刻开始就确定了,在 Topic 跨正本组迁徙,正本组要下线等须要切流的场景下,如果须要音讯可读,须要采纳复制数据的计划来实现 Topic 跨正本组迁徙,只能采纳音讯级别的拷贝,而不能简略的把一个分区从正本组 A 挪动到正本组 B。有一些音讯产品在面对这个场景时,采纳了数据按分区复制的计划,这种计划可能会立即产生大量的数据传输(分区 rebalance),而 RocketMQ 的切流个别能够做到秒级失效。
音讯维度的随机索引 IndexFile
RocketMQ 作为业务音讯的首选,上文中 ReputMessageService 线程除了构建生产队列的索引外,还同时为每条音讯依据 id, key 构建了索引到 IndexFile。这是不便疾速疾速定位指标音讯而产生的,当然这个构建随机索引的能力是能够降级的,IndexFile 文件构造如下:
IndexFile 也是定长的,从单个文件的数据结构来说,这是实现了一种简略原生的哈希拉链机制。当一条新的音讯索引进来时,首先应用 hash 算法命中黄色局部 500w 个 slot 中的一个,如果存在抵触就应用拉链解决,将最新索引数据的 next 指向上一条索引地位。同时将音讯的索引数据 append 至文件尾部(绿色局部),这样便造成了一条以后 slot 依照工夫存入的倒序的链表。这里其实也是一种 LSM compaction 在音讯模型下的改良,升高了写放大。
存储机制的演进方向
RocketMQ 的存储设计是以简略牢靠队列模型作为外围来形象的,也因而产生了一些缺点和对应的优化计划。
KV 模型与 Queue 模型联合
RocketMQ 实现了单条业务音讯的退却重试,在生产实践中,咱们发现局部用户在客户端生产限流时间接将音讯返回失败,在重试音讯量比拟大的时候,因为原有实现下重试队列数无限,导致重试音讯无奈很好的负载平衡到所有客户端。同时,音讯来回的在服务端和客户端之间传输,使得两侧的开销都减少了,用户侧正确的做法应该是生产限流时,让生产的线程期待一会儿。
从存储服务的角度上来说,这其实是一种队列模型的有余,让一条队列只能被一个消费者持有。RocketMQ 提出了 pop 生产这种全新的概念,让单条队列的音讯可能被多个客户端生产到,这波及到服务端对单条音讯的加解锁,KV 模型就十分符合这个场景。从久远来看,像定时音讯事务音讯能够有一些基于 KV 的更原生的实现,这也是 RocketMQ 将来致力的方向之一。
音讯的压缩与归档存储
压缩就是用工夫去换空间的经典 trade-off,心愿以较小的 CPU 开销带来更少的磁盘占用或更少的网络 I/O 传输。目前 RocketMQ 客户端从提早思考仅单条大于 4K 的音讯进行单条压缩存储的。服务端对于收到的音讯没有立即进行压缩存储有多个起因,例如为了保证数据可能及时的写入磁盘,音讯稠密的时候攒批成果比拟差等,所以 Body 没有压缩存储。而对于大部分的业务 Topic 来说,其实 Body 个别都有很大水平上是类似的,能够压缩到原来的几分之一到几十分之一。
存储个别有高速(高频)介质与低速介质,热数据寄存在高频介质上(如傲腾,ESSD,SSD),冷数据寄存在低频介质上(NAS,OSS),以此来满足低成本保留更久的数据。从高频介质转到更低频的 NAS 或者 OSS 时,不可避免的产生了一次数据拷贝。咱们能够在这个过程中异步的对数据进行规整(闲时资源充裕)。
那么咱们为什么要做规整呢,间接零拷贝复制不香吗?
答案就是低频介质尽管便宜大碗,但通常 iops 和吞吐量更低。对于 RocketMQ 来说须要规整的数据就是索引和 CommitLog 中的音讯,也就是说在高频介质与低频介质上音讯的存储格局能够是齐全不同的。当热音讯降级到二级存储的时候,数据密集且异步,这里就是一个十分适合的机会进行压缩和规整。业界也有一些基于 FPGA 来减速存储压缩的案例,未来咱们也会继续的做这方面的尝试。
存储层资源共享与争抢
- 磁盘 IO 的抢占
没错,这里想谈谈的其实是硬盘的调度算法。在一个思考性价比的场景下,因为 RocketMQ 的存储机制,咱们能够把索引文件存储在 SSD,音讯自身放在 HDD 里,因为热音讯总是在 PageCache 中的,所以在 IO 调度上优先满足写而饿死读。对于没有沉积的消费者来说,生产到的数据是从 page cache 拷贝到 socket 再传输给用户,实时性曾经很高了。而对于生产冷数据(几个小时,几天以前的数据)用户的诉求个别是尽快获取到音讯即可,此时服务端能够抉择尽快满足用户的 Pull 申请,因为大量的随机 IO,这样磁盘会产生重大的 rt 抖动。
认真思考,这里其实用户想要的是尽可能大的吞吐量,假如拜访冷数据须要 200 毫秒,假如在服务端把冷读的行为滞后,再加上提早 500 毫秒再返回给用户数据,并没有显著的区别。而这里的 500 毫秒,服务端外部就能够合并大量的 IO 操作,咱们也能够应用 madvice 零碎调用去倡议内核读取。这里的合并带来的收益很高,能够显著的缩小对热数据的写入的影响,大幅度晋升性能。
- 用户态文件系统
还是为了解决随机读效率低的问题,咱们能够设计一个用户态文件系统,让 IO 调用全副 kernel-bypass。
次要有几个方向:
- 多点挂载。罕用的 Ext4 等文件系统不反对多点挂载,让存储可能反对多个实例的对同一份数据的共享拜访。
- 调整对于 IO 的合并策略,IO 优先级,polling 模式,队列深度等。
- 应用文件系统相似 O_DIRECT 的非缓存形式读写数据。
RocketMQ 的将来
RocketMQ 存储系统通过多年的倒退,基本功能个性曾经比较完善,通过一系列的翻新技术解决了分布式存储系统中的难题,稳固的服务于阿里团体和海量的云上用户。RocketMQ 在云原生时代的演进中遇到了更多的乏味的场景和挑战,这是一个须要全链路调优的简单工程。咱们会继续在规模,稳定性,多活容灾等企业级个性,老本与弹性等方面发力,将 RocketMQ 打造为“音讯,事件,流”一体化的交融平台。同时,咱们也会将开源口头更加可继续的倒退上来,为社会发明价值。
参考文献
[1]. 深刻了解 Linux 中的 page cache
https://www.jianshu.com/p/ae7…
[2]. PacificA: Replication in Log-Based Distributed Storage Systems.
https://www.microsoft.com/en-…
[3]. J. DeBrabant, A. Pavlo, S. Tu, M. Stonebraker, and S. B. Zdonik. Anti-caching: A new approach to database management system architecture. PVLDB, 6(14):1942–1953, 2013.
[4].《RocketMQ 技术底细》
[5]. 一致性协定中的“幽灵复现” https://zhuanlan.zhihu.com/p/…
[6]. Calder B, Wang J, Ogus A, et al. Windows Azure Storage: a highly available cloud storage service with strong consistency[C]//Proceedings of the Twenty-Third ACM Symposium on Operating Systems Principles. ACM, 2011: 143-157.
[7]. Chen Z, Cong G, Aref W G. STAR: A distributed stream warehouse system for spatial data[C] 2020: 2761-2764.
[8]. design data-intensive application《构建数据密集型利用》