0.引言
在回顾rocketmq的消息存储架构时突然想到一个问题,印象里commitlog的写入是需要加锁防止数据错乱的,而rocketmq将所有的topic存放在一个commitlog中,也就是说他的commitlog的写入并发会很小,这样没问题吗?于是我决定带着这个问题再次学习一下rocketmq的消息存储机制。
1.对话ds-r1
问:rocketmq这样的设计是否有问题?
答:ds分别从锁粒度和锁优化,顺序写入,异步并行三个角度给我说明这样的设计没有问题。我总结了一下:
- 锁粒度,其持有锁的时间只有:1计算消息offset,2将消息追加至mappedfile,3更新消息存储状态。锁优化,rocketmq通过自旋等操作优化,我也在rocketmq官网找到一篇abs锁的实践,后续会学习。
- 顺序写入,单commitlog的设计让全局消息都能做到顺序写入,也就是顺序写入pro max。同时还提供page cache的优化,通过mmap和异步刷盘机制。
- 异步并行,这个点和我的问题关系不大,这个就是mq做的一些基本优化。
问:对比kafka,rocketmq这样的设计是不是不太好?
ds给我列了个表格,同时告诉我为啥rocketmq能容忍单线程写入
| 维度 | Kafka | RocketMQ |
|---|---|---|
| 写入模型 | 多 Partition 并行写入(每个 Partition 独立文件)。 | 全局 CommitLog 顺序写入,逻辑队列异步构建。 |
| 锁竞争 | 每个 Partition 独立锁,并行度高。 | 全局锁,但锁粒度极细,竞争影响小。 |
| 磁盘 I/O | 随机写(依赖多个 Partition 文件)。 | 纯顺序写(最大化磁盘吞吐)。 |
| 适用场景 | 高并发写入(需分区规划)。 | 超高吞吐、低延迟、顺序写优化场景。 |
- 生产者可以将消息合批发送,降低网络IO开销
- 通过mmap直接操作内存,避免jvm堆外内存复制
- 顺序写充分发挥SSD/HDD带宽能力,单线程也能打满磁盘IO
第三点我没啥概念,顺序写有这么牛逼?而且相关的概念好像在redis那边也看过,不过人家确实是纯内存操作,你这个mmap内存映射也可以套用吗?
2.rocketmq的消息存储流程
过一遍rocketmq的消息存储流程,查漏补缺一下。
在此之前先记录一下我不确定的几个点:
- 加锁位置?应该是在mappedbytebuffer去slice到当前offset前加锁
- 通过mmap内存映射,可以直接等同于纯内存操作吗?mmap的force是否有开销?
- 写入的时机,是不是有什么buffer缓存,先累积一批,再去做mmap的追加写操作?
参考源码版本,rocketmq-release-5.1.1
2.1.rocketmq存储结构设计
producer向broker发送消息时,会以顺序写的方式写入commitlog文件,默认每个commitlog文件的大小为1gb,如果文件写满则新建,commitlog的命名方式为其start-offset,记得在哪看过,这样命名对读消息时查找对应文件很方便。
commitlog存储的消息数据格式如下:
msgsize + magic num + crc32 checksum + queueId + tag + queue offset
commitlog offset + sys tag + timestamp + host + store_timestamp + store_host
retry_times + tx_info + msg_len + msg_body + topic_size + topic_info + prop size + prop body
consumequeue按照topic进行分组,以queueId命名文件夹,其中存放的就是consumequeue数组。consume部分很简单,不是本文重点,就不细说了。
2.2.流程
producer发送消息到broker的全流程,用ds生成的图,检查后发现没啥问题,后面用文字补充细节。
%% Producer -> Broker -> CommitLog -> ConsumeQueue 流程图
flowchart TD
subgraph Producer
A[Producer] -->|1. 发送消息| B[Broker]
end
subgraph Broker处理流程
B --> C[接收请求]
C --> D[解析消息]
D --> E{是否合法?}
E -->|合法| F[获取 putMessageLock]
E -->|非法| G[返回错误]
F --> H[写入 CommitLog 内存映射缓冲区]
H --> I[释放 putMessageLock]
I --> J[返回写入结果给 Producer]
end
subgraph 存储层
H --> K[CommitLog 文件]
K -->|异步刷盘| L[磁盘持久化]
K -->|异步构建| M[ConsumeQueue]
M -->|按 Topic/Queue 分片| N[ConsumeQueue 文件]
K -->|异步构建索引| O[IndexFile]
end
subgraph 高可用
L -->|主从复制| P[Slave Broker]
end
style A fill:#f9f,stroke:#333
style B fill:#f96,stroke:#333
style K fill:#69f,stroke:#333
style M fill:#6f9,stroke:#333
数据封装与校验
消息到broker时,broker创建一个MessageExtBrokerInner对象封装消息数据,包括topic信息,queueid,body,prop等等信息设置到inner里,判断下是否开启了事务。
开始校验,看下broker role,看下commitlog满没满,看下pagecache是否繁忙,检查消息的长度,检查crc32的checksum
消息写入
给前面的inner对象设置store_timestamp,计算crc值放入变量,设置store_host
对消息长度做一次校验,判断超没超过最大容量,根据消息长度申请一块内存buffer,写入数据。
找到对应的commitlog文件,判断下要不要init或者创建新的commitlog文件,这一步就要开始加锁了,避免多个线程创建commitlog文件。
准备写入commitlog,rocketmq有两种方式写
- 若开启暂存池,刷盘设置为async,brokerrole为master,就会使用暂存池去写入数据,暂存池原理是:类似线程池,池中有提前申请好的内存,写完后入池,等待系统一并将积攒的buffer池刷入磁盘。这个得到一个普通的writebuffer
- 若没有开启暂存池,使用同步映射的方式,则通过mappedbytebuffer映射到对应的commitlog文件,通过slice方式创建共享内存,用于write数据。这个得到一个mmap的writebuffer。
此前消息已经写入到缓冲区中,将缓冲区内容写入到writebuffer里,返回消息结果。此时进行解锁。
然后执行刷盘策略,有同步刷盘和异步刷盘两种方式,异步是由mq起一个定时任务执行刷盘。同步就是直接force,异步如果开启了暂存池,有两个阶段。
- commit阶段,将bytebuffer的数据写入commitlog的filechannel中
- flush阶段,将filechannel的force方法。
关键流程:
SendMessageProcessor#sendMessage
DefaultMessageStore#asyncPutMessage调用CommitLog#asyncPutMessage在这里面加了锁
伪代码-ds生成
# 异步写入消息的核心方法(伪代码)
def async_put_message(msg):
# === 1. 消息预处理 ===
# 自动升级消息版本(处理长主题)
# 标记IPv6地址
# === 2. 高可用检查 ===
need_ack_num = 1 # 默认需要1个副本确认
if 需要处理HA(消息):
# === 3. 并发控制 ===
topic_queue_key = 生成主题队列键(msg)
try:
获取主题队列锁(topic_queue_key) # 保证同一队列顺序写入
# 分配消息偏移量(非从节点且未启用去重时)
if 需要分配偏移量:
分配消息偏移(msg)
# === 4. 消息编码 ===
编码结果 = 线程本地编码器.编码(msg)
if 编码失败:
return 编码错误结果
# === 5. 文件写入 ===
try:
获取写入锁() # 自旋锁或可重入锁
开始时间 = 当前时间()
# 获取或创建内存映射文件
mapped_file = 获取最后一个内存映射文件()
if mapped_file is None or mapped_file已满:
mapped_file = 创建新内存映射文件()
# 追加消息到文件
追加结果 = mapped_file.追加消息(msg, 回调函数)
# 处理不同追加结果
if 追加结果 == PUT_OK:
更新提交日志元数据(msg, 追加结果)
elif 追加结果 == END_OF_FILE: # 文件已满
创建新文件后重试追加()
else: # 处理非法消息等错误
return 对应错误结果
# 记录锁内耗时
耗时 = 当前时间() - 开始时间
if 耗时 > 500ms:
记录警告日志()
finally:
释放写入锁()
# === 6. 更新统计信息 ===
统计服务.记录写入次数(msg.主题, 消息数量)
统计服务.记录写入字节(msg.主题, 写入字节数)
finally:
释放主题队列锁()
# === 7. 后处理 ===
if 需要解锁已满文件:
解锁内存映射文件()
# 处理磁盘刷盘和HA复制(异步)
最终结果 = 处理磁盘刷盘及HA同步(追加结果, msg, need_ack_num)
return 包装为CompletableFuture(最终结果)
涉及到一个consumequeue锁,一个commitlog锁
topicqueuekey为topic-queueid,确保当前队列只有一个线程能够执行写入。putMessageLock则是从DefaultMessageStore中获取来的,与commitlogfile绑定。至此可以看到,lock的过程是与预期一样的。
持有锁时主要做了以下操作
- 获取上一次写入的mappedfile
- 更新存储时间戳
- 看下mappedfile是否空or满,进行对应操作
- append消息(buffer形式)
2.3.分析
消息的内存流转状态变化:
netty的bytebuf→堆内存对象messageextbrokerinner→encode之后变成直接内存bytebuffer
然后被mappedbytebuffer做append操作
数据从网卡先复制到堆内存,再从堆内存到直接内存,然后追加到mmap映射的内存后等待刷盘。
netty那边有自己的优化,到堆中之后,也全都在用户态就完成了复制,因此速度也很快。同时,写入的数据利用到了pagecache机制,并非直接写磁盘,这一部分属于mmap的优化。刷盘的时候利用dma,无需cpu参与数据搬运,同时还是顺序写入。
联想到一个面试题,为啥rocketmq快,可以这样回答:
- netty自带的优化
- bytebuffer→mappedbytebuffer,无需用户态切换至内核态
- mmap零拷贝优化
- dma+顺序写的优化
- 暂存池机制在批量写场景也有很大帮助
还有一些极端场景的优化
- mmapfile缺页,需要加锁重新load,但是rocketmq的mmapfile很大,发生缺页次数不会太频繁
- 刷盘策略,rocketmq也提供灵活刷盘机制
- 锁竞争,rocketmq尽量缩小了锁的粒度
用ds生成的预测延时,供参考
ByteBuffer 拷贝到 MappedByteBuffer | 100~500 ns | 纯内存操作,取决于数据大小 |
|---|---|---|
| 缺页中断 | 1~10 μs | 与文件预分配和硬件相关 |
| 异步刷盘 | 0 ns(异步) | 无感知延迟,刷盘由后台线程完成 |
| 同步刷盘 | 1~10 ms | 依赖磁盘 IOPS(如 NVMe SSD 可达 0.1ms) |
总之每一步都做到了机制的优化,整体的性能确实是不差的,但是能不能做到更好呢?
3.如果使用分topic方式存储,会怎么样?
3.1方案
采用分topic存储,类似consumequeue,每个topic目录下存放commitlog文件,进行自增。
3.2理论分析
- 锁争抢
原流程获取putmessage lock时,不同topic会发生争抢锁操作,同一topic有重入锁
改造流程获取putmessage lock时,不同topic不会发生争抢操作,理论上来说吞吐量大大提高。
- 顺序写
原流程可以保证全局的顺序写,改造流程单topic视角下可以顺序写,全局从磁盘角度来看是随机写
- 文件本身
原流程单commitlog,分配大空间,缺页情况很少发生,改造后commitlog单文件肯定不能分配这么大,要往百倍往上打折,缺页情况发生的会很频繁。
- 主从复制
原来只需要复制单一commitlog流,改造后比较复杂,不过可以采用主节点主动推送的方式去同步,但是主从在核对时比较麻烦。
实际测试,感觉有点难度,改rocketmq源码感觉过于复杂,以后技术提升了,我会模拟两个场景做点简单测试。
和redis对比
思考这个场景的时候,让我想到了经常被问到的一个面试题:为什么redis采用单线程处理用户指令?
既然commitlog的写入并发如此低,能不能用单线程去处理producer的发送操作呢?答案肯定是不能的,首先就是rocketmq本身有很多异步化操作,其持锁的时间非常短,那代表整个流程的开销还是很高的,而redis是纯内存操作,其数据结构简单,指令操作也很简单,整体流程肯定是比rocketmq存消息的开销要小很多。
不过rocketmq的设计思想我觉得和redis还是有一点共通之处的,rocketmq在尽量简化并发模型,利用内存操作和顺序写提高性能。
abs锁
rocketmq官方博客中有一篇对其的介绍,文章链接:rocketmq.io/learning/ex…
abs锁,adaptive backoff spin lock,其核心思想是通过为轻量化自选锁提供一套退避策略,从而实现降低成本,有限制的锁自旋行为。适应不同强度资源的争抢情况。
自旋锁在临界区小,资源争抢不激烈的场景下表现优异。但竞争激烈时锁自旋反而会大大拖垮cpu性能。互斥锁则是避免了cpu的空转,但在临界区小时的损耗又比自旋锁大许多。abs锁是在自旋锁spin lock的基础上,为其优化降低无效的资源损耗。
锁概念回顾
临界区
临界区是一段只允许线程独占式访问的代码块。比如synchronized修饰的方法或代码块。本文想探讨的临界区就是在putMessageLock lock和unlock之间的代码块。
互斥锁
互斥锁是一种独占锁,线程A加锁成功后,在此代码块上的其他线程会加锁失败,继而释放cpu给别的线程。
互斥锁加锁失败而阻塞是在os的内核态实现的,内核将线程置为睡眠状态。锁被释放后,内核又在合适的时机唤醒线程。这期间涉及到了两次线程上下文切换。
自旋锁
通过cpu提供的原子操作CAS在用户态完成lock&unlock操作,不会发生内核态的切换。互斥锁加锁失败时线程会进入阻塞,而自旋锁的线程则通过忙等待的方式应对。
因此,如果我们的临界区小,处理速度快,锁竞争不激烈,适合使用spinlock,反之,则该使用互斥lock节约cpu资源。也就是说消息size小,tps不高的情况下选自旋。
在org/apache/rocketmq/store/CommitLog中有这样的代码,来进行spinlock与reentranlock的选择。这里的config是需要自己配置的,是否use spin lock
this.putMessageLock = messageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ? new PutMessageReentrantLock() : new PutMessageSpinLock();
k次退避锁
自旋锁自身无法得知锁竞争的情况,但是我们可以根据自旋次数判断是否发生了激烈的锁竞争,因此基于自旋行为提出了k次退避锁,当进行k次自旋后仍然没获得锁,则调用Thread.yield将cpu执行权交给操作系统。这样可以在一定程度避免cpu的空转,也能在竞争不强的情况下减少上下文切换。
rocketmq官方的blog中有相关实验数据,其结论是:在x86架构,同步刷盘情况下,k=10^3时发送速度最快,cpu使用率也达到最低。
abs锁
但是在已知锁竞争激烈的情况下,k次退避锁的自旋操作是无意义的,因为最终逃离不了上下文切换的成本,反而增加了k次自旋的损耗。
abs锁通过对k进行动态调整实现优化。当k值达到与互斥锁相同级别损耗时,自旋锁就不适用于当前场景,此时要做自旋锁→互斥锁的转变。
abs锁有多种锁的实现,在请求abs锁时,会去判断当前系统的参数决定获取锁的类型。运行时参数包括:竞争线程数/tps/消息大小/临界区大小。
对于每次争抢,记录当前k值当前自旋获取锁的成功率,如果成功率过低,则适当增大k,如果一直增大还是不管用,则转变锁的实现。官方测试给出k=10^4时,自旋的代码就会大于cpu上下文切换一次的代价。
同时,转变为互斥锁后也会继续监控锁的请求速率,如果速率达到80%,则退为自旋锁。