某次接手一个号称“完全按照官方最佳实践”部署的 Pulsar 集群,业务方反馈高并发场景下大量 Producer 频繁抛出 PulsarClientException$TimeoutException,P99 写入延迟从常态的 5ms 瞬间飙升至 8000ms+,集群吞吐呈断崖式下跌。直接抛出排查结论:这是典型的底层存储架构无知导致的惨案。部署人员将 BookKeeper 的 journalDirectories(写前日志)和 ledgerDirectories(数据与索引)挂载到了同一块物理磁盘(甚至是同一块云盘)。当 Ledger 触发后台垃圾回收(Garbage Collection)或 RocksDB 刷盘时,海量随机 IO 直接榨干了磁盘 IOPS,导致 Journal 的顺序 fsync 严重阻塞。Bookie 内部线程池大面积挂起,最终因 ZK 心跳超时被踢出集群,引发 NotEnoughBookiesException 全局写入雪崩。
Pulsar 最大的卖点就是“计算与存储分离”(Broker 与 Bookie 分离),但很多人只停留在节点级别的隔离,完全无视了 BookKeeper 内部极其苛刻的 IO 路径分离要求。
BookKeeper 的写入模型极其严谨且保守:一条消息到达 Bookie 后,必须强制 fsync 落盘到 Journal(类似 MySQL 的 Redo Log),才会向 Broker 返回 ACK。同时,消息会被写入内存(MemTable),随后异步批量刷入 Ledger 磁盘,并更新 RocksDB 中的索引。
这套设计的初衷非常明确:用 Journal 的极速顺序写保证低延迟和数据可靠性,用 Ledger 的大容量存储应对历史数据读取和高吞吐。
把 Journal 和 Ledger 混在一块盘上,无异于在高速公路上摆地摊。
排查期间,登陆故障 Bookie 节点,一条极其普通的 iostat 命令就让问题原形毕露:
# iostat -dx 1
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
nvme1n1 0.00 0.00 850.00 1200.00 10240.00 45000.00 53.89 145.20 70.83 90.50 56.90 0.49 100.00
磁盘 %util 死死钉在 100%,avgqu-sz(请求队列长度)高达 145,await 飙到 70ms 以上(对于 NVMe 来说,超过 5ms 就已经是不及格了)。
去翻看 Bookie 的 Prometheus 监控,核心指标 bookkeeper_journal_JOURNAL_SYNC_99_per(Journal 落盘 99 线)与磁盘 IO 延迟高度吻合,出现了巨幅毛刺。此时,Broker 的日志里已经尸横遍野:
org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:142)
...
为什么会突然爆发?因为 BookKeeper 并非只有简单的追加写。当 Ledger 中的 EntryLog 文件里被删除(或过期)的数据达到一定比例时,Bookie 会触发后台 GC 线程(Minor/Major Compaction)。GC 的动作是读取旧文件、过滤有效数据、重写到新文件。这是一个极其暴力的重度随机读 + 顺序写过程。
如果 Journal 和 Ledger 共享物理 IO 设备,GC 产生的海量 IO 请求会瞬间塞满 OS 的 Block Layer 队列,Journal 线程哪怕只是想追加写入几 KB 数据并调用一次 fsync,也只能在队列里绝望地排队。
不仅如此,由于 Journal 同步阻塞,Bookie 的 Netty Worker 线程被耗尽,导致 Bookie 连发往 ZooKeeper 的心跳都无法及时响应。ZK 判定 Bookie 宕机,Broker 发现 Ensemble 可用节点不足(例如配置了 3 副本,只剩下 2 个健康节点),直接拒绝写入。由于集群是均衡负载的,随着 GC 在各个节点轮番上演,整个 Pulsar 集群如同多米诺骨牌般倒塌。
解决这种问题,不要去迷信什么神奇的 JVM 调优参数,核心就是尊重物理拓扑。
修复手段与防御性配置:
1. 物理级别的 IO 隔离(最关键)
修改 bookkeeper.conf,强制分离 Journal 和 Ledger 目录到不同的物理磁盘。Journal 给一块极小但极快的高性能 NVMe SSD(几十G即可,写满会自动清理),Ledger 给大容量的普通 SSD 甚至 HDD。
# 高速 NVMe 挂载点
journalDirectories=/mnt/nvme_journal/bookkeeper/journal
# 大容量 SSD/HDD 挂载点
ledgerDirectories=/mnt/ssd_ledger/bookkeeper/ledgers
2. 对后台 GC 进行冷酷的资源限流
不要让 GC 跑起来像脱缰的野马。在 bookkeeper.conf 中开启 GC 限速,严格控制其对磁盘带宽的占用:
# 开启按字节限流
isThrottleByBytes=true
# 限制 Compaction 最大速率为 50MB/s (根据底层磁盘能力调整)
compactionRateByBytes=52428800
# 避免在高峰期触发 Major Compaction
minorCompactionThreshold=0.2
majorCompactionThreshold=0.8
3. RocksDB 索引刷盘的平滑处理 Ledger 中的索引默认由 RocksDB 管理,RocksDB 的 MemTable Flush 同样会带来 IO 尖峰。确保配置了合理的 Write Buffer 和并发度:
dbStorage_rockdb_writeBufferSizeMB=64
dbStorage_rockdb_numLevels=6
架构设计不是画几个方块就完事了。Pulsar 这种分布式中间件的性能底座,其实都建立在底层 Linux IO 调度和文件系统特性的基础之上。不理解数据的生命周期流转,不看磁盘的 IOPS 和延迟分布,一键部署出来的集群,最终都会在晚高峰教你做人。
排查清单:BookKeeper IO 阻塞与假死速查
-
磁盘物理拓扑核对:执行
df -h和lsblk,严格对照bookkeeper.conf中的journalDirectories和ledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。 -
Journal Sync 延迟监控:紧盯
bookkeeper_journal_JOURNAL_SYNC的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。 -
ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有
Expired session,以及 ZK 侧是否有Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。 -
GC 日志与速率审查:搜索 Bookie 日志中的
GarbageCollectorThread关键字,观察 Compaction 触发频率和耗时。确认isThrottleByBytes是否开启并配置了合理的阈值,防止后台合并打挂前台写入。 -
Direct Memory 泄漏挤压 OS Cache:检查
dbStorage_directIO_entryLogger是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。