近期排查了一起 Pulsar 集群(v2.10.4)P99 写入延迟从 5ms 突增至 2s 的雪崩故障。核心根因是 BookKeeper 的 Journal 与 Ledger 未做物理盘隔离,且 RocksDB Compaction 引发大量写放大,导致 fsync 阻塞 IO 线程组。通过硬隔离 NVMe 盘、开启 Direct IO 并优化 Group Commit 参数,最终将 P99 压回 2ms 以内。
故障现场:P99 延迟雪崩与 Broker 背压
排查过程中,监控告警显示某核心业务 Topic 的写入耗时剧增。查看 Prometheus 监控,Broker 侧的 pulsar_broker_publish_latency P99 指标平时稳定在 5ms,突发流量下直接飙升到 2000ms 以上,甚至触发了 Client 端的 TimeoutException。
登录其中一台 Bookie 节点(BookKeeper 4.14.5),直接用 dmesg 和 iostat 摸底硬件状态:
# 每秒打印一次 IO 状态
iostat -x -d 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
nvme0n1 0.00 0.00 0.00 4521.00 0.00 74523.00 32.96 12.45 15.20 0.00 15.20 0.22 100.00
可以看到 nvme0n1 的 %util 已经被打到 100%,且 w_await 高达 15ms(对于 NVMe 来说,超过 2ms 就已经说明底层队列严重排队了)。
翻看 Bookie 的 bookkeeper.log,果不其然,满屏的 Journal Sync 超时告警:
22:14:05.123 [bookie-journal-DIR-sync] WARN org.apache.bookkeeper.bookie.Journal - Sync object took 1250 ms
22:14:05.345 [bookie-journal-DIR-sync] WARN org.apache.bookkeeper.bookie.Journal - Sync object took 1420 ms
22:14:06.001 [bookie-journal-DIR-sync] ERROR org.apache.bookkeeper.bookie.Journal - Journal Sync queue is full, dropping requests!
为什么明明是存算分离架构,Bookie 的 Journal 刷盘依然会被阻塞?
Pulsar 吹嘘最多的就是“存算分离”和“读写分离”。但在 BookKeeper 底层,读写分离是有前提的。
BookKeeper 的一次写入流程(Add Entry)包含两步核心操作:
-
Append to Journal:将数据追加到 Journal 盘(WAL),并在内存中将数据加入 MemTable。只要 Journal 刷盘(
fsync)成功,Bookie 就向 Broker 返回 ACK。 -
Flush to Ledger:后台线程定期将 MemTable 刷入 Entry Logger(数据文件)和 RocksDB(索引文件),这两个文件通常存放在 Ledger 盘。
理论上,Journal 负责顺序写,Ledger 负责随机读和后台批量写,互不干扰。但现实很骨感:如果你的 Journal 和 Ledger 共享同一块物理盘,或者共享同一个 PCIe 通道/阵列卡带宽,灾难就会发生。
在我们的现场,之前的运维人员为了图省事,将 /bookkeeper/data/journal 和 /bookkeeper/data/ledger 挂载到了同一块 2TB 的 NVMe 盘上。当业务触发海量小消息写入时,Ledger 目录下的 RocksDB 疯狂进行 Compaction(文件合并),产生极其严重的写放大。底层文件系统的 Page Cache 被瞬间打满,导致 Journal 线程调用 fsync() 时,被 OS 的脏页回写机制死死卡住。
Journal 线程一卡,Broker 发来的新请求全部堆积在 Bookie 的 Netty 队列里,进而引发 OOM 风险,最终触发 Broker 侧的背压(Backpressure),全局写入延迟雪崩。
解决与加固:防御性 IO 隔离与参数调优
知道了痛点,解决思路就很清晰:物理隔离与绕过 Page Cache 污染。
1. 物理盘绝对隔离
永远不要让 Journal 和 Ledger 抢 IO。Journal 盘需要极低的 fsync 延迟,容量不需要太大(只保存 WAL,写满后会滚动删除);Ledger 盘需要大容量,对 IOPS 也有一定要求(特别是有 Catch-up 追赶读的场景)。
实战配置:
-
Journal:挂载独立的 100G NVMe SSD。
-
Ledger:挂载大容量的 SATA SSD 或阵列。
在 bookkeeper.conf 中明确拆分:
journalDirectories=/data/journal/bk
ledgerDirectories=/data/ledger/bk
2. 启用 Ledger Direct IO,避免 Page Cache 污染
Ledger 的后台 Flush 和 Catch-up 读非常容易污染操作系统的 Page Cache,导致真正需要 Page Cache 加速的读请求被驱逐,甚至影响其他进程的 IO 性能。
在 BookKeeper 4.14+ 中,建议开启 DbLedgerStorage 的 Direct IO,直接让读写绕过 OS 缓存。
# 使用 DbLedgerStorage
storage_facility=org.apache.bookkeeper.bookie.DbLedgerStorage
# 针对 Entry Logger 开启 Direct IO (绕过 Page Cache)
dbStorage_directIOEntryLogger=true
# 分配给 RocksDB Block Cache 的内存(建议占节点物理内存的 10%-20%)
dbStorage_rocksdbBlockCacheSize=4294967296
# 增加 Write Cache 以减少高并发下的 RocksDB 刷盘频次
dbStorage_writeCacheMaxSizeMb=1024
3. Journal Group Commit (组提交) 调优
Journal 是靠 fsync 保证数据不丢的(journalSyncData=true 绝对不能关,关了就是拿命在跑)。但高并发下,每一条消息都 fsync 会让 NVMe 直接瘫痪。
我们需要调整 Group Commit 参数,让 Bookie 在延迟和吞吐之间找到一个平衡。
# 最大等待时间,默认 1ms。如果你的网络/磁盘稍有抖动,1ms 太激进。建议改为 2-5ms,牺牲微小延迟换取巨大吞吐。
journalMaxGroupWaitMSec=2
# 队列中积压多少字节触发一次强制刷盘,默认 512KB,可适当调大到 1MB 或 2MB
journalBufferedWritesThreshold=1048576
# 如果 Journal 盘写入排队依然严重,可以增加 Journal 目录的数量(多盘配置)
journalDirectories=/data/journal1/bk,/data/journal2/bk
重启 Bookie 节点后,再次观察 iostat,nvme0n1(Journal盘)的 w_await 稳定在 0.5ms 以内,Pulsar 生产端的 P99 写入延迟死死压在 2ms,故障彻底解除。
常见问题 (FAQ)
Q:为什么开启了存算分离,消费端(Consumer)追赶历史数据(Catch-up Read)时,还是会导致生产端(Producer)延迟抖动?
A:如果没有开启 Ledger 的 Direct IO,追赶读会把大量历史冷数据加载到 OS Page Cache,把操作系统的空闲内存耗光,触发 OS 的全局脏页回写(Global Flush)。此时哪怕 Journal 在另一块盘上,CPU 和总线也会受到系统态 IO 阻塞的影响。解决方案就是强制配置 dbStorage_directIOEntryLogger=true。
Q:journalSyncData=false 能不能用来解决 P99 高的问题?
A:可以解决监控上的数字问题,但会解决掉你的饭碗。设置为 false 意味着数据写到 OS Page Cache 就直接向 Broker 返回成功,一旦 Bookie 所在机器断电或 Kernel Panic,尚未刷盘的数据将永久丢失。对于金融级或核心交易场景,必须保持为 true,靠优化硬件和 Group Commit 来提速。
Q:如何预估和监控 RocksDB 引发的写放大?
A:重点监控 Bookie 的 bookkeeper_server_DbLedgerStorage_compaction_time 和 IO 设备的写入量。如果发现 Ledger 盘的实际写入速率是业务写入速率的 3 倍以上,说明遇到了 RocksDB 的写放大瓶颈。可以通过调大 dbStorage_writeCacheMaxSizeMb 和限制后台 Compaction 线程的速率来缓解。
Q:多租户场景下,个别异常租户的高频写入会不会打满 Bookie 导致全局故障?
A:会。Bookie 底层是对所有 Topic 的 Entry 进行混合追加写的,一个 Topic 的流量突增会吃光 Journal 的 fsync 能力。必须在 Broker 端开启严格的资源配额(Quota)和按租户/Namespace 的流量限流(Rate Limiting),这叫“防御性运维”。不要指望底层存储能无限制抗住上层的不合理调用。