CommitLog顺序写 —— 为什么RoceketMQ所有消息都往一个文件追加?

前言:高性能是怎么做到的?

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 + 小数据量 + 文件预分配的特殊情况:

  1. SSD随机写性能接近顺序写(差距仅1.2-2倍)
  2. 97MB完全在PageCache中,未触发真实磁盘IO
  3. 随机写提前预分配文件,避免了元数据更新开销

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

这个数据没有参考价值,因为:

  1. 没有刷盘 - 宕机必丢数据
  2. 数据太小 - 完全在内存中
  3. 没有网络开销
  4. 没有索引构建

加上刷盘 + 网络 + 索引后,真实性能:

复制代码
异步刷盘(先返回成功,后台定时刷盘):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负责索引(异步构建)。

消费者读取时:

  1. 先读ConsumeQueue,找到消息在CommitLog的物理偏移量
  2. 再去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的设计选择(吞吐量优先):

  1. 更激进的批量策略

    • 默认批量大小:16KB
    • 延迟换吞吐量
    • 适合:日志收集、流式处理
  2. sendfile零拷贝

    • 从PageCache直接发送给网卡
    • 不经过用户空间
    • 消费场景性能极佳
  3. 稀疏索引

    • 索引颗粒度:4KB一个索引点
    • 索引构建开销小
    • 但查询粒度粗
  4. 简化的存储模型

    • 每个Partition一个文件
    • 顺序消费优化到极致
    • 但按Key查询需要扫描

RocketMQ的设计选择(功能丰富 + 低延迟):

  1. 细粒度索引

    • ConsumeQueue:20字节一个索引
    • IndexFile:支持按Key查询
    • Tag过滤在Broker端完成
  2. 丰富的功能

    • 事务消息(Half Message + 回查)
    • 延时消息(18个Level)
    • 消息轨迹、消息过滤
  3. 更低的延迟

    • 批量大小:4KB(可配置)
    • P99延迟:1-3ms
    • Kafka:5-10ms(批量导致)
  4. 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(多线程+批量+参数优化+硬件)

要达到生产级性能,需要:

  1. 多线程模型(网络、业务、刷盘、索引分离)
  2. 批量处理(批量发送、批量编码、批量刷盘)
  3. 内核参数优化(TCP、虚拟内存、IO调度器)
  4. JVM优化(G1GC、直接内存)
  5. RocketMQ配置优化(异步刷盘、文件预热、堆外内存池)
  6. 硬件配置(NVMe SSD、大内存、万兆网卡)

设计权衡:

  • HDD:顺序写比随机写快50-800倍
  • SSD:顺序写比随机写快5-30倍(多文件场景)
  • 牺牲读取的直接性(用ConsumeQueue索引解决)
  • 牺牲空间效率(用定期清理解决)
  • 牺牲单点扩展(用Broker集群解决)
  • 换来写入的极致性能

通用思想:

  • WAL模式是高性能持久化的经典方案
  • 顺序IO是存储系统的黄金法则
  • 读写分离让各自性能达到极致
  • 性能不是免费的,需要系统性优化

顺序写的威力,不是快一点,而是快了几个数量级。但要达到这个威力,需要的不只是顺序写,而是整套优化组合拳。

这就是CommitLog设计的精髓。它不仅是RocketMQ的存储基石,更是一种可以广泛应用的设计思想。

下一篇,我们会深入ConsumeQueue的异步构建机制,看看RocketMQ如何在保证写入性能的同时,让消费者高效读取消息。


参考资料:

相关推荐
武子康2 小时前
大数据-200 决策树信息增益详解:信息熵、ID3 选特征与 Python 最佳切分实现
大数据·后端·机器学习
嘻哈baby2 小时前
MySQL远程连接配置与安全实战
后端
小码编匠3 小时前
工业视觉 C# + OpenCvSharp 的模板匹配实战
后端·c#·.net
To Be Clean Coder3 小时前
【Spring源码】getBean源码实战(二)
java·后端·spring
程序员爱钓鱼3 小时前
Node.js 编程实战:RESTful API 设计
前端·后端·node.js
程序员爱钓鱼3 小时前
Node.js 编程实战:GraphQL 简介与实战
前端·后端·node.js
千寻girling4 小时前
面试官 : “ 说一下 Map 和 WeakMap 的区别 ? ”
前端·javascript·面试
降临-max4 小时前
JavaWeb企业级开发---MySQL
java·开发语言·数据库·笔记·后端·mysql
C雨后彩虹4 小时前
二维伞的雨滴效应
java·数据结构·算法·华为·面试