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

288 阅读13分钟

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能容忍单线程写入

维度KafkaRocketMQ
写入模型多 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存储的消息数据格式如下:

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生成

# 异步写入消息的核心方法(伪代码)
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 拷贝到 MappedByteBuffer100~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在尽量简化并发模型,利用内存操作和顺序写提高性能。

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%,则退为自旋锁。