存储层实现:I/O Pool + 零拷贝 + Partitioned Log
在设计 RobustMQ 消息队列的存储层时,我们面临一个核心挑战:如何在保证消息 offset 严格连续的前提下,实现高吞吐、低延迟、可控内存的并发写入?本文将分享 RobustMQ 存储层的设计思路,以及我们如何通过 I/O Pool + 零拷贝 + Partitioned Log 的架构来解决这个问题。
核心挑战:并发写入与顺序保证
消息队列有一个基本要求:同一个 Partition 内的消息 offset 必须严格连续。这在高并发场景下并不容易。
最直观的想法是用锁来控制。如果使用全局锁,所有 Partition 的写入都会串行化,这显然无法接受。改用分区锁会好一些,每个 Partition 一把锁,不同 Partition 之间可以并发。但这个方案有个致命问题:每个线程获取锁后,通常只写入一条消息就释放锁,这导致了大量的小数据写入。频繁的小 I/O 对磁盘极不友好,无论是 SSD 还是 HDD,批量写入的效率都远高于零散写入。而且锁的持有时间包含了 I/O 操作,在高并发时锁竞争会很严重。
我们需要一个既能保证顺序,又能实现批量写入的方案。
方案演进:从锁到 I/O Pool
分区锁方案的问题在于写入模式。每个线程独立获取锁、写一条消息、释放锁,这意味着磁盘上会有大量的小写入操作。即使在代码层面做了缓冲,由于锁的存在,很难在多个线程的请求间做聚合。每个线程只能看到自己的请求,无法和其他线程协调批量写入。
每个 Partition 一个 Actor 可以解决批量写入的问题。请求进入 channel 排队,Actor 可以一次取出多个请求,批量处理后一次性写入磁盘。但内存占用是个大问题:1000 个 Partition 就需要 1000 个 channel,如果每个 channel 容量是 10000,内存占用可能达到 1GB。
I/O Pool 方案 既能实现批量写入,又能控制内存。核心思想是用固定数量的 I/O Worker(比如 16 个)来管理所有 Partition。通过 partition_id % worker_count 的映射规则,同一个 Partition 的请求总是路由到同一个 Worker。
关键优势在于批量处理。Worker 不会每收到一个请求就立即处理,而是批量接收。比如先阻塞等待第一个请求,然后非阻塞地尽可能多收集后续请求(可能收集到几百上千个)。这些请求可能来自不同的 Partition,Worker 会按 partition_id 分组,然后批量处理。
对于同一个 Partition 的多个请求,Worker 会:顺序分配 offset,将所有记录加入该 Partition 的缓冲区,当缓冲区达到阈值(比如 100 条记录)时,一次性序列化并写入磁盘。这样一次 fsync 可以持久化几百上千条消息,而不是每条消息都 fsync 一次。
相比分区锁方案的"一次锁保护一次小写入",I/O Pool 实现了"一次批量写入包含多条消息"。这对磁盘性能的影响是数量级的差异。
零拷贝:降低不必要的开销
数据拷贝是另一个容易被忽视的性能杀手。传统实现中,一条消息从网络到达磁盘,可能要经历多次拷贝:网络接收、类型转换、跨 channel 传递、序列化、写入文件。每一步都可能触发内存拷贝。
我们使用 Rust 的 Bytes 类型来解决这个问题。Bytes 内部使用 Arc 引用计数,clone() 操作只是增加引用计数,不会拷贝实际数据。
整个数据流变成了:网络接收到 BytesMut,调用 freeze() 转换为 Bytes(零拷贝的类型转换),发送到 channel 时 clone()(只增加引用计数),Worker 接收后可以批量处理多个 Bytes,序列化和写入时直接访问原始数据。从头到尾,数据本身只有一份,只是在不同地方持有引用。
这不仅节省了 CPU,也降低了内存带宽压力,让系统可以把资源用在更有价值的地方。
I/O Pool 的并发度
Worker 数量是一个需要权衡的参数。Worker 本质上是异步任务,在 Tokio 运行时中被调度执行。当一个 Worker 在等待 I/O 完成时,Tokio 可以调度其他 Worker 继续工作。
对于 SSD 存储,I/O 延迟通常在 1ms 以内,Worker 数量设置为 CPU 核心数就够了。对于 HDD 存储,I/O 延迟可能达到 5-10ms,需要更多的 Worker 来提高并发度,通常设置为 2 倍 CPU 核心数。
批量写入不仅提高了吞吐量,也有助于控制延迟。虽然单条消息可能需要等待一小段时间才能和其他消息一起刷盘,但由于 fsync 次数大幅减少,整体的延迟反而更稳定。我们会设置一个定时器,确保即使没攒够一批也会定期刷盘,这样可以控制最大延迟。
Partitioned Log:文件级别的隔离
每个 Partition 对应一个独立的文件(实际上是一组文件,因为会按大小分 Segment)。这种设计带来了完全的隔离性,不同 Partition 的数据在物理上是分开的。多个 Worker 可以同时写不同的文件,没有文件锁竞争。
Worker 和 Partition 的映射关系是固定的。比如有 16 个 Worker 和 1000 个 Partition,那么 Worker 0 会管理 Partition 0, 16, 32, 48 等等。每个 Worker 在内存中维护多个 PartitionState,包含 next_offset、buffer 和 file handle。
对于海量 Partition 的场景,可以使用 LRU 缓存策略,只保留热门 Partition 在内存中,冷门的可以懒加载。这样即使有上万个 Partition,活跃的可能只有几百个,内存占用仍然可控。
架构总览
所以存储层的实现主要包括三个核心部分。
I/O Pool 负责保证顺序、控制内存,最关键的是实现批量写入。固定数量的 Worker 可以从 channel 中批量接收请求,按 Partition 分组后批量处理,一次 fsync 持久化多条消息。这避免了锁方案中的小 I/O 问题。
零拷贝技术降低了数据拷贝的开销。使用 Bytes 类型实现端到端零拷贝,从网络接收到磁盘写入,数据只有一份,通过引用计数在不同地方共享。
Partitioned Log 实现了负载分散和物理隔离。每个 Partition 独立文件,多个 Partition 可以并行写入,通过哈希实现负载均衡。
可扩展性和权衡
这个架构的扩展性主要体现在两个方面。Partition 的扩展几乎不受限制,因为内存占用主要取决于 Worker 数量。Worker 的扩展则需要根据磁盘类型调整,SSD 可以少配,HDD 需要多配。
在设计过程中我们做了一些权衡。为了实现批量写入,我们放弃了最简单的分区锁方案。为了内存可控,我们没有给每个 Partition 分配独立的 Actor。我们选择了固定映射而不是动态调度,牺牲了一些灵活性,但换来了确定性和简单性。
与业界方案的比较
Kafka 也采用了类似的思路,使用 Partitioned Log 和 I/O 线程池,通过批量处理来提高磁盘效率。Pulsar 更进一步,采用了存储计算分离的架构。EMQX 使用 Erlang 的 Actor 模型,天然适合分布式环境。
RobustMQ 利用了 Rust 的优势:没有 GC 的开销,可以安全地进行零拷贝,编译器保证并发安全。在性能和资源占用之间取得了较好的平衡。
总结
RobustMQ 存储层通过 I/O Pool、零拷贝和 Partitioned Log 三个关键设计,解决了高并发写入中的顺序性和效率问题。I/O Pool 的核心价值在于实现了批量写入,避免了锁方案中大量小 I/O 对磁盘的伤害。Worker 可以聚合多个请求,一次写入包含几百上千条消息,这对磁盘效率的提升是数量级的。
零拷贝技术降低了数据拷贝的开销。Partitioned Log 实现了文件级别的隔离和并行写入。这个架构在性能、内存和复杂度之间找到了一个合理的平衡点。
