RocketMQ 打破锁性能瓶颈之道

作者|王怀远、季俊涛
2024年11月6日

背景

Apache RocketMQ 是一个云原生消息传递和流式处理平台,可简化创建事件驱动型应用程序的过程。多年来随着 RocketMQ 的迭代,已经编写了大量代码来利用多核处理器,通过并发提高程序效率。因此管理并发性能变得至关重要,同时锁对于确保在访问共享资源时多个执行线程安全同步至关重要。尽管锁对于确保多核系统中的互斥性是必不可少的,但它们的使用也可能带来优化挑战。随着并发系统内部变得越来越复杂,部署有效的锁管理策略是保持性能的关键。

因此,在 GSOC 2024 中,我们 Apache RocketMQ 开源社区提报了一个非常具有挑战性的题目:《GSOC-269 : Optimizing Lock Mechanisms in Apache RocketMQ》。在这个题目中,我们旨在优化锁行为,优化 RocketMQ 的性能以及资源占用。通过这个题目,我们创新性地提出了 ABS 锁 —— Adaptive Backoff Spin Lock。ABS 锁的思想是通过为轻量化的自旋锁提供一套退避策略,从而实现低成本、有限制的锁自旋行为,同时适应不同强度的资源争抢情况。

之所以取其缩写 ABS,是因为该锁的设计思想与刹车系统中的 ABS 系统有一定相似性。在早期系统中使用的自旋锁会一直进行自旋,但是当临界区较大时会产生大量无效自旋,类似刹车制动中的“抱死”结果,这导致我们的资源利用急剧增长。为了避免这种资源损耗,当前社区使用互斥锁对其进行了替代,避免临界区较大时的资源浪费。但是在这种替代方案落实后,当消息较小时,互斥锁的阻塞唤醒机制反而影响到消息发送的响应时间,并带来了更高的 CPU 损耗。

由于自旋锁在临界区较小时的响应时间(RT)以及 CPU 利用方面都有较好的表现,唯一的不足是在资源争抢时会带来资源浪费。因此我们决定对自旋锁进行优化,降低无效的资源损耗,从而更好的利用自旋锁的优点,使其同时适合争抢激烈或不激烈的场景。在实践中,我们已经证明了调整锁策略会影响 RocketMQ 的消息发送性能,带来显著的性能优化结果。

通过 ABS 锁,我们还能解决开源使用者对锁的抉择问题:在 RocketMQ 消息投递时存在两种锁定机制,SpinLock(swapAndSet),以及 ReentrantLock 互斥锁,但并无文档对其进行分析使用的场景。所以我们通过 ABS 锁对其进行整合,达到最优状态并且完成服务端的锁定机制闭环,不需要用户去决定当前场景锁定机制的选取,自然而然地根据争抢情况对锁参数进行微调。ABS 锁可以根据运行时条件动态调整其行为,例如锁争用级别和争用同一资源的线程数。这可以通过最大限度地减少与锁获取和释放相关的开销来提高性能,尤其是在高争用的情况下。通过实时监控系统的性能指标,ABS 锁可以在不同的锁定策略之间切换。

我们目前已经实现自适应锁定机制(ABS 锁),通过实验结果成功验证:自适应锁定机制达到不同场景下单独使用互斥锁/自旋锁的最优效果,简而言之就是取得不同场景下最优锁定机制的效果。本文将详细介绍 Apache RocketMQ 的锁机制迭代过程,并介绍 ABS 锁的优化效果。

相关概念介绍

在文章正式开始前,需要介绍一些本文中可能频繁用到的概念:临界区、互斥锁、自旋锁。了解清楚这些概念将有助于阅读本文的优化思想。

临界区

临界区(Critical Section)是一段供线程独占式访问的代码,也就是说若有一线程正在访问该代码段,其它线程想要访问,只能等待当前线程离开该代码段方可进入,这样保证了线程安全。一般临界区大小是受多方面影响的,例如,本文中消息发送过程的临界区大小可能受消息体大小影响。

互斥锁

互斥锁是一种独占锁,当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 就会失败,就会释放掉 CPU 给其他线程,线程 B 加锁的代码就会被阻塞。

互斥锁加锁失败而阻塞是由操作系统内核实现的,当加锁失败后,内核将线程置为睡眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程加锁成功后就可以继续执行。具体的互斥锁行为可以参考下图:

互斥锁带来的性能开销主要在两次线程上下文切换的成本。

  1. 当线程加锁失败时,内核将线程的状态从【运行】切换到睡眠状态,然后把CPU切换给其他线程运行;
  2. 当锁被释放时,之前睡眠状态的线程会变成就绪状态,然后内核就会在合适的时间把CPU切换给该线程运行。

自旋锁

自旋锁通过 CPU 提供的原子操作 CAS(CompareAndSet),在用户态完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些开销小一些。它和互斥锁的主要区别在于,当加锁失败,互斥锁使用线程切换应对,而自旋锁用忙等待应对。

RocketMQ 的锁机制迭代

下面,我们将介绍 RocketMQ 的锁机制迭代过程。由于自旋锁、互斥锁有各自的优劣势:自旋锁的优势在于其轻量级和低上下文切换开销,适合短时间等待的情况;而互斥锁的优势在于能够节约资源,适合长时间占有锁的场景。

因此本文中将其优势分别比喻为“鱼”和“熊掌”,我们将一步步介绍如何同时拿到最优质的“鱼”和“熊掌”。

鱼和熊掌的抉择——互斥/自旋

在早期为了减小线程上下文切换带来的资源损耗,Apache RocketMQ 选取了自旋锁进行实现。随着版本迭代 RocketMQ 的并发压力日益增长,导致当临界区较大时,自旋锁会产生大量无效自旋,这导致 Broker 的资源利用急剧增长。因此后期 RocketMQ 又使用互斥锁对其进行了替代,避免临界区较大时的资源浪费。

但是当消息较小时,互斥锁的阻塞唤醒机制反而影响到 RT 以及上下文切换引起的更高的 CPU 损耗,自旋锁在临界区较小时的响应时间(RT)以及 CPU 利用方面都有较好的表现,因此在过去我们一直被锁定机制的正确选取所困扰。直到今日,RocketMQ 的内部仍然保留着这两种锁,且通过一个开关进行控制:useReentrantLockWhenPutMessage。

这意味着,使用者需要在启动 broker 时,决定自己的锁类型——如果消息体都比较小,且发送 TPS 并不大,则使用自旋锁;当消息体较大时,或者竞争极为激烈时,则启用互斥锁。

鱼和熊掌兼得—— k 次退避锁

为了解决这个问题,我们启动了锁优化的工作。我们希望有一把锁能够同时具备自旋锁、互斥锁的特点,同时适用于竞争激烈和不激烈的情况。我们最终决定改造自旋锁,通过一把特殊的自旋锁,使系统在各种竞争情况下都保持非常优质的锁行为。自旋锁由于无限自旋直到获取到锁,在临界区较大时会产生较多的空转,耗费大量的 CPU 资源。为了能有效利用自旋锁的优势,因此我们要在临界区较大时对其空转次数的控制,从而避免大量空转,最大程度兼容临界区较大的场景。

最终我们通过对自旋锁的行为建模,提出了 k 次退避锁:进行 K 次自旋后还未获得锁后,执行 Thread.yield() 将 CPU 执行权交给操作系统。这种行为能够避免互斥锁的无谓上下文切换,也能避免高压场景下的无限自旋带来的 CPU 损耗。

这种行为能够缓解系统压力,取得自旋和 CPU 上下文切换两中方法中的最低资源损耗。

在刚过去不久的 FM 24 会议上,Juntao Ji 已经做出 k 次自旋锁的相关理论建模分享,以及实验验证[3]。在k次自旋锁的作用下,我们能找到系统性能的局部最优点,达到最大的 tps 性能。结果如下表所示:

以 X86 架构,同步刷盘的行为为例。实验结果表明,在 k= 10^3 时,发送速度不仅达到峰值(155019.20),CPU 使用率也达到最低。这表明退避策略成功地节省了 CPU 资源。此时,CPU 支持更高的性能水平和较低的利用率水平,这表明性能瓶颈已经转移——例如,可能已经转移到了磁盘上。在表中可以观察到,在具有相同的 k(10^3)和配置参数(最新代码,SYNC 刷盘模式)的 ARM CPU 上,RocketMQ 的性能提高了 10.4%。此外,如上图所示,当 k= 10^3 时,CPU 使用量大幅下降,从平均超过 1000% 下降到 750% 左右。资源消耗的减少表明,减轻其他系统瓶颈可能会导致更显著的性能提高。

最优质的鱼和熊掌—— ABS 锁

在上文已经证明了 k 次退避锁的有效性。但是当前还有一个问题:我们发现当临界区足够大时,自旋锁的资源损耗依旧远超互斥锁(多次退避仍然获取不到)。这样 k 值其实带来的影响是负面的,也就是说,最终逃避不了上下文切换的成本,反而还带来了自旋的等待成本。

因此我们决定通过实现 k 的动态调整再次优化,当临界区较大时,对k进行自适应增大,当达到与互斥锁相同级别资源损耗时(即 k 达到自适应的最大值),此时自旋锁已经不再适合此种场景,因此我们将对其进行互斥锁的切换。这也是我们最终要实现的 ABS 锁。

1. ABS 锁实现

我们通过对不同的锁定机制进行理论分析其所适合的场景,并对其在所适合的场景进行大量实验测试得出多个场景的最优锁定机制。最后通过对运行时条件的动态变化(竞争线程数/TPS/消息大小/临界区大小)进行最优锁定机制的切换。

2. ABS 锁的 K 值自适应策略

上面我们论证出控制自旋次数对于性能优化有不错的效果,但是这个 K 值对于不同系统是不一样的,因此我们需要实现自旋次数 K 的自适应。

简而言之,K 的自适应策略就是一种从低频次自旋到高频次自旋的演化过程,对应临界区争抢变得不断严重或是临界区不断变大的过程。当 k 逐渐增大的过程中,可以增加在线程退避之前就获取到锁的概率,但是当自旋次数增加到一定数量级时,此时自旋成本已经高于线程上下文切换的成本,说明此时已经不适合使用自旋锁——所以此时退化为互斥锁。

通过实验,我们将自旋次数 K 自适应最大值设置为1万,因为在实验中,我们发现当自旋次数大于1万时,竞争激烈时带来的优化效果会受到显著影响,甚至大于一次 CPU 上下文切换的代价。所以此时我们将其切换为互斥阻塞等待锁定机制。

为了自适应调整 K 值,我们提出了一个闭环的工作流,如下图所示:

在图中我们可以看到,我们主要衡量当前 k 值下,自旋获取锁的成功率。如果在当前 k 值下获取锁的成功率不够高,则适当增加 k 值,这将带来更大的获取概率。但是如果一直增加都无法有效提高拿锁成功率,则将其转为互斥锁,会带来更高的效益。

成为互斥锁代表当前可能有较高的突发流量,导致对锁的竞争变得激烈了。但是互斥锁是不适用于低压场景的,所以我们还需要决定如何从互斥锁转回自旋锁。因此我们记录了从自旋锁切换为互斥锁的请求速率。当整体请求速率低于这个数值的 80% 时,则切换回自旋锁。

实验及结果

为了验证 ABS 锁的正确性以及性能优化效果,我们做了多组实验,包括性能测试以及混沌故障测试。本章将介绍具体的实验设计以及结果。

1. 性能实验配置

  1. namesrv 一台,broker一台,openmessage-benchmark 一台压测机
  2. 上述三台机器的配置为,处理器:8 vCPU,内存:16 GiB,规格族:ecs.c7.2xlarge,公网带宽:5Mbps,内网带宽:5/ 最高 10 Gbps。
  3. 消息体大小分别设置了1KB以及2B两种场景,用于影响发送消息时的临界区大小。

2. 性能测试结果

CPU 与耗时的优化

消息 body 大小 1 kb 时,我们记录了不同消息发送速率下的 CPU 占用情况。结果如下图所示:

结果表明,在消息量不断增加时,我们的自适应锁带来了非常明显的优势,有效降低了 CPU 的使用率。

另外,我们还对消息发送时的 P9999 做了记录。P9999 代表发送过程中,发送耗时排在 99.99% 的尾部请求,一般反映了这段时间内的最慢请求速度。结果如下图所示:

可以看到,在不同 TPS 下,我们的自适应锁都能带来更优质的 P9999,有效降低了发送过程中由于锁争抢带来的耗时。

不过,尾部的请求情况一般代表竞争极为激烈时的极端场景。可能有的消息经过多次锁请求尝试但是都未获得锁,因此导致了这种长尾效应。我们还测试了在不同 TPS 下的平均发送耗时情况,发现在竞争极为严重时,我们的 ABS 锁由于自带了一定自旋,所以会让平均延时大约提升 0.5 ms。使用时需要仔细衡量具体场景,以确定是否在高压时开启自适应锁。

我们同时还针对消息体更小的实验场景做了实验,下表是完整的测试结果:

最大性能提升

此外,为了计算由自适应锁带来的性能提升,我们还测试了 Broker 的最大性能,结果如下所示:

CPU ArchFlush Policy Original QPSOptimal QPSImprovement
X86ASYNC176312.35184214.98+4.47%
X86SYNC177403.12187215.47+5.56%
ARMASYNC185321.49206431.82+11.44%
ARMSYNC188312.17212314.43+12.85%

根据该表,我们可以认为自适应锁同时能够在多个场景下均找到最大的性能点,实现性能的释放。

实验总结

经过如上所有实验数据,我们可以得出如下结论:

  1. 在临界区较小时,ABS 锁提供更高的 TPS 以及更低的响应延时,极限 TPS 提高 12.85% 甚至更高,极限 TPS 下响应时间降低了 50% 左右;
  2. 在临界区较大时,ABS 锁提供更低的 RT 以及更低的资源损耗,CPU 损耗从 500%-400%,减少无效资源浪费。

3. 故障测试

在软件工程领域,提倡”拥抱故障”的理念意味着认识到错误和异常是正常的一部分,而非完全避免。像 RocketMQ 这样的关键系统,它在生产环境中承受的压力远超于理想化的实验室测试。为此,采用了混沌工程策略,这是一种主动探寻系统极限和脆弱性的实践。

混沌工程的核心目标是增强系统的鲁棒性和容错能力,它是通过在实际操作中人为制造故障,如网络延迟、资源限制等,来观察系统如何应对突发状况。这样做是为了确认系统是否能在不确定和复杂的现实中保持稳定,能否在面对真实世界的问题时依然能高效运作。

RocketMQ 引入自适应锁定机制后,进行了严格的混沌工程实验,包括但不限于模拟分布式节点间的通信故障、负载峰值等情况,目的是验证新机制在压力下的性能和恢复能力。只有当系统经受住这种高强度的“拷打”测试,证明它能在不断变化的环境中维持高可用性,我们才认为这是一个成熟的、可靠的解决方案。

总结起来,通过混沌工程,我们对 RocketMQ 进行了实战演练,以此来衡量其实现高可用性的真正实力。 并希望通过这样的故障测试,证明我们对锁的调整不影响其数据写入的正确性。

实验配置

我们混沌测试的验证实验环境如下:

  1. namesrv 一台,内含 namesrv 进程。
  2. openchaos 的混沌测试一台,向 broker 发出控制指令。
  3. broker 三台,同属一个集群,内含 broker 进程。

上述五台机器的配置为,处理器:8 vCPU,内存:16 GiB,规格族:ecs.c7.2xlarge,公网带宽:5Mbps,内网带宽:5/ 最高 10 Gbps。

在测试中,我们设置了如下的若干种随机测试场景,每种场景都会持续至少 30 秒,且恢复后会保证 30 秒的时间间隔再注入下一次故障:

  1. 机器宕机,这个混沌故障注入通过 kill -9 命令实现,将会杀死范围内的随机进程。
  2. 机器夯机,这个混沌故障注入通过kill -SIGSTOP 命令实现,模拟进程暂停的情况。

每组场景的测试至少重复 5 次,每次至少持续 60 分钟。

实验结论

针对上述提出的所有场景,混沌测试的总时长至少有:

2(场景数) * 5(每组测试次数)* 60(单组时长) = 600分钟

由于设置的注入时长 30s,恢复时长 30s,因此至少共计注入故障 600/1 = 600 次(实际上注入时长、注入次数远多于上述统计值)。

在这些记录在册的测试结果中,RocketMQ 无消息丢失,数据在故障注入前后均保持强一致。

总结

本文实现了 RocketMQ 锁定机制的迭代过程以及自适应退避自旋锁机制(ABS 锁)的设计以及实现,随着并发系统内部变得越来越复杂,部署有效的锁管理策略是保持性能的关键。因此,我们希望更深入地探索该领域性能优化的潜力,探索性能的极限。

同时我们希望在未来结合多种分布式锁定机制以及其他优秀思想,对其进行实现以及性能测试,期待达到多端本地锁定状态共识:单次通信即可得到锁的成果,减少无效网络通信以及更低的总线资源损耗。但目前专注于消息投递时的锁定机制实现,并未对其进行具体实现以及测试。其它分布式锁机制可以参考如下。

未来可能引入的其它锁优化思想

延迟槽位思想(Delay card slot idea):

对每个客户端分配不同槽位的延时,让客户端进行延迟后再次请求,从而避免总线的大量无效碰撞。

CLH 思想:

循环读取上一个位点的值,更改自身状态。

MCS 思想:

与 CLH 相似,但是时在本地自旋,更改下一个节点的状态。解决了 CLH 在 NUMA 系统架构中获取 locked 域状态内存过远的问题

SMP CPU 架构

基于 P-persistent CSMA 思想:

P-persistent CSMA(Persistent Carrier Sense Multiple Access)是一种网络通信协议的思想,它通常用于描述分布式环境中如无线局域网(Wi-Fi)中的冲突避免策略。这个思想起源于 CSMA/CD(Carrier Sense Multiple Access with Collision Detection)协议,但它引入了一种更持久的监听机制。

1. 基本流程: 每个设备(节点)在发送数据前会持续监听信道是否空闲。如果信道忙,节点就会等待一段时间后再次尝试。
2. P-计数器: 当检测到信道忙时,节点不会立即退回到等待状态,而是启动一个名为 P(persistent period)的计数器。计数器结束后,再尝试发送。
3. 碰撞处理: 如果有多个节点同时开始发送,在信道上发生碰撞,所有参与碰撞的节点都会注意到并增加各自的P计数器后再试。这使得试图发送的数据包在更长的时间内有机会通过,提高了整体效率。
4. 恢复阶段: 当 P 计数器归零后,如果信道依然繁忙,节点会进入恢复阶段,选择一个新的随机延迟时间后再次尝试。

P-persistent CSMA 通过这种设计减少不必要的冲突次数,但可能会导致网络拥塞情况下的长时间空闲等待。

参考链接

[1] Apache RocketMQ
https://github.com/apache/rocketmq
[2] OpenMessaging OpenChaos
https://github.com/openmessaging/openchaos
[3] Ji, Juntao & Gu, Yinyou & Fu, Yubao & Lin, Qingshan. (2024). Beyond the Bottleneck: Enhancing High-Concurrency Systems with Lock Tuning. 10.1007/978-3-031-71177-0_20.
[4] Chaos Engineering
https://en.wikipedia.org/wiki/Chaos_engineering
[5] T. E. Anderson, “The performance of spin lock alternatives for shared-money multiprocessors,” in IEEE Transactions on Parallel and Distributed Systems, vol. 1, no. 1, pp. 6-16, Jan. 1990, doi: 10.1109/71.80120
[6] Y. Woo, S. Kim, C. Kim and E. Seo, “Catnap: A Backoff Scheme for Kernel Spinlocks in Many-Core Systems,” in IEEE Access, vol. 8, pp. 29842-29856, 2020, doi: 10.1109/ACCESS.2020.2970998
[7] L. Li, P. Wagner, A. Mayer, T. Wild and A. Herkersdorf, “A non-intrusive, operating system independent spinlock profiler for embedded multicore systems,” Design, Automation & Test in Europe Conference & Exhibition (DATE), 2017, Lausanne, Switzerland, 2017, pp. 322-325, doi: 10.23919/DATE.2017.7927009.
[8] OpenMessaging benchmark
https://github.com/openmessaging/benchmark