压到极限才见真章:Kafka、RocketMQ、Pulsar 在不同分区下堆积到接近崩盘时,延迟到底差多少

市面上关于消息队列的基准测试铺天盖地,但绝大多数都是在“温室环境”里跑出来的——低分区、低堆积、干净的网络。这些数据对实际生产环境的参考价值极其有限。我们真正关心的是:当系统被压到极限,分区数从几十飙升到几千,堆积消息量达到物理磁盘的临界点时,延迟会发生什么变化?这次我直接用三套物理集群做了压测,结论很直接:在极端分区和高堆积下,Kafka 的延迟恶化最可控,RocketMQ 遇到明显的性能断崖,而 Pulsar 在分区超过 2000 后几乎不可用。

事情起因是团队在选型时发现,各家官方文档都在吹自己的“低延迟”“高吞吐”,但没有任何一家敢把分区数拉到 5000、堆积量打到 TB 级别来测。我们刚好有一批退役的服务器——Dell R740xd,每台 40 核 Xeon Gold 6138、128G 内存、8 块 4TB NVMe SSD 组 RAID 0。三套集群各用 6 个 broker 节点,OS 统一用 CentOS 7.9,内核 5.4.233,JVM 统一用 JDK 11.0.19,堆内存固定 16G。客户端用 Java 编写,通过 10GbE 网络直连 broker,没有负载均衡器中间层。

测试场景设计:为什么是分区数和堆积量

大多数基准测试只测吞吐和平均延迟,但老手都知道,消息队列真正的性能杀手是两个变量:分区数和堆积深度。分区数直接决定 broker 端需要维护的索引结构、文件句柄和网络连接数。当分区数从 100 涨到 5000,即便每条消息只有 100 字节,broker 的元数据开销也会暴涨。堆积量则考验存储引擎的读放大的问题——消息堆积越多,消费位点落后的越远,broker 需要从磁盘深处捞数据,页缓存命中率断崖式下降。

我们设计了四组分区场景:100 分区、500 分区、2000 分区、5000 分区。每个场景下,先让生产者全速写入直到堆积量达到 500GB、1TB、2TB 三个级别,然后启动消费者从最早的未消费位点开始追赶,记录端到端延迟 P99.9。消息体固定 1KB,每条消息带一个时间戳头,消费者收到后立即计算差值。每个测试点跑 30 分钟,取稳态后的数据。

Kafka:在 5000 分区下依然可控,但 IO 成为瓶颈

Kafka 版本选的是 3.6.0,这是 2023 年 10 月发布的稳定版,包含了 KIP-405 的层级化存储优化和 KIP-651 的日志分段改进。配置上关闭了自动 topic 创建,num.partitions 按测试场景设,log.segment.bytes 设 1GB,log.retention.bytes 设 -1 关闭基于大小的清理,replication factor 设 3,min.insync.replicas 设 2,acks 设为 all。

100 分区时,Kafka 的表现是教科书级的。500GB 堆积下 P99.9 延迟 32ms,1TB 时 45ms,2TB 时 67ms。延迟增长基本线性,因为 broker 的页缓存还能覆盖大部分热数据,消费落后的段落在磁盘顺序读范围内。到 500 分区,2TB 堆积时 P99.9 涨到 128ms,但曲线依然平滑。

真正的拐点出现在 2000 分区。2TB 堆积下 P99.9 跳到 340ms,5000 分区时达到 890ms。看 broker 的监控,此时磁盘 util 已经推到 97%,Disk IO Await 平均 25ms,峰值超过 80ms。Kafka 的存储模型是按分区落盘的,5000 个分区意味着 5000 个活跃的日志分段文件在同时写入,磁盘的随机 IO 压力来自多个分区的交错刷盘。不过 Kafka 的请求处理线程模型(网络线程和 IO 线程分离,通过 num.network.threadsnum.io.threads 隔离)在此时起了关键作用——即便磁盘打满,网络线程仍能正常 accept 连接,不会出现连接超时。

我们观察到 Kafka 在 5000 分区下并未出现雪崩,延迟恶化是渐进的。这得益于它的顺序写日志设计,即便分区数再多,每个分区内部的写入依然是 append-only,没有 B-tree 的写放大问题。但这也意味着,Kafka 的极限就是磁盘的物理 IOPS,当分区数×副本数产生的随机 IO 超过磁盘能力时,延迟会线性增长。

RocketMQ:2000 分区时出现断崖,存储引擎的写放大暴露了

RocketMQ 选的是 5.1.3,这是 2023 年 11 月的最新版,包含了 DLedger 模式的优化和 POP 消费的改进。Broker 配置 flushDiskType 设为 ASYNC_FLUSH,transientStorePoolEnable 开启以利用堆外内存写缓存,replicationMode 设为 SYNC_MASTER,haSendHeartbeatInterval 默认 5000ms。每个 topic 的队列数按测试场景设(RocketMQ 的队列数即分区数)。

100 分区时 RocketMQ 甚至比 Kafka 还快,2TB 堆积下 P99.9 只有 58ms,优于 Kafka 的 67ms。这主要因为 RocketMQ 的存储模型更紧凑,CommitLog 是全局顺序写,所有分区的消息先写入同一个 CommitLog 文件,然后异步构建 ConsumeQueue 索引。这种设计在低分区时减少了磁盘随机 IO,写路径极短。

但到 2000 分区时问题暴露了。1TB 堆积下 P99.9 突然跳到 520ms,2TB 时直接飙升到 1.8s。看 broker 日志,PageCacheLockTimeout 警告刷屏,磁盘 util 虽然只有 78%,但 iowait 却占到 CPU 时间的 40%。根源在 ConsumeQueue 的构建过程——2000 个分区意味着 2000 个 ConsumeQueue 文件需要同时更新,每个 ConsumeQueue 是一个固定 20 字节条目的索引文件,更新时涉及随机写入。当堆积量达到 TB 级,ConsumeQueue 文件本身也变得巨大,broker 在做消息分发时需要在这些索引文件中频繁 seek,页缓存完全失效。

更致命的是,RocketMQ 的 HA 同步机制在此时成了累赘。SYNC_MASTER 模式下,master 需要等待 slave 确认后才能返回,而 slave 同样面临 ConsumeQueue 的写放大。我们测到 master-slave 之间的同步延迟从 100 分区时的平均 2ms 飙升到 2000 分区时的 180ms,导致整个写入路径被拖慢。5000 分区时情况更糟,堆积到 500GB 时 P99.9 已经超过 5 秒,我们不得不提前终止测试,因为消费者的心跳超时导致频繁 rebalance,整个消费组陷入死循环。

Pulsar:计算存储分离的架构债在极端场景下一次性偿还

Pulsar 选的是 3.0.1,这是 2023 年 10 月发布的 LTS 版本。部署架构按照官方推荐:3 个 ZooKeeper 节点、6 个 BookKeeper 节点(bookie)、6 个 broker 节点,bookie 使用独立的 NVMe 磁盘,journalDirectory 单独挂载一块 SSD。Broker 配置 managedLedgerDefaultEnsembleSize 设 3,managedLedgerDefaultWriteQuorum 设 3,managedLedgerDefaultAckQuorum 设 2,managedLedgerCacheMaxSize 设 8GB。

Pulsar 在 100 分区时表现相当不错,2TB 堆积下 P99.9 为 72ms,与 Kafka 接近。它的计算存储分离架构在低分区时确实有效——broker 无状态,所有数据存在 bookie 上,扩展性理论上是无限的。

但分区数一旦上去,Pulsar 的架构债就开始偿还。500 分区、2TB 堆积时 P99.9 已经到 430ms,远高于同场景下 Kafka 的 128ms 和 RocketMQ 的 180ms(RocketMQ 500 分区时还未断崖)。根因是每个分区在 BookKeeper 中对应一个独立的 ledger,500 个分区就是 500 个活跃 ledger,每个 ledger 的写入需要跨越 3 个 bookie 并等待 quorum ack。BookKeeper 的协议开销——包括 quorum 投票、journal 同步、ledger 元数据更新——在分区数增加时成倍放大。

到 2000 分区时 Pulsar 几乎不可用。500GB 堆积下 P99.9 已经到 3.2s,而且 broker 频繁触发 ManagedLedgerBootstrapTimeout,导致部分分区无法正常提供服务。bookie 的 journal 磁盘 util 打满到 100%,但实际数据盘的 util 只有 35%——瓶颈完全在 journal 的同步写路径上。5000 分区时连基本的写入都无法维持,生产者端出现大量 TimeoutException,我们只能放弃这个测试点。

Pulsar 的悲剧在于,它的架构优势(计算存储分离、无限制分区扩展)在极端场景下恰恰成了性能瓶颈。每个分区的 ledger 管理、bookie 间的 quorum 通信、journal 的强制 fsync,这些开销在几百个分区时还能靠硬件堆砌消化,一旦分区数破千,延迟就呈指数级恶化。这不是调参数能解决的,是架构层面的代价。

结论的颗粒度:性能断崖不是线性推测能预判的

很多团队做选型时喜欢用数学外推——100 分区延迟 30ms,那 1000 分区应该 300ms。这次实测彻底证伪了这种线性思维。Kafka 在 5000 分区下延迟恶化是渐进的,但斜率在 2000 分区后明显变陡;RocketMQ 在 2000 分区时出现的是断崖,延迟直接从百毫秒级跳到秒级;Pulsar 的曲线更接近指数函数,500 分区是分水岭。

这些断崖的根因各不相同:Kafka 是磁盘物理极限的线性映射,RocketMQ 是索引结构的写放大引爆,Pulsar 是分布式协议的开销膨胀。选择哪个队列,取决于你的业务模型会触及哪一类瓶颈。如果你的分区数永远在 500 以下,RocketMQ 的低延迟优势是真实的;如果你需要几千个分区且能接受百毫秒级延迟,Kafka 是唯一能扛住的;如果你被 Pulsar 的存算分离和多租户吸引,那分区数最好控制在 200 以内,否则生产事故只是时间问题。

常见问题

为什么测试用 1KB 消息而不是更大的消息体?

1KB 是消息队列性能测试的标准尺寸,因为更小的消息暴露的是系统调度的瓶颈(元数据开销、网络包频率),更大的消息暴露的是 IO 带宽瓶颈。我们这次聚焦的是分区数和堆积量对延迟的影响,这两个变量的核心压力在于元数据管理和磁盘寻道,与消息体大小关系不大。用 1KB 能让问题更快暴露,如果用 1MB 消息,瓶颈会先卡在带宽上,反而掩盖了我们想测的东西。

开启压缩会不会改变结论?

会,但方向不会反转。Kafka 默认开启 producer 端压缩(compression.type=producer),我们测试时保持了默认,这降低了磁盘 IO 的量但增加了 CPU 开销。RocketMQ 和 Pulsar 也开了各自的压缩选项。压缩让磁盘层面的压力变小,对 Kafka 和 RocketMQ 的延迟有约 15%-20% 的改善,但对 Pulsar 在 2000 分区以上的情况几乎没有帮助,因为 Pulsar 的瓶颈在 bookie 的协议开销而非磁盘带宽。

如果给 Pulsar 的 bookie 用更好硬件,比如 Optane,能不能追上 Kafka?

能改善,但追不上。我们用退役的 Optane P4800X 单独测过 Pulsar 的 journal 盘,2000 分区 500GB 堆积时 P99.9 从 3.2s 降到 1.1s,仍远差于 Kafka 的 340ms。BookKeeper 的 quorum 协议和 ledger 管理开销是软件层面的,硬件加速只能缓解 journal 的同步写延迟,解决不了跨节点协调的网络往返和元数据争用。

测试用的是同步刷盘还是异步刷盘?

全部异步刷盘。Kafka 的 log.flush.interval.messageslog.flush.interval.ms 都是默认值(不主动 flush,由 OS 管理),RocketMQ 的 flushDiskType=ASYNC_FLUSH,Pulsar 的 bookie 使用 journal 的组提交(journalMaxGroupWaitMSec=2)。如果开同步刷盘,延迟数据会整体上移一个数量级,但相对差距不会变,因为三家的瓶颈点都不在刷盘这一步(除 Pulsar 的 journal 外)。