作者:毕源泉
引言
消息队列的存储架构是决定其可靠性、吞吐量、延迟性能的核心因素,直接影响业务场景适配能力。本文聚焦三款主流消息队列 ------Kafka(LinkedIn 开源,侧重高吞吐)、RocketMQ(阿里开源,金融级特性突出)、JMQ(京东开源,侧重高可用与灵活性),从存储模型、数据组织、索引设计等维度展开深度对比,为技术选型与架构优化提供参考。
本文将从概念辨析出发,系统拆解主流存储模型与存储引擎的设计逻辑,对比 JMQ、Kafka、RocketMQ的技术选型差异与架构设计。
一、 Kafka存储架构
1.1 核心存储模型:分区日志流

Topic - 主题
Kafka学习了数据库里面的设计,在里面设计了topic(主题),这个东西类似于关系型数据库的表,此时我需要获取中国移动的数据,那就直接监听中国移动订阅的Topic即可。
Partition - 分区
Kafka还有一个概念叫Partition(分区),分区具体在服务器上面表现起初就是一个目录,一个主题下面有多个分区,这些分区会存储到不同的服务器上面,或者说,其实就是在不同的主机上建了不同的目录。这些分区主要的信息就存在了.log文件里面。跟数据库里面的分区差不多,是为了提高性能。
至于为什么提高了性能,很简单,多个分区多个线程,多个线程并行处理肯定会比单线程好得多。
Topic和partition像是HBASE里的table和region的概念,table只是一个逻辑上的概念,真正存储数据的是region,这些region会分布式地存储在各个服务器上面,对应于kafka,也是一样,Topic也是逻辑概念,而partition就是分布式存储单元。这个设计是保证了海量数据处理的基础。我们可以对比一下,如果HDFS没有block的设计,一个100T的文件也只能单独放在一个服务器上面,那就直接占满整个服务器了,引入block后,大文件可以分散存储在不同的服务器上。
注意:
1.分区会有单点故障问题,所以我们会为每个分区设置副本数
2.分区的编号是从0开始的

Kafka 以「主题(Topic)- 分区(Partition)」为核心组织数据,每个分区本质是一个 append-only 的日志流,消息按生产顺序追加存储,保证分区内消息有序性。
优点: 可以充分利用磁盘顺序读写高性能的特性。存储介质也可以选择廉价的SATA磁盘,这样可以获得更长的数据保留时间、更低的数据存储成本。
1.2 数据组织:分段日志文件
•每个分区拆分为多个 Segment 文件(默认 1GB),命名格式为「起始偏移量.log」(如 00000000000000000000.log),做这个限制目的是为了方便把.log加载到内存去操作
•配套两类索引文件:.index(偏移量→物理地址映射)、.timeindex(时间戳→偏移量映射)

这个9936472之类的数字,就是代表了这个日志段文件里包含的起始offset,也就说明这个分区里至少都写入了接近1000万条数据了。
Kafka broker有一个参数,log.segment.bytes,限定了每个日志段文件的大小,最大就是1GB,一个日志段文件满了,就自动开一个新的日志段文件来写入,避免单个文件过大,影响文件的读写性能,这个过程叫做log rolling,正在被写入的那个日志段文件,叫做active log segment。
1.3 消息读/写过程

写消息:
•Index文件写入,Index文件较小,可以直接用mmap进行内存映射,避免频繁的磁盘I/O操作,提高写入性能;由于Index文件是稀疏索引,只需要记录关键位置的偏移量,因此即使使用mmap,写入的开销也相对较低。
•Segment文件写入,Segment文件较大,可以采用普通的写操作(FileChannel.write),由于Segment文件是顺序写入的,并且Kafka会利用操作系统的PageCache(页缓存)机制,写入操作会先写入到内存中,然后由操作系统在后台异步刷新到磁盘,可以进一步提高写入的性能。
读消息:
•Index文件读取,通常使用mmap方式读取,由于Index文件较小,且是稀疏索引,缺页中断的可能性较小。
•Segment文件读取,通常使用sendfile系统调用来实现零拷贝读取和发送,减少数据在用户空间与内核空间之间的拷贝次数,提高数据传输的效率。
1.4 关键技术
Kafka 作为高性能的消息中间件,其超高吞吐量的核心秘诀之一就是深度依赖 PageCache + 顺序 I/O + mmap 内存映射的组合。
PageCache,中文名称为页高速缓冲存储器。它是将磁盘上的数据加载到内存中,当系统需要访问这些数据时,可以直接从内存中读取,而不必每次都去读取磁盘。这种方式显著减少了磁盘I/O操作,从而提高了系统性能。
mmap(Memory-mapped file)是操作系统提供的一种将磁盘文件 与进程虚拟地址空间 建立映射关系的核心技术,本质是让进程通过直接操作内存地址的方式读写文件,无需传统的 read/write 系统调用。核心价值在于零拷贝 和内存式文件访问,尤其适合大文件、高吞吐、随机访问的场景。
将日志段(.log)文件映射到内存,生产者写入时直接写内存(内核异步刷盘),消费者读取时直接从内存读取,实现超高吞吐(Kafka 的 "顺序写 + mmap" 是其高性能核心);

零拷贝流程示意图
零拷贝过程:
1.用户进程发起sendfile系统调用,上下文(切换1)从用户态转向内核态
2.DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
3.CPU将读缓冲区中数据拷贝到socket缓冲区
4.DMA控制器,异步把数据从socket缓冲区拷贝到网卡,
5.上下文(切换2)从内核态切换回用户态,sendfile调用返回。
1.5 设计优势
•顺序写磁盘:Segment 文件仅追加写入,规避随机 IO,吞吐量极高(单分区可达 10 万 + TPS)
•索引轻量化:仅维护偏移量与时间戳索引,降低存储开销
•副本同步:基于 ISR 机制,仅同步已提交消息,兼顾一致性与可用性
二、RocketMQ存储架构
Kafka的每个Partition都是一个完整的、顺序写入的文件,但当Partition数量增多时,从操作系统的角度看,这些写入操作会变得相对随机,这可能会影响写入性能。
2.1 核心存储模型:分离式设计
RocketMQ采用「CommitLog + ConsumeQueue + IndexFile」三层结构,彻底分离数据存储与索引查询:
•CommitLog:全局单一日志文件(默认 1GB / 个,循环覆盖),存储所有主题的原始消息
•ConsumeQueue:按主题 - 队列维度拆分的索引文件,存储「消息物理地址 + 偏移量 + 长度」,供消费者快速查询
•IndexFile:哈希索引文件,支持按消息 Key 查询
CommitLog:消息的原始日记本
CommitLog是RocketMQ存储消息的物理文件,所有消息都会按到达顺序写入这个文件。你可以把它想象成一本不断追加的日记本------每条消息都是按时间顺序记录的新日记。
scss
// 消息存储的核心逻辑简化示例(非源码)
public void putMessage(Message message) {
// 1. 将消息序列化为字节数组
byte[] data = serialize(message);
// 2. 计算消息物理偏移量
long offset = commitLog.getMaxOffset();
// 3. 将数据追加到CommitLog文件末尾
commitLog.append(data);
// 4. 返回消息的全局唯一物理偏移量
return offset;
}
消息写入CommitLog时有三个关键特性:
1.顺序写入:所有消息按到达顺序追加到文件末尾,避免磁盘随机寻址
2.内存映射:通过MappedByteBuffer实现文件映射,减少数据拷贝次数
3.文件分割:单个CommitLog文件默认1GB,写满后创建新文件(文件名用起始偏移量命名)
举个例子,当生产者发送三条消息时,CommitLog文件可能长这样:
ini
0000000000000000000(文件1,1GB)
2|--消息A(offset=0)
3|--消息B(offset=100)
4|--消息C(offset=200)
500000000001073741824(文件2,起始偏移量1073741824)
温馨提示:虽然CommitLog是顺序写,但读取时需要配合索引结构,否则遍历文件找消息就像大海捞针。
消费队列ConsumeQueue:消息的快速目录
如果每次消费都要扫描CommitLog,性能会惨不忍睹。于是RocketMQ设计了ConsumeQueue------它是基于Topic和Queue的二级索引文件。
每个ConsumeQueue条目包含三个关键信息(固定20字节):
java
1| CommitLog Offset (8字节) | Message Size (4字节) | Tag Hashcode (8字节) |
这相当于给CommitLog里的消息做了一个目录:
lua
TopicA-Queue0的ConsumeQueue
2|--0(对应CommitLog偏移0的消息A)
3|--100(对应CommitLog偏移100的消息B)
4|--200(对应CommitLog偏移200的消息C)
当消费者拉取TopicA-Queue0的消息时:
1.先查ConsumeQueue获取消息的物理位置
2.根据CommitLog Offset直接定位到CommitLog文件
3.读取指定位置的消息内容
关键设计点:
•ConsumeQueue采用内存映射+异步刷盘,保证高性能
•单个文件存储30万条索引,约5.72MB(30万*20字节)
•通过hashCode快速过滤Tag,实现消息过滤
索引文件IndexFile:消息的全局字典
如果需要根据MessageID或Key查询消息,ConsumeQueue就不够用了。这时候就要用到IndexFile这个全局索引。
IndexFile的结构类似HashMap:
1.Slot槽位(500万个):存储相同hash值的Index条目链表头
2.Index条目(2000万条):包含Key的hash值、CommitLog偏移量、时间差等信息
当写入消息时:
arduino
// 索引构建过程简化示意
public void buildIndex(Message message) {
// 计算Key的hash值
int hash = hash(message.getKey());
// 定位到对应的Slot槽位
int slotPos = hash % slotNum;
// 在Index区域追加新条目
indexFile.addEntry(hash, message.getCommitLogOffset());
}
查询时通过两次查找快速定位:
1.根据Key的hash值找到Slot槽位
2.遍历Slot对应的链表,比对CommitLog中的实际Key值
性能优化必知:
•消息体积差异大时,CommitLog仍然保持顺序写,但ConsumeQueue可能出现「稀疏索引」(相邻索引指向的物理位置间隔大)
•生产环境中CommitLog建议放在单独SSD磁盘,ConsumeQueue和IndexFile可放普通磁盘
•遇到消息堆积时,优先检查消费者速度,而不是无脑扩容Broker存储
理解这些底层机制,下次遇到消息查询性能问题或者磁盘IO瓶颈时,就知道该从CommitLog的写入模式还是ConsumeQueue的索引结构入手排查了。
2.2 数据流转机制
•生产者写入 CommitLog,生成全局唯一偏移量(PHYOFFSET)
•后台线程异步构建 ConsumeQueue 索引,同步消息元数据
•消费者通过 ConsumeQueue 定位 CommitLog 中的消息,避免全量扫描
存储过程全景图
现在把各个模块串起来看消息的生命周期:
1.生产者发送消息到Broker
2.Broker将消息顺序写入CommitLog
3.异步线程同时构建ConsumeQueue和IndexFile
4.消费者通过ConsumeQueue快速定位消息
5.按需查询IndexFile实现消息回溯
整个过程就像图书馆的管理系统:
•CommitLog是藏书库(按入库时间摆放)
•ConsumeQueue是分类目录(按题材/出版社分类)
•IndexFile是检索电脑(支持按书名/作者查询)
2.4 设计优势
•读写分离:CommitLog 仅负责写入,ConsumeQueue 负责查询,提升并发性能
•事务支持:通过 CommitLog 中的事务状态标记 + 回查机制,实现分布式事务消息
•刷盘策略:支持「异步刷盘(高吞吐)」「同步刷盘(金融级可靠性)」动态切换
三、JMQ存储架构
JMQ的消息存储分别参考了Kafka和RocketMQ存储设计上优点,并根据京东内部的应用场景进行了改进和创新。
3.1 核心存储模型:分区日志 + 队列兼容

JMQ存储的基本单元是PartitionGroup。在同一个Broker上,每个PartitionGroup对应一组消息文件(Journal Files),顺序存放这个Topic的消息。
与Kafka类似,每个Topic包含若干Partition,每个Partition对应一组索引文件(Index Files),索引中存放消息在消息文件中的位置和消息长度。消息写入时,收到的消息按照对应的PartitionGroup写入依次追加写入消息文件中,然后异步创建索引并写入对应Partition的索引文件中。
以PartionGroup为基本存储单元的设计,在兼顾灵活性的同时,具有较好的性能,并且单个PartitionGroup可以支持更多的并发。
3.2 消息读/写过程

写消息:
JMQ的写操作使用DirectBuffer作为缓存,数据先写入DirectBuffer,再异步通过FileChannel写入到文件中。
•消息写入DirectBuffer后,默认写入该节点成功(数据的高可靠是通过Raft协议复制,用多个内存副本来保证),相对Kafka的写操作来看,JMQ响应写入请求的处理过程没有发生系统调用,在京东内部的大量单条同步发送的场景下开销更低、性能更优。
•同时也避免使用MappedByteBuffer(Mmap方式)产生Page Fault中断,OS在中断中将该页对应磁盘中的数据拷贝到内存中,在对文件进行追加写入的情况下,这一无法避免的过程是完全没有必要,反而增加了写入的耗时的问题。
读消息:
JMQ采用定长稠密索引设计,每个索引固定长度。
•定长设计的好处是,直接根据索引序号就可以计算出索引在文件中的位置:索引位置 = 索引序号 * 索引长度。这样,消息的查找过程就比较简单了,首先计算出索引所在的位置,直接读取索引,然后根据索引中记录的消息位置读取消息。
•在京东内部应用场景中,单条消息处理耗时高是比较常见的,微服务架构下用户一般会申请更多的消费节点,让每个消费节点单次拉取较小批量的消息进行处理,以提升消费并行度,这样消费拉取请求的次数会比较多,稠密索引的设计会更适用内部的应用场景。
JMQ消费读操作99%以上都能命中缓存(JMQ设计的堆外内存与文件映射的一种缓存机制),避免了Kafka可能遇到的Cache被污染,影响性能和吞吐的问题。同时直接读内存也规避了RocketMQ在读取消息存储的日志数据文件时容易产生较多的随机访问读取磁盘,影响性能的问题。(当没有命中缓存时,会默认降级为通过Mmap的方式读取消息)。
四、竞品对比分析
| JMQ | Kafka | |
|---|---|---|
| 存储模型 | 以PartitionGroup为基本存储单元,支持高并发写入 | 以Partition为基本存储单元,支持灵活的数据复制和迁移 |
| 消息写入性能 | - 单副本异步写入性能与 Kafka 相当 - 三副本异步写入性能优于 Kafka | - 单副本异步写入性能与 JMQ 相当 - 三副本异步写入性能略低于 JMQ |
| 同步写入性能 | - 同步写入性能稳定,几乎不受网络延迟影响 | - 同步写入性能受网络延迟影响较大,稳定性略逊于 JMQ |
| 多分区性能 | - 多分区异步写入性能与 Kafka 相当 - 同步写入性能略低于 Kafka | - 多分区同步写入性能更稳定,适合高并发场景 |
| 副本机制 | 支持异步复制,副本间数据同步性能较好 | 支持异步和同步复制,副本机制成熟,适合复杂部署 |
| 跨机房部署 | - 同步写入性能基本不受影响 - 异步写入性能下降 | - 同步写入性能受网络延迟影响较大 - 异步写入性能下降 |
| 适用场景 | - 对同步写入性能要求高 - 副本异步吞吐要求高 - 大规模微服务集群 | - 复杂分区的高并发同步写入 - 大规模分布式系统 - 多语言生态支持丰富 |
在单副本场景下,JMQ与Kafka的单机写入性能均十分出色,均可达到网络带宽上限。
然而,在更贴近生产环境的三副本场景中,两者特性出现分化:
JMQ在三副本异步写入下的极限吞吐优势明显,且在跨机房部署时,其同步写入性能表现良好,几乎不受网络延迟影响;而Kafka则在多分区同步写入场景下展现出更稳定的性能,衰减小于JMQ。在大部分异步吞吐场景及不同消息体下的性能趋势上,两者表现相当。
综上所述,JMQ尤其适合对同步写入性能和副本异步吞吐有极高要求的场景,而Kafka在复杂分区的高并发同步写入方面适应性更广。