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存储的消息数据格式如下:
sql
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生成的图,检查后发现没啥问题,后面用文字补充细节。
数据封装与校验
消息到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生成
python
# 异步写入消息的核心方法(伪代码)
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在尽量简化并发模型,利用内存操作和顺序写提高性能。