[mq系列]-🚀mq的单commitlog锁争抢会不会成为性能瓶颈?

0.引言

在回顾rocketmq的消息存储架构时突然想到一个问题,印象里commitlog的写入是需要加锁防止数据错乱的,而rocketmq将所有的topic存放在一个commitlog中,也就是说他的commitlog的写入并发会很小,这样没问题吗?于是我决定带着这个问题再次学习一下rocketmq的消息存储机制。

1.对话ds-r1

问:rocketmq这样的设计是否有问题?

答:ds分别从锁粒度和锁优化,顺序写入,异步并行三个角度给我说明这样的设计没有问题。我总结了一下:

  1. 锁粒度,其持有锁的时间只有:1计算消息offset,2将消息追加至mappedfile,3更新消息存储状态。锁优化,rocketmq通过自旋等操作优化,我也在rocketmq官网找到一篇abs锁的实践,后续会学习。
  2. 顺序写入,单commitlog的设计让全局消息都能做到顺序写入,也就是顺序写入pro max。同时还提供page cache的优化,通过mmap和异步刷盘机制。
  3. 异步并行,这个点和我的问题关系不大,这个就是mq做的一些基本优化。

问:对比kafka,rocketmq这样的设计是不是不太好?

ds给我列了个表格,同时告诉我为啥rocketmq能容忍单线程写入

维度 Kafka RocketMQ
写入模型 多 Partition 并行写入(每个 Partition 独立文件)。 全局 CommitLog 顺序写入,逻辑队列异步构建。
锁竞争 每个 Partition 独立锁,并行度高。 全局锁,但锁粒度极细,竞争影响小。
磁盘 I/O 随机写(依赖多个 Partition 文件)。 纯顺序写(最大化磁盘吞吐)。
适用场景 高并发写入(需分区规划)。 超高吞吐、低延迟、顺序写优化场景。
  1. 生产者可以将消息合批发送,降低网络IO开销
  2. 通过mmap直接操作内存,避免jvm堆外内存复制
  3. 顺序写充分发挥SSD/HDD带宽能力,单线程也能打满磁盘IO

第三点我没啥概念,顺序写有这么牛逼?而且相关的概念好像在redis那边也看过,不过人家确实是纯内存操作,你这个mmap内存映射也可以套用吗?

2.rocketmq的消息存储流程

过一遍rocketmq的消息存储流程,查漏补缺一下。

在此之前先记录一下我不确定的几个点:

  1. 加锁位置?应该是在mappedbytebuffer去slice到当前offset前加锁
  2. 通过mmap内存映射,可以直接等同于纯内存操作吗?mmap的force是否有开销?
  3. 写入的时机,是不是有什么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生成的图,检查后发现没啥问题,后面用文字补充细节。

%% 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有两种方式写

  1. 若开启暂存池,刷盘设置为async,brokerrole为master,就会使用暂存池去写入数据,暂存池原理是:类似线程池,池中有提前申请好的内存,写完后入池,等待系统一并将积攒的buffer池刷入磁盘。这个得到一个普通的writebuffer
  2. 若没有开启暂存池,使用同步映射的方式,则通过mappedbytebuffer映射到对应的commitlog文件,通过slice方式创建共享内存,用于write数据。这个得到一个mmap的writebuffer。

此前消息已经写入到缓冲区中,将缓冲区内容写入到writebuffer里,返回消息结果。此时进行解锁。

然后执行刷盘策略,有同步刷盘和异步刷盘两种方式,异步是由mq起一个定时任务执行刷盘。同步就是直接force,异步如果开启了暂存池,有两个阶段。

  1. commit阶段,将bytebuffer的数据写入commitlog的filechannel中
  2. 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的过程是与预期一样的。

持有锁时主要做了以下操作

  1. 获取上一次写入的mappedfile
  2. 更新存储时间戳
  3. 看下mappedfile是否空or满,进行对应操作
  4. append消息(buffer形式)

2.3.分析

消息的内存流转状态变化:

netty的bytebuf→堆内存对象messageextbrokerinner→encode之后变成直接内存bytebuffer

然后被mappedbytebuffer做append操作

数据从网卡先复制到堆内存,再从堆内存到直接内存,然后追加到mmap映射的内存后等待刷盘。

netty那边有自己的优化,到堆中之后,也全都在用户态就完成了复制,因此速度也很快。同时,写入的数据利用到了pagecache机制,并非直接写磁盘,这一部分属于mmap的优化。刷盘的时候利用dma,无需cpu参与数据搬运,同时还是顺序写入。

联想到一个面试题,为啥rocketmq快,可以这样回答:

  1. netty自带的优化
  2. bytebuffer→mappedbytebuffer,无需用户态切换至内核态
  3. mmap零拷贝优化
  4. dma+顺序写的优化
  5. 暂存池机制在批量写场景也有很大帮助

还有一些极端场景的优化

  1. mmapfile缺页,需要加锁重新load,但是rocketmq的mmapfile很大,发生缺页次数不会太频繁
  2. 刷盘策略,rocketmq也提供灵活刷盘机制
  3. 锁竞争,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理论分析

  1. 锁争抢

原流程获取putmessage lock时,不同topic会发生争抢锁操作,同一topic有重入锁

改造流程获取putmessage lock时,不同topic不会发生争抢操作,理论上来说吞吐量大大提高。

  1. 顺序写

原流程可以保证全局的顺序写,改造流程单topic视角下可以顺序写,全局从磁盘角度来看是随机写

  1. 文件本身

原流程单commitlog,分配大空间,缺页情况很少发生,改造后commitlog单文件肯定不能分配这么大,要往百倍往上打折,缺页情况发生的会很频繁。

  1. 主从复制

原来只需要复制单一commitlog流,改造后比较复杂,不过可以采用主节点主动推送的方式去同步,但是主从在核对时比较麻烦。

实际测试,感觉有点难度,改rocketmq源码感觉过于复杂,以后技术提升了,我会模拟两个场景做点简单测试。

和redis对比

思考这个场景的时候,让我想到了经常被问到的一个面试题:为什么redis采用单线程处理用户指令?

既然commitlog的写入并发如此低,能不能用单线程去处理producer的发送操作呢?答案肯定是不能的,首先就是rocketmq本身有很多异步化操作,其持锁的时间非常短,那代表整个流程的开销还是很高的,而redis是纯内存操作,其数据结构简单,指令操作也很简单,整体流程肯定是比rocketmq存消息的开销要小很多。

不过rocketmq的设计思想我觉得和redis还是有一点共通之处的,rocketmq在尽量简化并发模型,利用内存操作和顺序写提高性能。

相关推荐
wowocpp4 分钟前
spring boot Controller 和 RestController 的区别
java·spring boot·后端
后青春期的诗go9 分钟前
基于Rust语言的Rocket框架和Sqlx库开发WebAPI项目记录(二)
开发语言·后端·rust·rocket框架
freellf15 分钟前
go语言学习进阶
后端·学习·golang
全栈派森2 小时前
云存储最佳实践
后端·python·程序人生·flask
CircleMouse2 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
獨枭4 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架4 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱4 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
☞无能盖世♛逞何英雄☜4 小时前
Flask框架搭建
后端·python·flask
进击的雷神4 小时前
Perl语言深度考查:从文本处理到正则表达式的全面掌握
开发语言·后端·scala