RobustMQ 存储层设计:核心特性
RobustMQ 作为新一代消息中间件,采用了存算分离的现代架构。本文将深入探讨我们在存储层设计中的核心决策,包括如何保证消息顺序、选择一致性协议,以及实现灵活的分层存储策略。
一、核心挑战:高并发下的顺序保证
消息队列的存储层有一个基本要求:同一个 Partition 内的消息 offset 必须严格连续。在高并发写入场景下,如何既保证顺序,又实现高性能?
传统方案会使用锁来控制 offset 分配。分区锁看起来不错,每个 Partition 一把锁,不同 Partition 可以并发。但问题在于:每个线程获取锁后通常只写入一条消息就释放,导致大量的小 I/O 操作。频繁的小写入对磁盘极不友好,而且锁的持有时间包含了慢速的磁盘操作,高并发时锁竞争会很严重。
二、I/O Pool:批量处理的关键
我们的解决方案是 I/O Pool 架构。核心思想是用固定数量的 I/O Worker(比如 16 个)来管理所有 Partition。通过 partition_id % worker_count 的固定映射规则,保证同一个 Partition 的所有请求总是路由到同一个 Worker。
批量处理是关键优势。Worker 不会每收到一个请求就立即处理,而是批量接收。先阻塞等待第一个请求,然后非阻塞地收集后续请求(可能收集几百上千个)。这些请求按 partition_id 分组后批量处理。对于同一个 Partition 的多个请求,Worker 顺序分配 offset,将所有记录加入缓冲区,当缓冲区达到阈值(比如 100 条)时,一次性序列化并写入磁盘。这样一次 fsync 可以持久化几百上千条消息,而不是每条消息都 fsync 一次。相比锁方案的"一次锁保护一次小写入",I/O Pool 实现了"一次批量写入包含多条消息",对磁盘性能的影响是数量级的差异。
内存管理也很优雅。内存占用只跟 Worker 数量有关,而不是 Partition 数量。16 个 Worker 意味着 16MB 的 channel 内存,即使管理 10000 个 Partition,总内存也只需要约 100MB。Worker 数量通常设置为 CPU 核心数,对于 SSD 存储来说这个配置已经能充分利用 I/O 能力。如果是 HDD,可以设置为 2 倍 CPU 核心数来提高 I/O 并发度。
三、零拷贝:降低 CPU 开销
数据从网络到达磁盘,传统实现可能要经历多次拷贝。我们使用 Rust 的 Bytes 类型解决这个问题。Bytes 内部使用 Arc 引用计数,clone() 操作只是增加引用计数,不会拷贝实际数据。
整个数据流变成了:网络接收到 BytesMut,调用 freeze() 转换为 Bytes(零拷贝的类型转换),发送到 channel 时 clone()(只增加引用计数),Worker 接收后批量处理,写入时直接访问原始数据。从头到尾,数据本身只有一份,只是在不同地方持有引用。这不仅节省了 CPU,也降低了内存带宽压力。
四、索引设计:稀疏索引 + 同步构建
RobustMQ 使用 RocksDB 存储索引,包括 offset 索引、时间索引、key 索引和 tag 索引。我们选择在写入时同步构建索引,而不是异步构建。
批量写入是性能的关键。Worker 批量处理 1000 条记录时,会同时构建这 1000 条记录的所有索引,然后通过 RocksDB 的 WriteBatch 一次性写入。数据文件写入需要一次 I/O,索引写入也需要一次 I/O,总共只有两次 I/O。批量写入下,索引构建的开销很小(约增加 1ms),但换来的是索引立即可用,数据和索引强一致,崩溃恢复简单。
对于 offset 索引,我们采用稀疏索引策略。不是每条记录都建索引,而是每 1000 条记录一个索引点。查询时先通过 RocksDB 定位到最近的索引点,获得文件位置,然后从该位置顺序扫描最多 1000 条记录即可找到目标数据。稀疏索引占用极小(1000 万条记录只需 240KB),但查询速度仍然很快,整体延迟在 2ms 左右。
五、一致性选择:ISR vs Quorum
RobustMQ 采用存算分离架构,存储层按 Shard 组织,每个 Shard 包含多个 Segment,每个 Segment 分布在不同的 Storage Node。我们对比了 ISR 和 Quorum 两种一致性协议,最终选择了 ISR。
ISR 是 Kafka 的核心设计。每个 Segment 有一个 Leader,维护一个 ISR 列表(同步副本集合)。写入成功意味着数据复制到 ISR 中的多数派。Follower 通过 Pull 模式主动从 Leader 拉取数据,这在大 QPS 小消息场景下性能更高,因为 Follower 可以批量拉取,网络请求次数从百万级降到百级。
核心优势在于数据完整性。Storage Leader 保证有全量数据,ISR Follower 也有全量数据。读取时从 Leader 或 ISR Follower 读取,100% 能读到数据,不会有空洞。实现简单可靠,无需复杂的后台修复机制。
Quorum 方式虽然无单点,但有个关键问题:任何副本都可能不完整。写入时 Broker 并发写 3 个 Storage Node,等待 2 个确认就返回成功,这意味着可能某个副本缺数据。比如写入 1000 条消息,可能 Storage A 缺了 100 条,B 缺了 120 条,C 缺了 80 条。虽然每条消息都满足 Quorum,但没有一个副本是完整的。读取时如果恰好读到缺失的副本,就会失败,需要重试其他副本。更严重的是顺序消费场景,消费者可能因为遇到"空洞"而误以为消费完了,实际漏掉了后续数据。Quorum 依赖复杂的后台修复逻辑,工程复杂度高,初期很难做好。如果修复跟不上写入速度,问题会持续累积。
考虑到工程实现的复杂度和风险,我们选择了 ISR。虽然 Storage 层有 Leader,但通过合理的 Segment 分布,Leader 分散在不同 Storage Node,不会形成单点瓶颈。而且 Broker 层仍然是无状态的,存算分离的核心优势依然保留。
我们也提供了灵活的 acks 配置。用户可以选择 acks=all 等待所有 ISR 副本确认(强一致),acks=quorum 等待多数派(平衡性能和可靠性),或者 acks=1 只等 Leader 确认(最快但可能丢数据)。不同业务场景可以按需选择一致性级别。
六、Active vs Sealed Segment
我们在 ISR 基础上做了一个重要优化:区分 Active Segment 和 Sealed Segment。Active Segment 是正在写入的活跃段,有 Leader 和 ISR 机制,Follower 持续 Pull 复制。当 Segment 写满(比如达到 1GB)或达到时间阈值时,会被封存为 Sealed Segment。Sealed Segment 停止写入,此时 Leader 会等待所有 ISR Follower 完全追上,并验证所有副本的一致性。只有在所有副本都完整且一致后,才会标记为 Sealed,然后释放 Leader 角色。之后这个 Segment 就没有 Leader 了,所有副本地位平等,可以从任意副本读取。
这个设计带来了巨大优势。Leader 数量等于 Shard 数量,而不是 Segment 总数。假设 1000 个 Shard,每个 Shard 有 100 个 Segment(99 个封存,1 个活跃),传统方案需要 10 万个 Leader,而我们只需要 1000 个 Leader。Leader 管理开销(选举、ISR 维护、心跳)降低 100 倍。而且 Sealed Segment 的读取可以负载均衡到所有副本,99% 的历史数据读取压力分散到所有 Storage Node,充分利用存储资源。
七、Segment 分段
分段 Segment 设计还解决了 Kafka 的另一个痛点:扩容时的数据迁移。Kafka 扩容时需要重新分配 Partition,这涉及在 Broker 之间拷贝大量数据,不仅耗时长、占用带宽,还可能影响线上业务。
RobustMQ 的扩容过程完全不同。当新增 Storage Node 时,系统不需要迁移任何历史数据,只需要等待当前 Active Segment 写满。新创建的 Segment 会自动分配到新节点上,开始承担流量。由于高流量场景下 1GB 的 Segment 可能在几分钟到几十分钟内就会写满,新节点很快就能分担负载。整个过程对业务完全透明,无性能损失。
即使业务特别紧急需要立即分散负载,也可以手动触发封存操作,强制将当前 Active Segment 标记为 Sealed。新创建的 Segment 会立即分配到新节点,瞬间实现负载转移。但一般情况下这个手动操作并不需要,因为大流量场景下 Segment 会快速写满,系统会自动完成切换。
历史数据保留在原节点上,作为 Sealed Segment 继续提供读取服务。如果需要释放老节点的空间,可以通过分层存储将 Sealed Segment 迁移到 S3,这个迁移是后台异步进行的,不影响业务。缩容时也是类似的逻辑,停止在要下线的节点上创建新 Segment,等待其上的 Active Segment 自然写满并封存,然后将 Sealed Segment 迁移走或者保留在 S3,节点即可下线。
这种设计相比 Kafka 的数据迁移方式,有几个明显优势:扩容立即生效(新 Segment 马上创建),无历史数据迁移(避免大量数据拷贝),对业务无影响(读写不中断),操作简单(自动化完成)。
八、分层存储
Sealed Segment 的不可变特性,让分层存储的实现变得非常简单。热数据层的 Active Segment 存储在本地 SSD,使用 3 副本和 ISR 机制,提供最低延迟和最高吞吐。温数据层的 Recent Sealed Segment 仍在本地存储(SSD 或 HDD),任意副本可读,延迟稍高但仍然很快。冷数据层的 Old Sealed Segment 迁移到 S3,延迟虽然增加到 50ms,但成本极低且容量无限。
迁移过程非常简单。由于 Sealed Segment 不可变且所有副本完整,可以从任意副本读取完整文件,直接上传到 S3,更新元数据即可。不需要复杂的协调机制,失败了重试即可。通过分层存储,本地只需保留近期热数据(比如 7 天),历史数据全部在 S3。对于每天写入 1TB、保留 1 年的场景,存储成本可以从 21 万美元降低到 1 万美元,节省 95%。
九、灵活的副本策略
我们提供了多种存储策略,让用户按需选择。多副本加本地存储是默认配置,使用 3 副本和 ISR 机制,提供高性能和低延迟,适合核心业务数据。单副本直接写 S3 可以降低本地存储成本,避免集群内网络复制开销,适合日志和归档类数据,依赖 S3 的 11 个 9 可靠性保证。单副本或多副本加内存存储可以提供极低延迟(100µs 级别),适合实时推送、行情数据等可接受数据丢失的高频场景。内存加异步持久化则兼顾了性能和可靠性,适合会话状态、临时计算等场景。
不同场景选择不同策略,在性能、成本、可靠性之间灵活权衡。这让 RobustMQ 可以服务更广泛的应用场景,从金融交易到日志收集,从实时推送到数据归档。
十、架构总览
RobustMQ 存储层采用三层架构。计算层的 Broker 完全无状态,只负责路由和协调,可以随意扩缩容。存储层的 Storage Node 管理 Segment,Active Segment 有 Leader 使用 ISR 机制,Sealed Segment 无 Leader 任意副本可读。协调层使用 Meta Service 管理元数据和 Leader 选举。
分层存储自动化管理数据生命周期。热数据保留在本地 SSD 使用多副本,温数据可能在本地 HDD,冷数据迁移到 S3,归档数据进入 S3 Glacier。用户可以按 Topic 或 Shard 配置副本数和存储策略,灵活选择存储介质(内存、SSD、HDD、S3),系统自动处理分层迁移。
Kafka 采用存算一体架构,ISR 机制成熟,性能极高,但扩容需要数据迁移,这个过程可能耗时几小时甚至几天,并且会影响线上性能。分层存储是后期加入的功能。Pulsar 采用存算分离,使用 BookKeeper 的 Quorum 机制,弹性伸缩能力强,但架构复杂,需要后台修复机制来保证副本完整性,运维成本较高。RocketMQ 主要是存算一体,一致性方案经历了从主从复制到 DLedger(Raft)再到 Controller 模式的演进,现在才接近最优方案。
RobustMQ 从设计之初就采用存算分离,但选择了 ISR 而不是 Quorum,兼顾了灵活性和性能。通过 Active 和 Sealed Segment 的分离优化了 Leader 数量,通过分段设计实现了无迁移扩容,原生支持分层存储,并提供多种副本策略让用户灵活配置。我们的目标是在借鉴业界成熟方案的基础上,做出最适合实际场景的选择。
十一、总结
在设计存储层时,我们始终坚持一个原则:简单可靠的设计比复杂的完美设计更有价值。选择 ISR 而不是 Quorum,是因为我们看重数据完整性的简单保证,不想让系统依赖复杂的后台修复逻辑。选择同步构建索引,是因为批量写入下开销很小,但换来的是立即可用和强一致性。选择 Active 和 Sealed 的分离,是因为这个简单的优化就能大幅降低管理成本。选择分段 Segment,是因为它自然解决了数据迁移的难题。
我们相信,在存储层这个基础组件上,应该选择经过验证的成熟方案,并在此基础上做针对性优化,而不是追求理论上的完美但工程上充满风险的方案。RobustMQ 的存储层设计充分借鉴了 Kafka、Pulsar、RocketMQ 的经验和教训,力求在高性能、高可靠、低成本之间找到最佳平衡点。
