深入 Apache Pulsar 雪崩排查:大负载滥用引发的 Bookie OOM 与 Zookeeper Ledger 元数据风暴

某次核心业务线的 Pulsar 集群突发雪崩,生产端 99 线写入延迟从 5ms 瞬间飙升到 5000ms+,紧接着出现大面积 ProducerFencedExceptionTimeoutException。先抛结论:这又是一起典型的“把 MQ 当网盘用”引发的血案。业务方将单条动辄 5MB 到 10MB 的非结构化 JSON 直接怼进 Pulsar,且未开启消息分块(Chunking)。大负载瞬间打爆了 Bookie 的 Direct Memory 导致节点 OOM 宕机;Bookie 下线后触发了 Broker 的 Ledger Ensemble 切换风暴,海量的新 Ledger 创建请求最终将底层的 ZooKeeper 彻底打瘫,集群随之全局假死。

如果你也遇到了 Pulsar 写不进去,但 Broker 负载看着很低的情况,先去查底层的 BookKeeper 和 Zookeeper,Pulsar 存储计算分离的本质决定了:Broker 只是无状态的网关,真正的血肉之躯在下层。

案发现场与指标崩盘

排查初期,监控面板上的数据极其诡异:

  1. Broker 层:CPU 负载平稳,甚至有点闲置,但 pulsar_storage_write_latency_le 指标直接断崖式破表。

  2. Bookie 层:集群中某一台 Bookie 节点离奇掉线,剩余存活节点的 bookkeeper_journal_JOURNAL_SYNC_latency_99 从微秒级涨到了惊人的 3-5 秒。

  3. Zookeeper 层Outstanding Requests 飙升至数万,znode_count 在短短十分钟内激增了几十万。

登入那台掉线的 Bookie 节点,dmesg -T 没有看到 OS OOM Killer 的痕迹,但翻看 Bookie 的 bookkeeper.log,满屏的猩红:

ERROR org.apache.bookkeeper.bookie.Bookie - Error on writing ledger
java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:694)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:754)
    at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:331)
...

很明显,Bookie 进程因为 Netty 直接内存(Direct Memory)耗尽挂了。

底层原理解析:大消息为何引发全局雪崩?

在 Pulsar 的架构中,消息持久化由 BookKeeper 负责。为了追求高吞吐,Bookie 高度依赖 Netty 的池化直接内存来处理读写 IO,避免 JVM 堆内存的垃圾回收停顿(GC Pauses)。

第一米多米诺骨牌:Direct Memory 爆炸 业务侧高并发写入 5MB+ 的大消息时,Bookie 的 Write Cache(由 dbStorage_writeCacheMaxSizeMb 控制,默认占用分配直接内存的 25%)被迅速填满。同时,由于单条 Payload 过大,Netty 在分配和回收 Direct Buffer 时出现碎片化和频繁的扩容操作,最终直接顶破了 MaxDirectMemorySize 的上限。

第二米多米诺骨牌:Ledger 切换风暴 Pulsar 的写高可用依赖于 Bookie 的 Ensemble 机制。假设配置了 E=3, W=3, A=2(使用3个Bookie节点,写3份,2份Ack即成功)。当上述那台 Bookie OOM 宕机后,Broker 在等待 Ack 时发生超时,此时 Broker 会果断执行防御性动作:

  1. 将当前正在写入的 Ledger 标记为关闭(Fenced)。

  2. 从存活的 Bookie 列表中挑选新的节点,组成新的 Ensemble,并在 Zookeeper 中创建一个全新的 Ledger。

灾难点在于:业务侧的重试风暴没有停止,大消息还在疯狂涌入。新 Ledger 刚创建,新的 Bookie 又被大消息塞得 IO 夯死或网络延迟,Broker 再次超时,再次 Fence Ledger,再次请求 ZK 创建新 Ledger。

第三米多米诺骨牌:Zookeeper 瘫痪pulsar-admin topics stats-internal 输出中,平常一个 Topic 只有寥寥几个 Ledger,此时却看到了几千个碎片化的 Ledger ID:

"ledgers": [
    {"ledgerId": 104523, "entries": 5, "size": 25600000},
    {"ledgerId": 104524, "entries": 2, "size": 10240000},
    {"ledgerId": 104525, "entries": 1, "size": 5120000}
]

每一个 Ledger 的创建、状态变更,都需要强一致性地写入 Zookeeper。Zookeeper 本身就不擅长处理高频写,在这场疯狂的切换风暴中,ZK 的事务日志盘被彻底压爆,连接队列堆满。最终,Broker 抛出 MetadataStoreException: KeeperErrorCode = ConnectionLoss,全员罢工。

与此同时,BookKeeper 内部的 AutoRecovery 检测到副本数不足,开始后台搬运数据,这让仅存的几台 Bookie 的磁盘 IOPS 和带宽更是雪上加霜,Journal 盘彻底失去响应(Sync 卡死)。

现场恢复与架构调整

要让这套系统活过来,重启是没用的,必须阻断恶性循环。

  1. 阻断生产洪峰:临时在 Broker 的 broker.conf 中动态下调 maxMessageSize(比如降回 1MB),硬性拦截业务侧的大负载写入,强制生产端抛错。

  2. 扩容与隔离:调大 Zookeeper 的 JVM 堆内存,增加 maxClientCnxns;重启 OOM 的 Bookie,并在启动参数 bkenv.sh 中将其 XX:MaxDirectMemorySize 翻倍。

  3. 禁用自动恢复:紧急执行 bookkeeper shell autorecovery -disable,防止数据重建任务抢占正常读写的 IO 资源,等凌晨低峰期再开启。

长期避坑建议与加固方案:

不要指望业务开发能完全遵守规范,运维和架构的底线就是通过配置和架构隔离来兜底。

  • 强制启用生产端 Chunking 或外置对象存储:对于大负载,如果非要用 MQ,生产端必须配置 ProducerBuilder.enableChunking(true),将大消息切片后发送,消费端再重组;或者将原始负载丢入 S3/MinIO,Pulsar 里只流转 Object URL。

  • 硬件层级冷热分离:BookKeeper 必须严格区分 Journal 盘和 Ledger 盘。Journal 盘用于顺序写 WAL,必须上 NVMe SSD;Ledger 盘用于批量落盘和随机读,可以使用大容量 SATA SSD 甚至 HDD。如果混用在一块盘上,fsync 延迟必然被大消息拉爆。

  • 精细化 Bookie 内存与缓存控制: 在 bookkeeper.conf 中,明确指定 DbLedgerStorage 的内存分配比例,防止 Direct Memory 失控: ini # 读缓存与写缓存的分配比例(默认 25/25,推荐读多时调高读,写多调高写) dbStorage_readAheadCacheMaxSizeMb=... dbStorage_writeCacheMaxSizeMb=... # 控制直接内存用于 Netty 接收缓存的比例 allocatorPoolingPolicy=PooledDirect

排查清单:Pulsar 写入雪崩同类问题速查

  1. 查看 Broker 底层延迟指标:重点监控 bookkeeper_journal_JOURNAL_SYNC_latency_99。如果该指标突破 50ms 甚至达到秒级,说明 Bookie 磁盘 IO 已成瓶颈,检查是否触发了 AutoRecovery 或存在大消息滥用。

  2. 排查 Zookeeper 压力:如果 Broker 日志频繁出现 ConnectionLossSessionExpired,检查 ZK 的 Outstanding Requests 指标。大概率是 Broker 频繁更换 Ledger 导致的元数据风暴。

  3. 检查 Topic 碎片化:使用 pulsar-admin topics stats-internal 查看 ledgers 列表。如果单个 Topic 存在大量仅包含几个 Entry 的碎片化 Ledger,说明 Bookie 状态极不稳定,触发了频繁的 Ensemble 容错切换。

  4. Bookie OOM 溯源:检查 dmesg 排除系统级 OOM 后,直接看 Bookie 进程日志搜索 OutOfMemoryError。若为堆外内存溢出,需结合 bkenv.sh 中的 MaxDirectMemorySize 以及业务消息 Size 综合评估。