RobustMQ Raft 状态机性能突破:Batch 语义 + 并发治理,从 2 万到 14 万 ops/s
上一篇《Raft 状态机持续优化:Group、心跳时钟与 Runtime 线程的再验证》的结论是:当前单机环境下 data_raft_group_num=4 是负优化,=1 配合保守时钟参数能稳定在 2 万 ops/s 左右。文末留下的问题是:为什么多 Raft node 没有提升反而退化?下一步怎么办?
这篇记录的是那之后的分析过程和最终突破。结论先放前面:瓶颈不在 Raft group 数量,而在请求粒度和并发深度。把 CreateSession 改为 Batch 语义,并把客户端并发从数千降到 20,单 Raft group 的吞吐从 2 万 ops/s 直接拉到 14 万 ops/s,成功率 100%,延迟 P95 < 24ms。
回到问题本身:瓶颈到底在哪
上一轮的结论是"raft_write_latency 到秒级,但 log append 和 apply 只是毫秒级",我们把原因归结为"入口排队和系统性等待"。这个判断方向没错,但还不够精确——它回答了"时间花在等待上",却没有回答"为什么会等这么久"。
这一轮我们把注意力聚焦到 OpenRaft 的内部实现上。阅读 openraft-0.9.21/src/core/raft_core.rs 后发现了一个关键事实:
RaftCore::append_to_log 不是把日志丢进队列就返回,而是内部 rx.await 等待 log flush 回调。也就是说,每一次 proposal 都会阻塞 Raft core event loop,直到磁盘写入完成。
这意味着 Raft core 的 proposal 处理能力有一个硬上限:大约每秒 33~50 个 proposal(取决于单次 log flush 的延迟)。当外部并发远超这个速率时,请求全部堆积在 rx_api channel 里排队。队列越深,尾部请求等待越久,最终体现为秒级的端到端延迟和大量客户端超时。
这个发现解释了上一篇所有未解的现象:
- 为什么 log append 和 apply 很快,但 raft_write_latency 很高? 因为 latency 不是执行时间,是排队时间 + 执行时间。执行本身只需毫秒,但排在队尾的请求可能等了一两秒才轮到。
- 为什么多 group 反而更差? 4 个 group 意味着 4 套 Raft 后台任务在抢同一个 runtime 的调度时间片,tokio 的调度延迟被拉高,每个 group 的有效 proposal 处理速率反而更低。
- 为什么 CPU 没有打满但性能上不去? 因为瓶颈不在 CPU 算力,而在 Raft core event loop 的串行等待。核心线程在
rx.await上 idle,其他核再多也帮不上忙。
结论很清楚:真正的杠杆不在"加更多 Raft 实例",而在"减少每个实例需要处理的 proposal 数量"和"控制排队深度"。
优化一:CreateSession 全链路 Batch 语义
既然单个 Raft group 每秒只能处理约 40 个 proposal,那让每个 proposal 承载更多业务数据就是最直接的放大器。
Proto 层:从单条到批量
原先 CreateSessionRequest 是单条语义——一个请求只创建一个 Session。改造后引入 CreateSessionRaw,一个请求可以携带一批 Session:
message CreateSessionRaw {
string client_id = 1;
bytes session = 2;
}
message CreateSessionRequest {
repeated CreateSessionRaw sessions = 1;
}这样一次 gRPC 调用、一次 Raft proposal、一次状态机 apply,就能处理 N 个 Session。如果 batch_size=100,40 个 proposal/s 就等于 4000 个 Session/s——这还只是单 group 的理论下限。
状态机 Apply 层:RocksDB WriteBatch
原来 apply 阶段每个 Session 都是一次独立的 rocksdb.put_cf()。N 个 Session 就是 N 次 WAL fsync,每次都要走一遍"序列化 → 拿 CF handle → 写盘"的完整路径。
改造后在 rocksdb-engine 层新增了 engine_batch_save 接口,使用 RocksDB 原生的 WriteBatch:
pub fn engine_batch_save<T: Serialize>(
engine: &Arc<RocksDBEngine>,
column_family: &str,
source: &str,
entries: &[(String, &T)],
) -> Result<(), CommonError> {
let cf = get_cf_handle(engine, column_family)?;
let mut batch = rocksdb::WriteBatch::default();
for (key, value) in entries {
let wrap = StorageDataWrap::new(value);
let serialized = serialize(&wrap)?;
batch.put_cf(&cf, key.as_bytes(), &serialized);
}
engine.write_batch(batch)
}一个 WriteBatch 里的所有 put 操作只触发一次 WAL fsync,原子性由 RocksDB 保证。100 个 Session 从 100 次 fsync 变成 1 次,apply 阶段的 I/O 开销降到原来的 1/100。
效果链路
改造后的一次写入链路:
cli-bench (100 sessions) → 1 gRPC call → 1 Raft proposal → 1 log append
→ 1 apply → 1 RocksDB WriteBatch (100 puts, 1 fsync) → return相比改造前(100 个独立请求 → 100 个 proposal → 100 次 fsync),Raft core 的负担降低了两个数量级。
优化二:并发治理——少即是多
Batch 语义解决了"每个 proposal 做的事太少"的问题,但还有另一个同样重要的维度:排队深度。
过度并发为什么有害
前几轮压测一直使用 --concurrency 2000 甚至 5000。直觉上,高并发意味着高吞吐。但在 Raft 写链路里,这个直觉是错的。
Raft core 是一个严格串行的处理器:一个 proposal 进入 → log flush → 等回调 → 处理下一个。不管外面有多少并发在等,它的处理节奏不会因为"排队的人更多"而加快。相反,队列越深,副作用越大:
- 调度竞争加剧。2000 个 tokio task 同时处于 await 状态,每次 Raft core 完成一个 proposal,runtime 需要在大量 waker 里选择下一个唤醒。这个调度开销随并发数增长。
- 尾延迟恶化。排在队尾的请求延迟 ≈ 队列深度 × 单 proposal 处理时间。并发 2000 + 单 proposal 25ms = 最后一个请求可能等 50 秒。
- 超时引发连锁反应。客户端设了 3 秒超时,但排队等了 3 秒还没轮到,客户端取消请求、关闭连接。服务端 Raft 最终处理完后发现 oneshot receiver 已经被 drop,触发
OneshotConsumer.tx.send: is_ok: false警告。这些"幽灵 proposal"白白消耗了 Raft 的处理能力,却没有产生有效结果。
降到 20 并发后发生了什么
把 --concurrency 从数千降到 20:
- 排队深度从数千降到不超过 20。每个 proposal 进来几乎立刻被处理,不需要排队。
- 调度开销骤降。tokio runtime 只需管理 20 个活跃 task,context switch 大幅减少。
- 零超时。所有请求在毫秒级完成,没有"幽灵 proposal"浪费 Raft 处理能力。
- Raft core 利用率反而更高。因为没有调度抖动和超时干扰,core 能以更稳定的节奏推进 proposal。
这就是经典的排队论现象(Little's Law):在固定服务速率下,降低队列深度不会降低吞吐,但会大幅降低延迟。而延迟降低又会消除超时和重试,进一步提高有效吞吐。
压测结果
测试环境
- Apple Silicon,14 logical CPU(10P + 4E)
- 本机服务 + 本机压测(单机 all-in-one)
data_raft_group_num = 1,Raft 时钟100/1000/2000,runtime 线程 16- Debug 模式编译(非 release)
压测命令
cargo run --package cmd --bin cli-bench meta placement-create-session \
--host 127.0.0.1 \
--port 1228 \
--count 10000000 \
--concurrency 20 \
--batch-size 100 \
--timeout-ms 60000 \
--output table核心指标
| 指标 | 值 |
|---|---|
| 总 Session 数 | 10,000,000 |
| 总耗时 | 69 秒 |
| 平均吞吐 | 144,927 sessions/s |
| 峰值吞吐 | 179,000 sessions/s |
| 成功率 | 100% |
| 超时 | 0 |
| 延迟 P50 | 11.7ms |
| 延迟 P95 | 23.9ms |
| 延迟 P99 | 57.3ms |
| 延迟 min | 1.6ms |
与上一轮对比
| 维度 | 上一轮(单条 + 高并发) | 本轮(Batch + 低并发) |
|---|---|---|
| batch_size | 1 | 100 |
| concurrency | 2000~5000 | 20 |
| 吞吐 (ops/s) | ~20,000 | ~145,000 |
| P95 延迟 | 数百毫秒~秒级 | 24ms |
| 超时率 | 持续累积 | 0 |
| 成功率 | < 100% | 100% |
吞吐提升约 7 倍,延迟降低约 两个数量级,超时从"持续累积"变为零。
Grafana 监控印证
从 Raft 指标面板可以看到优化后的状态机运行非常健康:
- Raft Write QPS:data_0 稳定在 ~340 req/s(即 ~340 proposal/s × 100 sessions = ~34,000 sessions/s 的 Raft 层吞吐)
- Raft Write Success Rate:与请求量完全匹配,无失败
- Raft Write Failure Rate:全链路 0 失败
- Log Append Latency P50:~0.5ms
- Apply Batch Latency P50:~0.5ms
- Raft Write Latency:毫秒级,不再出现秒级尖刺
这组数据说明 Raft 内部各环节都运行在舒适区间:log flush 快、apply 快、没有排队积压。整条链路的耗时就是"网络 + 序列化 + log flush + apply"这几步的真实开销,没有额外的等待放大。
回头看上一篇的遗留问题:多 Group 为什么始终退化
上一篇的核心困惑是"多 Raft node 为什么退化",当时归因为"单机共享资源导致协调成本超过并行收益"。这个归因方向对,但不够精确。
这一轮在 Batch 语义已经就位的前提下,我们又做了一次对照:把 data_raft_group_num 从 1 调到 4,其余参数不变(concurrency=20, batch_size=100)。结果性能仍然大幅退化。 这排除了"上一轮是因为单条语义 + 高并发才导致多 group 退化"的可能——即使 Batch + 低并发,多 group 依然比单 group 差。
追查代码后,我们最终把退化原因锁定在 OpenRaft 的 per-proposal async 调度模型上,而不是 RocksDB 本身。
先排除一个容易误判的方向:所有 Raft group 共享同一个 Arc<DB>,但 Grafana 显示 Log Append Latency 只有 ~0.5ms,说明 RocksDB 单次 db.write() 非常快,理论上每秒可以处理 ~2000 次写入。即使 4 个 group 同时写(4 × 340 ≈ 1360 次/秒),RocksDB 完全吃得下。RocksDB 不是瓶颈。
真正的瓶颈在于:每个 proposal 在 OpenRaft 内部不只是一次 db.write(),而是一次 tokio async 往返:
RaftCore (task A) → 发送 entries 给 log store → yield,等待回调 (rx.await)
↓ tokio 调度切换
LogStore worker (task B) → db.write(batch),0.5ms → callback.log_io_completed()
↓ tokio 调度切换
RaftCore (task A) → 被唤醒 → 继续 run_engine_commands → 处理下一个 proposal单 group 时,这个往返涉及 2 次 tokio task 切换,实测总延迟 ~3ms(0.5ms 磁盘 + 剩余全是调度开销)。所以 1 group 能稳定跑到 ~340 proposals/sec。
4 个 group 时情况完全不同:
- 4 个 RaftCore task + 4 个 log worker task + 4 个 SM worker task + 4 组心跳定时器 + 4 组选举定时器 ≈ 16+ 个活跃 task 在同一个 tokio runtime 上调度
- 每次 task 切换的延迟变长——不是因为 CPU 不够,而是 tokio scheduler 要在更多 waker 里选择唤醒对象,调度队列更深
- 每个 group 的 per-proposal 往返从 ~3ms 涨到 ~8-10ms,单 group 的有效 proposal 速率显著下降
- 4 组后台心跳和 Balancer 持续插入高频小任务,进一步干扰前台 proposal 的调度节奏
最终效果:4 个 group 的总吞吐不但没有达到 4 倍,反而低于 1 个 group。因为 per-proposal 的延迟增幅(调度竞争导致)超过了 group 并行带来的理论增益。
data_raft_group_num = 1:
1 个 RaftCore,每 proposal ~3ms → ~340 proposals/s → 34,000 sessions/s (batch=100)
data_raft_group_num = 4:
4 个 RaftCore,每 proposal ~8-10ms → 每 group ~100-125 proposals/s
总计 ~400-500 proposals/s?不一定——4 组后台开销会进一步降低
实际观测:总吞吐明显低于单 group核心结论:OpenRaft 每个 proposal 必须经历 "core → log worker → core" 的 async 往返,这个往返的延迟主要取决于 tokio 调度延迟而非磁盘 I/O。多 group 在同一个 tokio runtime 上运行时,调度竞争会放大每个 proposal 的往返延迟,导致总吞吐不升反降。
这个问题的本质不在 RocksDB,也不在 OpenRaft 有 bug,而在于 "多个串行状态机共享同一个 async runtime"这种部署形态本身有调度天花板。要突破这个天花板,方向是让每个 group 运行在独立的 runtime 上(独立线程池),或者改进 OpenRaft 的 log flush 路径减少 async 往返次数。在当前架构下,data_raft_group_num=1 配合 Batch 语义就是最优解。
本轮改造总结
这一轮做了两件事,都不复杂,但效果直接:
第一,Batch 语义。把 CreateSession 从单条改为批量,一次 Raft proposal 处理 N 个 Session。Raft 层不需要改动,只需要把 gRPC 请求和状态机 apply 逻辑改为批量感知。存储层用 RocksDB WriteBatch 替代逐条 put,N 次 fsync 变 1 次。这是垂直优化——每个 proposal 做更多有效工作。
第二,并发治理。把客户端并发从数千降到 20。不是"降低压力",而是"消除无效排队"。Raft core 的处理速率是固定的,过多并发只会堆积排队,产生超时和调度开销,反而降低有效吞吐。控制并发 = 控制排队深度 = 消除尾延迟 = 消除超时 = 提高有效吞吐。这是水平优化——让系统在最佳工作点运行。
两者结合后,单 Raft group 在单机 Debug 模式下就跑到了 14 万 sessions/s。这个数字还有提升空间:Release 模式编译、多机部署消除同机争用、适度增加 group 数量。但当前基线已经足够说明方向是对的。
下一步
Batch + 并发治理的组合已经把 placement-create-session 这条链路带到了一个健康的基线。后续工作沿三条线展开:
第一,把 Batch 语义推广到其他高频写路径(Subscribe、Offset 等),用同样的方法降低 Raft proposal 压力。
第二,评估多 Raft group 独立 runtime 的可行性。当前所有 group 共享同一个 tokio runtime,调度竞争是多 group 退化的根因。如果每个 group 运行在独立的 runtime(独立线程池),per-proposal 的 async 往返延迟不会因为 group 数增加而被放大,多 group 的并行收益才能真正体现。这个改造需要评估线程资源开销和架构复杂度的平衡。
第三,继续在单 group 基线上优化单 proposal 的处理效率。当前 Debug 模式下已经跑到 14 万 sessions/s,Release 编译、多机部署、以及 OpenRaft 配置微调(如 max_payload_entries)还有进一步提升空间。
