深度解读 RocketMQ 存储机制

作者:斜阳

RocketMQ 实现了灵活的多分区和多副本机制,有效的避免了集群内单点故障对于整体服务可用性的影响。存储机制和高可用策略是 RocketMQ 稳定性的核心,社区上关于 RocketMQ 目前存储实现的分析与讨论一直是一个热议的话题。近期我一直在负责 RocketMQ 消息多副本和高可用能力的建设,和大家分享下一些有趣的想法。

本文想从一个不一样的视角,着重于谈谈我眼中的这种存储实现是在解决哪些复杂的问题,因此我从本文最初的版本中删去了冗杂的代码细节分析,由浅入深的分析存储机制的缺陷与优化方向。

RocketMQ 的架构模型与存储分类

先来简单介绍下 RocketMQ 的架构模型。RocketMQ 是一个典型的发布订阅系统,通过 Broker 节点中转和持久化数据,解耦上下游。Broker 是真实存储数据的节点,由多个水平部署但不一定完全对等的副本组构成,单个副本组的不同节点的数据会达到最终一致。对于单个副本组来说同一时间最多只会有一个可读写的 Master 和若干个只读的 Slave,主故障时需要选举来进行单点故障的容错,此时这个副本组是可读不可写的。

NameServer 是独立的一个无状态组件,接受 Broker 的元数据注册并动态维护着一些映射关系,同时为客户端提供服务发现的能力。在这个模型中,我们使用不同主题 (Topic) 来区分不同类别信息流,为消费者设置订阅组 (Group) 进行更好的管理与负载均衡。

如下图中间部分所示:

  1. 服务端 Broker Master1 和 Slave1 构成其中的一个副本组。
  2. 服务端 Broker 1 和 Broker 2 两个副本组以负载均衡的形式共同为客户端提供读写。

在这里插入图片描述

RocketMQ 目前的存储实现可以分为几个部分:

  1. 元数据管理

    1. 具体指当前存储节点的主题 Topic,订阅组 Group,消费进度 ConsumerOffset。
    2. 多个配置文件 Config,以及为了故障恢复的存储 Checkpoint 和 FileLock。
    3. 用来记录副本主备身份的 Epoch / SN (sequence number) 文件等(5.0-beta 引入,也可以看作 term)
  2. 消息数据管理,包括消息存储的文件 CommitLog,文件版定时消息的 TimerLog。

  3. 索引数据管理,包括按队列的顺序索引 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 就全量向磁盘覆盖写这个大文件,这个操作的开销非常高,尤其是在数据需要跨集群,跨节点迁移,或者应急情况下扩容逃生场景下,同步写文件严重延长了外围管控命令的响应时间,也成为云上大共享模式下严峻的挑战之一。在这个背景下,两个解决方案很自然的就产生了,即批量更新接口和增量更新机制。

  1. 批量更新指每次服务端可以接受一批 TopicConfig 的更新,这样 Broker 刷写文件的频率就显著的降低。
  2. 增量更新指将这个 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),就可以在逻辑上实现一个无需考虑长度的

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值