[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在尽量简化并发模型,利用内存操作和顺序写提高性能。

相关推荐
锋行天下13 分钟前
WebSocket 即时通讯前后端设计和基于token的鉴权
前端·后端
猿java38 分钟前
程序员,你使用过灰度发布吗?
java·分布式·后端
iOS开发上架哦38 分钟前
Flutter,让我们把 Navigator与Route详解 再讲一遍
后端
半桔39 分钟前
红黑树剖析
c语言·开发语言·数据结构·c++·后端·算法
疯狂的程序猴40 分钟前
flutter - 图文讲解表单组件基本使用 & 注册实战
后端
星星电灯猴41 分钟前
Flutter CupertinoNavigationBar iOS 风格导航栏的组件
后端
Asthenia041241 分钟前
深入剖析 MyBatis-Plus 自动注入封装的实现原理及其创新
后端
佩奇快跑43 分钟前
使用 Redis Stream 解决 Java 与 Python 的长连接请求交互
后端
加瓦点灯1 小时前
当你的对象结构拒绝修改时,访问者模式是如何破局的?
后端
追逐时光者1 小时前
在 Blazor 中使用 Chart.js 快速创建数据可视化图表
后端·.net