Skip to content

RobustMQ Raft 状态机持续优化:Group、心跳时钟与 Runtime 线程的再验证

在上一篇《Raft 状态机性能排查:问题不在 RocksDB,在排队》里,我们已经确认了一个关键事实:
client_write 的主要耗时不在 RocksDB 写盘,而在 Raft 入口排队与内部等待。

这篇继续记录后续优化过程。目标不是跑出一次漂亮峰值,而是把参数和现象之间的关系讲清楚,最后收敛出一组稳定、可复现、可解释的配置。


本文要回答的三个问题

第一轮结论出来后,我们把问题收敛到三个方向:

  • Group 分片:把 data_raft_group_num 从 1 提到 4,理论并行度上升后吞吐是否会提升?
  • Raft 时钟:heartbeat_intervalelection_timeout_min/max 会在多大程度上影响吞吐和稳定性?
  • Tokio runtime 线程:server/meta/broker 的 worker 线程如何设置才是更合理的平衡点?

这三个方向在理论上都可能带来性能收益,但实际是否成立,只能靠实验数据。


实验口径与环境说明

为了避免“参数在变、环境也在变”导致的误判,这一轮我们尽量固定压测口径。测试机是 Apple Silicon(14 logical CPU,10P + 4E),核心链路是 placement-create-session,压测工具使用项目内置 cli-bench,主要并发档位为 2000 和 5000。统计指标以 ops/sp95/p99、timeout 以及 Raft 相关指标(write/log append/apply)为主。

需要特别说明的是,这批实验大多是“本机压测 + 本机服务”的形态,结果会受到调度和温控影响。文中给出的结论是“在当前实验条件下”的可复现结果,不直接等价于多机线上环境的绝对上限。


先说清楚:Raft Group 是什么,原先是什么

在展开实验前先把术语对齐。改造前,dataoffset 这类路径本质是单 group 模型:一个逻辑类型对应一个 Raft 状态机,所有请求都进入同一个 client_write 入口,最后由一个 RaftCore 串行推进提交和 apply。这个模型的优点是链路短、状态集中、开销可控,缺点是单核串行能力会成为上限。

改造后引入 RaftGroup,一个逻辑类型不再只有一个节点,而是一个 shard 组(例如 data_0..data_N)。请求先按业务 key(例如 client_id)做 hash,路由到某个 shard,再执行该 shard 的 client_write。从架构意图看,这一步是在把“单 RaftCore 串行瓶颈”拆成“多个 RaftCore 并行处理”,理论上可以提升总吞吐。

如果写成链路,对比会更直观:

text
改造前(单 group):
request -> data(client_write) -> 单 RaftCore -> log append -> apply -> return

改造后(RaftGroup):
request -> hash(key) -> data_i(client_write) -> RaftCore_i -> log append -> apply -> return

这里有一个很容易被忽略的点:改造后增加的并不只有并行度,还增加了路由、分片管理、每个 shard 的后台维护成本(心跳、选举计时器、内部任务、监控维度等)。当部署形态是单机共享资源时,这些“固定成本”可能先于“并行收益”显现。也就是说,RaftGroup 的收益前提不是“只要分片就能提速”,而是“分片带来的并行收益必须大于分片引入的系统成本”。


实验一:Runtime 线程从 auto 到显式 16

我们先从 runtime 线程入手。测试机器是 14 logical CPU(10P + 4E),在 auto 策略下,吞吐通常在 1.1w ops/s 左右。把线程显式配成下面这组后,吞吐明显提升到 2w+。

toml
[runtime]
server_worker_threads = 16
meta_worker_threads = 16
broker_worker_threads = 16

继续把线程拉到 32 以后,结果和 16 基本持平,没有出现线性提升。这说明这条链路不是“线程越多越好”,而是存在一个有效阈值,跨过去就会进入新的瓶颈段。就这台机器来说,14 到 16 是关键台阶,16 到 32 的边际收益已经很小。换句话说,线程调优在这里更像“补齐调度预算”,而不是“通过堆线程持续扩容”。


实验二:Group 分片,data_raft_group_num=4 出现严重退化

按直觉,单个 Raft 串行,多个 group 并行后吞吐应上升。但实测正好相反:把 data_raft_group_num 从 1 调到 4,性能出现断崖式下降,QPS 从 2w+ 掉到几千甚至更低,raft_write_latency 到秒级,timeout 开始持续累积。代表性现象是:压测开始阶段还能维持几千 ops/s,很快进入 1k~3k 平台并伴随 timeout 增长,p95/p99 会拉到秒级。

这个结果说明在当前实验形态里,多 group 带来的不只是并行度,也带来了可观的管理成本。尤其在“本机服务 + 本机压测”的单机共享资源场景中,4 个 group 共享同一套 CPU、runtime、调度和存储路径,额外开销会被放大,最终超过并行收益。当前这条链路上,data_raft_group_num=4 是负优化,=1 更稳。


实验三:Raft 时钟参数,从激进到保守

这轮里最关键的变量之一是 Raft 时钟。我们先验证了激进参数:

rust
heartbeat_interval: 10,
election_timeout_min: 15~20,
election_timeout_max: 50,

在这组参数下,吞吐很容易掉到 1.1w 左右,且在多 group 场景退化更明显。随后换成保守参数:

rust
heartbeat_interval: 100,
election_timeout_min: 1000,
election_timeout_max: 2000,

在以下配置下:

toml
server_worker_threads = 16
meta_worker_threads = 16
broker_worker_threads = 16
data_raft_group_num = 1

placement-create-session 可以恢复到 2w+ ops/s,并且错误率和延迟更稳定。这个结果和前面线程实验是互相印证的:线程只是把调度预算补足,时钟参数决定了 Raft 后台成本和稳定性边界。

这里也解释了一个常见误区:heartbeat_intervalelection_timeout 并不是“只在故障时才生效”。在高并发写入时,它们会持续影响后台任务频率和协调开销,最终反馈到前台吞吐。


时钟参数为什么会影响吞吐

很多人会把这三个参数理解成“只影响故障切换速度”。实际在高并发写路径里,它们同样直接影响吞吐。原因是这些参数会改变 Raft 后台任务的运行频率和稳定性,而这些后台任务与前台 client_write 共享同一套调度资源。

heartbeat_interval 越小,Leader 心跳和相关状态推进就越频繁。单个 group 时这部分开销可能还能接受,但当 group 数增加时,后台 tick 会按 group 叠加,调度器会把更多时间花在高频维护任务上。前台请求虽然没有报错,但更容易在入口处排队,最终体现为吞吐下滑、尾延迟抬升。这个过程不会一定表现为 CPU 打满,因此很容易被误判为“系统没压力”。

election_timeout_min/max 也不只是故障参数。超时区间太短时,网络抖动、调度抖动、短暂暂停都更容易被判定为“心跳异常”,从而触发选举相关流程。即使没有真正频繁切主,这类抖动也会引入额外协调成本,干扰写入路径的稳定推进。对应到现象上,就是吞吐平台更低、波动更大,尤其在高并发和多 group 场景里更明显。

所以时钟参数不是“可用性优先”或“性能优先”的二选一,而是同一个旋钮的两端。对当前这条高并发写链路,保守参数(100/1000/2000)在吞吐稳定性上更合适。


稳定性补充:本机压测环境会放大波动

同参数多次压测仍会出现波动,有时 2w+,有时回落到 1.5w 左右。这个现象和本机压测环境高度相关,关键不在“有没有错误”,而在“噪声是否持续干扰采样窗口”。

首先是同机争用。本机压测 + 本机服务意味着压测客户端、gRPC 栈、服务端 runtime 全都在抢同一台机器的 CPU 时间片。压测并发很高时,客户端本身也会消耗大量计算和调度资源,这部分开销会直接反映到服务端看起来的吞吐波动里。

其次是 macOS 的调度与温控机制。Apple Silicon 存在性能核与能效核,系统会按负载和温度动态分配任务。压测刚开始时常见高平台值,运行一段时间后出现平台下移,再次运行又回升,这种“先高后低、再回升”的节奏,和动态频率/热状态切换非常一致。

再者是执行方式本身。如果一直使用 cargo run,编译和运行会串在同一个流程里,编译阶段带来的预热、系统缓存变化、瞬时负载都会污染后续采样。看起来是同一组参数,实际上每轮启动前机器状态并不一致。

因此这类实验要尽量减少环境噪声:统一 --release、先 build 再直接跑二进制、每轮先 warmup 再采样固定窗口、连续多轮取中位数。这样得到的结论才更接近系统本身,而不是机器瞬时状态。


核心反直觉问题(放在结论前):为什么多 Raft node 没有升,反而降得很厉害

到这里再回到最核心、也最反直觉的问题:单个 Raft node 有瓶颈可以理解,但在 CPU、内存、Tokio runtime 看起来都没明显打满时,为什么多个 Raft node(group 分片)没有提升,反而严重下降?

这个问题的关键在于,“资源没打满”只能说明没撞到硬件上限,不能说明写入链路没有软瓶颈。当前数据更像是链路内部的等待被放大,而不是硬件算力不够。我们观测到 raft_write_latency 到秒级,但 log append 和 apply 只是毫秒级上升,说明主要时间并不花在执行本身,而是花在执行之前和执行之间的等待上。

把这个现象拆开看,会更容易理解为什么会“反直觉”:

第一,group 从 1 增到 4 时,系统增加的不只是并行 worker,还增加了 4 套 Raft 后台活动。每个 shard 都有自己的心跳、选举计时、状态推进和内部任务,这些任务虽然单个不重,但会持续占用调度片段,和前台请求共享同一个 runtime 资源池。

第二,单机场景里并没有引入新的物理资源。我们不是把 4 个 group 分到 4 台机器或 4 块独立盘,而是在同一台机器里拆出 4 条逻辑路径。这样做的结果是“逻辑并行增加了,物理资源没有同步扩容”,因此并行收益和协调成本并不是同向变化。

第三,当前链路的入口闸门仍然存在。即使每个 shard 内部都是独立 RaftCore,只要请求在路由前后、提交推进或内部通知环节发生排队,总体延迟就会迅速放大,吞吐则会先出现平台化,再出现明显下降。

这也是为什么我们会看到一个看似矛盾的组合:没有明显 CPU 打满、没有 RocksDB 单点爆炸,但端到端吞吐却大幅退化。它不是“某一个组件坏了”,而是多组并发下系统性等待成本被放大。

基于目前证据,我们给出的阶段性判断是:这不是 Multi-Raft 方向本身错误,而是当前单机实验形态下,data_raft_group_num=4 的额外成本超过了并行收益。要验证多 group 的真实价值,需要在更接近生产拓扑的条件下继续做对照,例如多机、多副本、以及更完整的链路分段观测。

换句话说,这一轮已经回答了“现象是什么”,也回答了“现象最可能来自哪里”,但还没有回答“在线上拓扑下是否仍然如此”。技术分享里这条边界要明确:结论是阶段性的、场景绑定的,而不是对 Multi-Raft 的最终判决。


第二轮结论与推荐基线

这轮调优把三个最关键变量基本锁定了,当前推荐基线是:

  • data_raft_group_num = 1。在这次单机场景里,调到 4 会明显退化。
  • Raft 时钟采用 100/1000/2000。这组参数明显优于激进参数。
  • Runtime 线程采用 16。在这台机器上,16 是有效阈值,32 几乎没有额外收益。

这组参数不一定是理论最优,但已经是一个可复现、可解释、可继续演进的稳定基线。


下一步

下一步不再做泛化优化,而是聚焦解决一个核心问题:为什么 data_raft_group_num 从 1 增到 4 后,吞吐不升反降。围绕这个问题,我们会把后续工作收敛成一条主线:先把多 group 场景下 client_write 的完整路径拆开,明确秒级耗时主要集中在入口排队、提交等待还是 shard 间调度竞争;再在相同压测模型下做 group=1 与 group=4 的一对一对照,把每段耗时和每个 shard 的负载分布放在同一张图里,确认退化到底来自哪里;最后只针对已确认的瓶颈做定点优化,而不是继续盲目调线程或改其它参数。

这轮的目标不是再找一个“看起来更快”的配置,而是把“为什么多 Raft node 会退化”这件事彻底解释清楚,并形成可以稳定复现的结论。

项目地址:https://github.com/robustmq/robustmq