前言:高性能是怎么做到的?
RocketMQ是阿里巴巴开源的消息中间件,在阿里内部承载着电商、支付、物流等核心业务的海量消息流量。
根据官方文档和社区实践,单机性能大致在几千到几万TPS ,优化后可达十万级别。即使是普通配置的几千TPS,对于大多数业务场景也已经足够。
那么问题来了:这个性能是怎么做到的?
答案在源码中:支撑这个性能的核心,是一个看起来很简单的设计决策:
所有Topic的消息,都写到同一个文件里,顺序追加。
这个设计叫做CommitLog。它是RocketMQ存储层的基石,也是理解整个RocketMQ架构的起点。
今天我们就来拆解这个设计:它是什么?为什么要这样设计?性能到底有多好?更重要的是,要达到这个性能需要什么条件?
一、CommitLog是什么?
1.1 核心设计
CommitLog的设计很简单:一个文件,顺序追加,所有Topic的消息共用。
类比笔记本:不管是订单、日志还是通知消息,都直接在笔记本最后一页往下写,不分类。
ini
CommitLog文件:
[订单消息-001] ← Topic: order
[日志消息-001] ← Topic: log
[订单消息-002] ← Topic: order
[通知消息-001] ← Topic: notify
...
1.2 消息存储格式
每条消息包含13个字段,定长字段在前(48字节),变长字段在后:
scss
定长部分(48字节):
- TOTALSIZE(4) + MAGICCODE(4) + BODYCRC(4) + QUEUEID(4)
- QUEUEOFFSET(8) + PHYSICALOFFSET(8)
- BORNTIMESTAMP(8) + STORETIMESTAMP(8)
变长部分:
- Topic + Body + Properties
定长在前的设计让解析很快:先读48字节,就知道后面变长部分有多长。
1.3 文件管理
CommitLog不是单个无限大的文件,而是多个1GB文件组成的队列:
ini
$ROCKETMQ_HOME/store/commitlog/
├── 00000000000000000000 (第1个,offset=0)
├── 00000001073741824 (第2个,offset=1GB)
├── 00000002147483648 (第3个,offset=2GB)
文件名即起始偏移量。当前文件写满1GB后,自动创建新文件继续追加。
1.4 核心写入逻辑
java
public AppendResult putMessage(Message msg) {
putMessageLock.lock(); // 1. 加锁
try {
MappedFile mappedFile = getLastMappedFile(); // 2. 获取当前文件
if (mappedFile.isFull()) {
mappedFile = createNewFile(); // 3. 文件满了就创建新文件
}
return mappedFile.appendMessage(msg); // 4. 追加消息
} finally {
putMessageLock.unlock();
}
}
整个流程:加锁 → 找文件 → 追加 → 解锁。没有复杂的索引更新,没有B树维护,就是往文件末尾写数据。
二、为什么要这样设计?
2.1 为什么不是每个Topic一个文件?
按常规思路,应该每个Topic一个文件:
c
订单Topic → order.log
日志Topic → log.log
通知Topic → notify.log
这样读写都方便。但RocketMQ没这么做,原因是性能会崩掉。
2.2 多文件方案的问题
问题不在应用层并发度,而在磁盘物理特性。
HDD的物理限制:
即使应用层用1000个线程写1000个文件,磁盘层仍然串行:
scss
应用层:1000线程并发写
↓
磁盘层:磁头串行跳跃
→ file-1 (寻道10ms)
→ file-2 (寻道10ms)
→ file-3 (寻道10ms)
1000个Topic场景,性能对比:
diff
多文件(HDD):
- 磁头跳跃1000次
- 每次寻道10-15ms
- 性能:100-1000 msg/s
单文件(HDD):
- 磁头固定,连续写
- 充分利用带宽:100-150 MB/s
- 性能:50,000-80,000 msg/s
差距:50-800倍
SSD也有问题:
虽然SSD没有磁头,但多文件仍有开销:
markdown
1. 文件元数据开销(inode、时间戳)
2. PageCache碎片化(1000个缓冲区,命中率低)
3. SSD内部GC和写放大
性能对比:
多文件(SSD):10,000-50,000 msg/s
单文件(SSD):100,000-300,000 msg/s
差距:5-30倍
2.3 顺序写 vs 随机写的本质
为什么差距这么大?核心原因是磁盘的物理特性。
机械硬盘(HDD)的痛点:
磁盘是一个圆盘,数据存储在盘片的不同位置。要写入数据,磁头需要移动到对应位置。
bash
顺序写:
磁头固定在一个位置,数据连续写入
→ 无需移动磁头
→ 充分利用盘片旋转速度
→ 吞吐量:100-200 MB/s
随机写:
磁头需要不断移动到不同位置
→ 每次移动耗时10-15ms(寻道时间,即磁头移动到目标位置的时间)
→ 每秒最多移动100次
→ 吞吐量:5-10 MB/s
固态硬盘(SSD)的差异:
SSD没有物理移动部件,随机写性能好很多,但顺序写仍然有优势:
bash
顺序写:300-500 MB/s
随机写:200-300 MB/s
差距:1.5-2倍
虽然差距没有HDD那么夸张,但在多Topic场景下,差距会被放大。
2.4 PageCache的威力
还有一个关键因素:PageCache(页缓存)。
操作系统会把磁盘文件缓存在内存中。当你写入文件时,实际上是写入PageCache,然后操作系统异步刷到磁盘。
顺序写的PageCache优势:
yaml
CommitLog(单文件):
所有消息连续写入 → PageCache连续 → 命中率高
多文件:
1000个文件分散写入 → PageCache分散 → 命中率低
PageCache的命中率,直接影响读写性能。顺序写让数据在内存中连续,预读机制更有效,缓存淘汰更少。
这就是为什么RocketMQ要用单文件顺序写。
三、性能验证:手写测试对比
理论说再多,不如实测来得直接。
我参考RocketMQ的设计,手写了一个简化版的CommitLog,用mmap(内存映射文件,避免用户态/内核态数据拷贝)实现顺序写。然后写了个对比测试,看看顺序写和随机写到底差多少。
3.1 测试场景
测试环境:
- 操作系统:macOS 14.3.1
- 磁盘:SSD
- 测试数据:10万条消息,每条1KB,共97MB
顺序写实现:
java
// 使用mmap,顺序追加
MappedByteBuffer buffer = fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
for (Message msg : messages) {
buffer.put(encodeMessage(msg)); // 顺序追加
}
随机写实现:
java
// 预分配文件,随机位置写入
RandomAccessFile file = new RandomAccessFile("random.log", "rw");
file.setLength(fileSize); // 预分配空间
for (Message msg : messages) {
long randomPos = random.nextLong(fileSize);
file.seek(randomPos);
file.write(encodeMessage(msg)); // 随机写入
}
3.2 测试结果
测试环境:SSD,数据量97MB(10万条×1KB)
| 指标 | 顺序写 | 随机写 |
|---|---|---|
| 总耗时 | 360 ms | 267 ms |
| 吞吐量 | 271 MB/s | 365 MB/s |
为什么随机写更快?
这是SSD + 小数据量 + 文件预分配的特殊情况:
- SSD随机写性能接近顺序写(差距仅1.2-2倍)
- 97MB完全在PageCache中,未触发真实磁盘IO
- 随机写提前预分配文件,避免了元数据更新开销
HDD环境的理论对比:
bash
顺序写:100-150 MB/s,性能 100,000-150,000 msg/s
随机写:每次寻道10-15ms,性能 5,000-10,000 msg/s
差距:10-30倍
结论:SSD上随机写不慢,但顺序写仍有优势。真正差距体现在多Topic、大数据量、定期刷盘(将内存中的数据强制写入磁盘)的场景。
3.3 生产场景对比
多Topic场景(HDD):
1000个Topic,每个Topic一个文件:
bash
多文件:磁头跳跃,性能 100-1000 msg/s
单文件:顺序写入,性能 50,000-80,000 msg/s
差距:50-800倍
定期刷盘场景(SSD):
每秒刷盘一次:
bash
顺序写:数据连续,一次性刷盘,性能 100,000-200,000 msg/s
随机写:数据分散,多次刷盘,性能 10,000-20,000 msg/s
差距:5-10倍
3.4 不刷盘的"假性能"
SimpleCommitLog测试:
bash
写入:10,000条
耗时:20 ms
TPS:500,000 msg/s
这个数据没有参考价值,因为:
- 没有刷盘 - 宕机必丢数据
- 数据太小 - 完全在内存中
- 没有网络开销
- 没有索引构建
加上刷盘 + 网络 + 索引后,真实性能:
异步刷盘(先返回成功,后台定时刷盘):20,000-40,000 TPS
同步刷盘(等待刷盘完成才返回):5,000-10,000 TPS
性能降低:10-100倍
看性能数据必须问清楚测试条件,不刷盘的数据没有意义。
四、设计权衡:单文件带来的挑战
顺序写性能这么好,是不是就没有缺点了?
当然不是。任何设计都是权衡。
4.1 读取问题:如何快速找到某个Topic的消息?
所有Topic的消息混在一起,消费者怎么读取?
难道要从头到尾扫描CommitLog,找出自己Topic的消息吗?那性能就炸了。
RocketMQ的解决方案:ConsumeQueue索引
ini
CommitLog(物理存储):
[订单-001][日志-001][订单-002][通知-001][日志-002]...
ConsumeQueue(逻辑索引):
订单Queue: [offset-0][offset-2]...
日志Queue: [offset-1][offset-4]...
通知Queue: [offset-3]...
CommitLog只负责写入(顺序追加),ConsumeQueue负责索引(异步构建)。
消费者读取时:
- 先读ConsumeQueue,找到消息在CommitLog的物理偏移量
- 再去CommitLog读取实际消息
这就是读写分离:写入走CommitLog(顺序写),读取走ConsumeQueue(索引查找)。
4.2 空间问题:所有消息都存储,占用大
每条消息都存在CommitLog中,不管有没有被消费,都要占用磁盘空间。
RocketMQ的解决方案:定期清理
java
// 默认保留72小时
fileReservedTime = 72
// 磁盘使用率超过75%,强制删除
diskMaxUsedSpaceRatio = 75
过期的CommitLog文件会被删除,释放空间。
4.3 单点瓶颈:顺序写只能单点
CommitLog是单点顺序写,无法并行。这是性能的天花板。
RocketMQ的解决方案:Broker分片
makefile
Broker1: 处理 Topic A, B, C
Broker2: 处理 Topic D, E, F
Broker3: 处理 Topic G, H, I
通过多个Broker横向扩展,每个Broker维护自己的CommitLog。
4.4 权衡总结
| 维度 | 单文件(CommitLog) | 多文件(每Topic一个) |
|---|---|---|
| 写入性能 | 极高(顺序写) | 极低(随机写) |
| 读取性能 | 需要索引 | 直接读取 |
| 空间占用 | 所有消息都存储 | 可按Topic清理 |
| 扩展性 | 单点写入 | 天然分布式 |
| 复杂度 | 简单 | 管理复杂 |
RocketMQ选择了写性能优先的策略,用ConsumeQueue索引解决读取问题,用定期清理解决空间问题,用Broker集群解决扩展问题。
这就是设计的艺术:抓住核心矛盾(写入性能),用其他方式弥补代价(读取、空间、扩展)。
五、性能不是免费的:要达到5万TPS需要什么?
单纯的顺序写性能一般,最简单的实现:
java
// 最简单的顺序写:每条都刷盘
FileChannel channel = new RandomAccessFile("test.log", "rw").getChannel();
for (Message msg : messages) {
channel.write(ByteBuffer.wrap(msg.getBytes()));
channel.force(false); // 每条都刷盘
}
性能:500-1000 TPS
差得远。
要达到RocketMQ的5万-20万TPS,需要一整套优化组合拳。
5.1 多线程模型
RocketMQ采用多线程架构:
网络线程(Netty):8-16个,处理网络IO
业务线程:8-16个,处理消息编码
刷盘线程:1个后台线程,异步批量刷盘
索引线程:1个后台线程,构建ConsumeQueue
只有写入CommitLog时需要加锁,持有锁时间仅几微秒。其他环节都是并发处理。
5.2 批量处理
批量处理是性能的关键:
java
// 生产者批量发送
producer.send(List<Message> msgs); // 100条/批
// Broker批量写入
for (Message msg : batch) {
commitLog.putMessage(msg);
}
// 定时批量刷盘
flushIntervalCommitLog = 500 // 500ms刷一次
性能对比:
单条处理:163 TPS
批量处理(100条/批):8,333 TPS
批量处理(1000条/批):8,695 TPS
多线程并发(8线程):69,560 TPS
5.3 内核参数优化
操作系统参数调优同样重要。以下参数来自社区实践,需根据实际环境调整:
bash
# 文件描述符
ulimit -n 1000000
# TCP参数
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.core.somaxconn = 65535
# 虚拟内存(影响刷盘行为)
vm.dirty_background_ratio = 5 # 推荐5-10
vm.dirty_ratio = 10 # 推荐10-20
vm.dirty_writeback_centisecs = 100
vm.dirty_expire_centisecs = 500
vm.max_map_count = 655360
# IO调度器
echo none > /sys/block/nvme0n1/queue/scheduler # SSD
echo mq-deadline > /sys/block/sda/queue/scheduler # HDD
# 文件系统
mount -o noatime,nodiratime /dev/nvme0n1 /data/rocketmq
优化效果(根据社区反馈):
优化前:10,000-15,000 TPS
优化后:30,000-50,000 TPS
提升:2-5倍
5.4 JVM参数优化
bash
-Xms8g -Xmx8g # 堆内存8G
-XX:+UseG1GC # 使用G1GC
-XX:MaxGCPauseMillis=50 # 最大GC停顿50ms
-XX:MaxDirectMemorySize=15g # 直接内存15G(mmap需要)
-XX:-UseBiasedLocking # 关闭偏向锁
15G直接内存用于:CommitLog (10GB) + ConsumeQueue (2GB) + IndexFile (1GB) + 余量 (2GB)
5.5 RocketMQ配置优化
properties
# 刷盘策略
flushDiskType = ASYNC_FLUSH
flushIntervalCommitLog = 500
flushCommitLogLeastPages = 4
# 文件预分配和预热
warmMapedFileEnable = true
# 堆外内存池
transientStorePoolEnable = true
transientStorePoolSize = 5
# 锁类型(false=自旋锁,低延迟)
useReentrantLockWhenPutMessage = false
# 线程池
sendMessageThreadPoolNums = 16
5.6 硬件配置
推荐配置:
bash
CPU:16核+,主频3GHz+
内存:32GB+(64GB推荐)
磁盘:NVMe SSD(3000-7000 MB/s)
网络:万兆网卡(10Gbps)
不同配置的性能:
| 配置 | TPS |
|---|---|
| HDD + 8核16G | 5,000 |
| SSD + 16核32G | 50,000 |
| NVMe + 32核64G | 200,000 |
5.7 性能提升路径
从简单到完整优化的路径:
最简单顺序写(每条刷盘):500-1000 TPS
加上mmap(不刷盘):50,000-100,000 TPS(不实用)
加上批量刷盘:10,000-20,000 TPS
加上多线程:30,000-50,000 TPS
加上批量处理:50,000-80,000 TPS
完整优化:100,000-200,000 TPS
顺序写只是基础,要达到生产级性能需要完整的优化组合。
六、对比:Kafka为什么能到单机100万TPS?
有人说Kafka单机能到100万TPS,为什么RocketMQ只有20万?
首先明确:Kafka单机100万TPS的测试条件
- 异步刷盘(与RocketMQ相同)
- 批量大小:16KB(RocketMQ默认4KB)
- 高配服务器:32核64G + NVMe SSD
- 单Topic或少量Topic(重要!)
- Producer端压缩
- 消息大小:1KB
这是一个极限优化场景下的数据,不是常规性能。
Kafka和RocketMQ的设计差异
两者都是优秀的消息中间件,但设计目标不同:
Kafka的设计选择(吞吐量优先):
-
更激进的批量策略
- 默认批量大小:16KB
- 延迟换吞吐量
- 适合:日志收集、流式处理
-
sendfile零拷贝
- 从PageCache直接发送给网卡
- 不经过用户空间
- 消费场景性能极佳
-
稀疏索引
- 索引颗粒度:4KB一个索引点
- 索引构建开销小
- 但查询粒度粗
-
简化的存储模型
- 每个Partition一个文件
- 顺序消费优化到极致
- 但按Key查询需要扫描
RocketMQ的设计选择(功能丰富 + 低延迟):
-
细粒度索引
- ConsumeQueue:20字节一个索引
- IndexFile:支持按Key查询
- Tag过滤在Broker端完成
-
丰富的功能
- 事务消息(Half Message + 回查)
- 延时消息(18个Level)
- 消息轨迹、消息过滤
-
更低的延迟
- 批量大小:4KB(可配置)
- P99延迟:1-3ms
- Kafka:5-10ms(批量导致)
-
Topic无关的性能
- 所有Topic共用CommitLog
- Topic数量不影响写入性能
- Kafka:Topic多会影响性能
性能对比(在各自擅长的场景)
| 场景 | Kafka | RocketMQ |
|---|---|---|
| 日志收集(大批量) | 100万+ TPS | 20-30万 TPS |
| 业务消息(小批量) | 10-20万 TPS | 10-30万 TPS |
| 多Topic(1000+) | 5-10万 TPS | 20-30万 TPS |
| 按Key查询消息 | 需要扫描 | 支持(IndexFile) |
| 事务消息 | 不支持 | 支持 |
结论:不是谁好谁坏,而是设计目标不同
Kafka适合:
- 日志收集、埋点上报
- 流式处理(大数据场景)
- 顺序消费为主
- 对延迟不敏感
RocketMQ适合:
- 业务消息(订单、支付、通知)
- 需要事务消息、延时消息
- 需要按Key查询、Tag过滤
- 对延迟敏感
两者都是优秀的设计,只是优化的方向不同。
七、升华:WAL思想的通用性
CommitLog的设计,本质上是**WAL(Write-Ahead Log)**模式的实现。
7.1 WAL的核心原则
markdown
1. 先写日志(CommitLog)
↓
2. 后建索引(ConsumeQueue)
↓
3. 读写分离(写走日志,读走索引)
关键点:
- 顺序IO是王道 - 充分利用磁盘特性
- 先保证持久化,再优化查询 - 写入与查询解耦
- 日志与索引分离 - 各司其职
7.2 其他系统的WAL实现
这个思想在很多系统中都有体现:
MySQL的Redo Log:
markdown
写入流程:
1. 事务修改数据
2. 先写Redo Log(顺序追加,保证持久性)
3. 后写Undo Log(用于回滚,保证原子性)
4. 最后刷Data Page(随机写)
崩溃恢复:
从Redo Log重放 → 恢复已提交事务
从Undo Log回滚 → 撤销未提交事务
核心:Redo Log的顺序写保证了事务持久性
Kafka的Partition Log:
markdown
存储结构:
1. 每个Partition包括:Log Segment + Index文件
2. Log Segment:消息本身(顺序追加)
3. Index文件:稀疏索引(offset → 文件位置)
写入:
顺序追加到当前Segment
读取:
先查Index找到Segment位置 → 再读Log
索引特点:
每4KB消息建一个索引点(稀疏)
与RocketMQ的ConsumeQueue类似,但粒度更粗
Redis的AOF:
markdown
持久化:
1. 每个写操作追加到AOF文件
2. 顺序写入
3. 定期重写压缩
恢复:
重放AOF文件 → 恢复内存数据
HBase的WAL:
markdown
写入流程:
1. 先写WAL(HLog)
2. 再写MemStore
3. 最后刷到HFile
崩溃恢复:
从WAL重放 → 恢复MemStore
7.3 你的系统也可以用
如果你在设计一个需要高吞吐量持久化的系统,WAL思想可能适合你:
任务调度系统(JobFlow):
markdown
任务执行记录:
1. 任务执行日志(顺序写)
2. 任务状态索引(异步构建)
3. 查询走索引,不扫描日志
审计日志系统:
markdown
操作日志:
1. 所有操作追加到日志文件
2. 按时间、用户、操作类型建索引
3. 查询走索引
事件溯源(Event Sourcing):
markdown
事件存储:
1. 所有事件顺序追加
2. 聚合根状态异步计算
3. 查询走状态快照
7.4 适用场景
WAL模式适合:
- 写多读少的场景
- 对写入性能要求高
- 需要保证数据不丢
- 可以容忍读取稍慢(通过索引优化)
不适合:
- 需要频繁随机读取
- 磁盘空间紧张
- 实时查询要求高
总结
RocketMQ的CommitLog设计看起来简单,实则精妙。
设计核心:
- 所有Topic共用一个CommitLog
- 顺序追加写入
- mmap零拷贝 + PageCache
性能真相:
- 单纯顺序写:500-1000 TPS(每条都刷盘)
- 加上mmap:50,000-100,000 TPS(不刷盘,不实用)
- 完整优化:100,000-200,000 TPS(多线程+批量+参数优化+硬件)
要达到生产级性能,需要:
- 多线程模型(网络、业务、刷盘、索引分离)
- 批量处理(批量发送、批量编码、批量刷盘)
- 内核参数优化(TCP、虚拟内存、IO调度器)
- JVM优化(G1GC、直接内存)
- RocketMQ配置优化(异步刷盘、文件预热、堆外内存池)
- 硬件配置(NVMe SSD、大内存、万兆网卡)
设计权衡:
- HDD:顺序写比随机写快50-800倍
- SSD:顺序写比随机写快5-30倍(多文件场景)
- 牺牲读取的直接性(用ConsumeQueue索引解决)
- 牺牲空间效率(用定期清理解决)
- 牺牲单点扩展(用Broker集群解决)
- 换来写入的极致性能
通用思想:
- WAL模式是高性能持久化的经典方案
- 顺序IO是存储系统的黄金法则
- 读写分离让各自性能达到极致
- 性能不是免费的,需要系统性优化
顺序写的威力,不是快一点,而是快了几个数量级。但要达到这个威力,需要的不只是顺序写,而是整套优化组合拳。
这就是CommitLog设计的精髓。它不仅是RocketMQ的存储基石,更是一种可以广泛应用的设计思想。
下一篇,我们会深入ConsumeQueue的异步构建机制,看看RocketMQ如何在保证写入性能的同时,让消费者高效读取消息。
参考资料:
- RocketMQ 官方文档
- RocketMQ Design
- 《RocketMQ技术内幕》第三章 - 消息存储