标签: BookKeeper

  • 深入 Apache Pulsar 跨机房雪崩排查:Geo-Replication 断连引发的 Cursor 堆积与 BookKeeper GC 死亡螺旋实战

    某次跨地域容灾演练引发断网,Pulsar 集群 P99 写入延迟飙升至 5s。核心结论:Geo-Replication 复制链路断开导致 Replicator Cursor 停止推进,底层 BookKeeper Ledger 无法回收,磁盘满载后触发极端激进的 EntryLog Compaction,将 Ledger 盘 IOPS 彻底打满,导致 Write Cache 无法 Flush,最终阻塞写入。解决方案为配置 Backlog Quota 降级策略并隔离 GC IO。

    事故现场与指标异动

    排查过程中,监控大盘发出严重告警。集群(Pulsar 2.10.4, BookKeeper 4.14.7)在地域 A(主)和地域 B(备)配置了 Geo-Replication。地域 B 的专线网络模拟切断 30 分钟后,地域 A 的本地生产者全部收到 ProduceTimeout 异常。

    查看监控指标,发现以下几个异常:

    1. Broker 侧pulsar_broker_publish_latency P99 从 5ms 突增到 5000ms+。

    2. Broker 侧pulsar_replication_backlog 持续线性增长,Replicator 处于断开状态。

    3. Bookie 侧:Journal 盘(NVMe SSD)的 iostat 正常,但 Ledger 数据盘(普通 SSD)的 util% 持续 100%。

    4. Bookie 侧:Write Cache 满载,bookkeeper_server_ADD_ENTRY_QUEUE_SIZE 严重积压。

    登录其中一台 Bookie 节点,直接抓取磁盘 IO 现场:

    # iostat -xz 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 (Journal) 0.00     0.00  120.00  450.00  1536.00  4096.00    19.78     0.12    0.21    0.15    0.23   0.10   5.70
    sda (Ledger)      0.00     0.00 4850.00 2100.00 65536.00 28672.00    27.11    34.50   14.20   12.10   19.05   0.14  100.00
    

    可以看到,负责同步刷盘的 Journal 毫无压力,但负责异步刷盘和冷数据存储的 Ledger 盘 IOPS 被完全打满。查阅 Bookie 日志,满屏的 Compaction 狂暴运作:

    20:15:33.123 [GarbageCollectorThread-1-1] INFO  org.apache.bookkeeper.bookie.GarbageCollectorThread - Suspending compaction, disk usage 92% is above warn threshold 90%.
    20:15:33.456 [GarbageCollectorThread-1-1] INFO  org.apache.bookkeeper.bookie.GarbageCollectorThread - Running major compaction on entrylog 14552...
    

    为什么 Geo-Replication 断连会引发本地写雪崩?

    很多刚接触 Pulsar 计算存储分离架构的人会有个误区:BookKeeper 的 Journal 盘和 Ledger 盘是物理隔离的,只要 Journal 写得快,Pulsar 就能一直维持低延迟。

    但在实际生产的复杂拓扑(尤其是跨机房双活/多活)中,这个防御假设不堪一击。本次故障的底层原理是一场由 Cursor 停滞引发的连环死亡螺旋:

    1. Replication Cursor 卡死:Pulsar 的 Geo-Replication 本质上是 Broker 内部维护的一个特殊的 Subscription(游标)。当专线断开,地域 B 无法接收数据,地域 A 的 Broker 侧 pulsar_replication_backlog 会不断堆积。

    2. ManagedLedger 无法回收:Broker 侧的数据清理完全依赖 ManagedLedger 的 Cursor 推进。由于 Replication Cursor 是全量保留的,Zookeeper 中的 Ledger 元数据无法被标记为 Deleted。

    3. Bookie 磁盘水位告警:未删除的 Ledger 持续占据 Bookie 空间,导致 Ledger 磁盘使用率触碰 diskUsageWarnThreshold(默认 90%)。

    4. 触发 GC 死亡螺旋:BookKeeper Garbage Collector 线程检测到磁盘高水位,企图通过激进的 Major Compaction 腾出空间。但由于 绝大部分 Ledger 仍在存活期,Compaction 需要从旧的 EntryLog 中读取出存活的 Entry,重新写入新的 EntryLog,并更新 RocksDB 索引。这引发了极其庞大的无用读写(Read-Modify-Write)放大。

    5. Write Cache 阻塞:Ledger 盘的 IOPS 被 GC 榨干。由于 Pulsar 写入时不仅写 Journal,还要写入内存的 Memtable(Write Cache)。Memtable 满了后必须 Flush 到 Ledger 盘。Ledger 盘 IO 打满导致 Flush 极慢,最终阻塞整个 Write 链路,甚至引发 Broker 内存爆满和重连。

    核心配置调优与防御性加固

    花里胡哨的跨机房双活,最后往往死在最基础的磁盘保护和背压(Backpressure)机制上。解决此类问题,必须在 Broker 和 Bookie 双侧同时实施防御性配置。

    1. Broker 侧:强制接管 Backlog Quota (破局点)

    绝不能允许一个远端机房的断连拖死本地主干业务。必须对 Namespace 设置基于时间或大小的 Backlog Quota,并采取丢弃策略(Eviction),确保本地磁盘的生存权。

    # 查看当前的 backlog-quota
    pulsar-admin namespaces get-backlog-quotas my-tenant/my-ns
    
    # 强制配置 backlog-quota:超过 50GB 直接开始按最旧数据丢弃
    pulsar-admin namespaces set-backlog-quota my-tenant/my-ns \
      --limit 50G \
      --policy consumer_backlog_eviction
    

    broker.conf 中,针对 Replication 场景开启默认强制容忍限制(重要):

    # 如果订阅方掉线,最多保留多久的数据(分钟)
    managedLedgerDefaultMarkDeleteRateLimit=1.0
    backlogQuotaDefaultLimitGB=50
    backlogQuotaDefaultRetentionPolicy=consumer_backlog_eviction
    

    2. Bookie 侧:GC 隔离与限速 (防爆点)

    Bookie 的默认 GC 策略过于理想化,在极端容量下,GC 必须被限速,否则它自己就会成为压垮 IO 的最后一根稻草。修改 bookkeeper.conf

    # 磁盘使用率达到 90% 时触发告警,达到 95% 时 Bookie 直接变为 Read-Only(拒绝新写入,保命)
    diskUsageWarnThreshold=0.90
    diskUsageThreshold=0.95
    
    # 严格限制 GC 线程的 IO 速率,避免 Compaction 打满磁盘
    # 每秒最多重写 1000 个 Entry 或 10MB 数据
    compactionRateByEntries=1000
    compactionRateByBytes=10485760
    
    # 开启 GC EntryLog 元数据缓存,减少 GC 时的随机读盘
    gcEntryLogMetadataCacheEnabled=true
    
    # 调小 Minor/Major Compaction 的阈值,平摊日常 GC 压力,防止堆积
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. DbLedgerStorage 读写隔离 (底层优化)

    Pulsar 默认使用 DbLedgerStorage(基于 RocksDB)。在高并发落盘时,必须确保 RocksDB 的 Write Buffer 和 Block Cache 合理分配。在 bookkeeper.conf 调整分配策略:

    # 使用直接 IO,绕过 PageCache,防止 Catch-up 读或 GC 污染 PageCache,挤占 Flush 性能
    dbStorage_directIOEntryLogger=true
    
    # 为 RocksDB 设置合理的 BlockCache 大小(假设节点内存较大)
    dbStorage_rockdbBlockCacheSize=2147483648
    dbStorage_rockdbWriteBufferSizeMB=64
    

    常见问题

    Q: Broker 侧开启了 TTL(Time To Live),为什么断网后 Bookie 磁盘空间还是没有释放? A: 这是 Pulsar 最常见的认知误区。TTL 默认只对 没有 Cursor 引用 的数据有效。如果你的 Replication Cursor(或者任何下游 Consumer Cursor)卡住了,数据会被标记为 Retained。此时 TTL 是无效的。如果想要 TTL 强制覆盖 Cursor,必须配合设置 ttlDurationDefault 并且使用 Namespace 级别的强制 Retention 策略,但这会导致复制链路丢失数据,需业务层评估接受度。

    Q: Bookie 日志盘 (Journal) 和数据盘 (Ledger) 已经分离,为什么 Ledger 盘高 IO 还是会影响实时写延迟? A: 因为 BookKeeper 的写入模型中,数据除了追加到 Journal 盘(负责 WAL 可靠性),同时还会写入内存中的 Write Cache(Memtable)。Write Cache 满了需要 flush 到 Ledger 数据盘。如果 Ledger 盘因为 GC/大批量 Catch-up 读导致 IOPS 耗尽,Write Cache 无法被清理,此时新的写入请求就会被阻塞在内存池外,最终导致客户端超时。

    Q: 跨机房同步场景下,如何监控并提前预警这种问题? A: 必须强监控 Broker 端的三个关键指标:

    1. pulsar_replication_backlog:复制延迟超过阈值必须立即告警,不能放任。

    2. pulsar_storage_backlog_quota_evictions:一旦触发丢弃,说明集群已经进入自保降级状态。

    3. bookkeeper_server_GC_ACTIVE_THREADS 和 Ledger 磁盘 util%:在正常运行期如果这两个指标飙升,说明你的节点容量或者负载规划已经严重不足。

  • 深入 Apache Pulsar 延迟雪崩排查:Bookie Journal 刷盘阻塞引发的 P99 飙升与 IO 瓶颈实战

    近期排查了一起 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),直接用 dmesgiostat 摸底硬件状态:

    # 每秒打印一次 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)包含两步核心操作:

    1. Append to Journal:将数据追加到 Journal 盘(WAL),并在内存中将数据加入 MemTable。只要 Journal 刷盘(fsync)成功,Bookie 就向 Broker 返回 ACK。

    2. 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 节点后,再次观察 iostatnvme0n1(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),这叫“防御性运维”。不要指望底层存储能无限制抗住上层的不合理调用。

  • 深入 Apache Pulsar 写入雪崩排查:Journal/Ledger 磁盘混用引发的 IO 饱和与 Bookie 假死实战

    某次接手一个号称“完全按照官方最佳实践”部署的 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 阻塞与假死速查

    1. 磁盘物理拓扑核对:执行 df -hlsblk,严格对照 bookkeeper.conf 中的 journalDirectoriesledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。

    2. Journal Sync 延迟监控:紧盯 bookkeeper_journal_JOURNAL_SYNC 的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。

    3. ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有 Expired session,以及 ZK 侧是否有 Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大 zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。

    4. GC 日志与速率审查:搜索 Bookie 日志中的 GarbageCollectorThread 关键字,观察 Compaction 触发频率和耗时。确认 isThrottleByBytes 是否开启并配置了合理的阈值,防止后台合并打挂前台写入。

    5. Direct Memory 泄漏挤压 OS Cache:检查 dbStorage_directIO_entryLogger 是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。

  • 深入 Apache Pulsar 跨机房同步雪崩排查:Geo-Replication 游标阻塞引发的 Bookie ReadOnly 风暴与 Journal 夯死实战

    排查过程中最让人脑溢血的,往往不是什么惊天动地的内核 Bug,而是对基础架构的运行机制一知半解,最后被默认配置反噬。近期处理了一起极度典型的 Pulsar 跨机房同步(Geo-Replication)雪崩事故。故障现象是主可用区所有 Producer 突然大面积报 TimeoutExceptionNotEnoughBookiesException,消息写入 QPS 直接跌零。

    最终结论先行: 远端机房专线网络抖动,导致 Geo-Replication 的内置同步游标(pulsar.repl.xxx)阻塞不动。由于该 Namespace 未配置 Backlog Quota(积压配额),本地机房数据无法被垃圾回收(GC),直接将 Bookie 磁盘撑到 95% 触发 ReadOnly 模式。更致命的是,前人部署时将 BookKeeper 的 Journal 和 Ledger 目录混布在同一块磁盘上,磁盘高水位触发的底层 Compaction 动作彻底打爆了 IOPS,导致 Journal fsync 延迟飙升至 5000ms+,最终引发全局雪崩。

    不要以为“存算分离”就能包治百病,一旦连最基本的 IO 隔离和边界防御机制都不做,架构越高级,死得越难看。

    案发现场:全线熔断与诡异的 p99 延迟

    某次大促压测期间,监控大屏突然全线飘红。Pulsar 生产端的写入 P99 延迟从平时的 5ms 瞬间飙升到 5000ms 以上,紧接着大量 Producer 直接断开连接。

    去 Broker 节点抓取日志,满屏都是创建 Ledger 失败的报错:

    [pulsar-io-1-8] ERROR org.apache.pulsar.broker.service.ServerCnx - Failed to create ledger for topic persistent://tenant-a/ns-core/order-events
    org.apache.pulsar.client.api.PulsarClientException$NotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.pulsar.client.impl.ConnectionHandler.handleConnectionError(ConnectionHandler.java:123)
    

    看到 NotEnoughBookiesException,直觉告诉我底层存储层 BookKeeper 已经大规模掉线或进入了防御状态。立马切到 Bookie 节点查看状态,果然抓到了罪魁祸首:

    [bookie-io-1-1] WARN  org.apache.bookkeeper.bookie.LedgerDirsManager - Disk usage on /data/bookkeeper/ledgers is 0.96, which is above the threshold 0.95. Transitioning to ReadOnly mode.
    [bookie-io-1-1] INFO  org.apache.bookkeeper.bookie.Bookie - Bookie is running in ReadOnly mode.
    

    Bookie 的 diskUsageThreshold 默认是 0.95。一旦磁盘使用率超过 95%,Bookie 会强行把自己设置为 ReadOnly 模式,拒绝所有 addEntry(写入)请求。当集群中处于 ReadOnly 的 Bookie 数量导致无法满足 Topic 的 EnsembleSizeWriteQuorum 时,Broker 就会抛出 NotEnoughBookies

    抽丝剥茧:游标为什么不走?IO 为什么夯死?

    磁盘写满了?不应该。这批机器配了 2TB 的 SSD,按理说以当时的业务吞吐量和 TTL(配置的 3 天过期),磁盘利用率常年徘徊在 40% 左右。

    通过命令行查看涉事 Topic 的状态:

    bin/pulsar-admin topics stats persistent://tenant-a/ns-core/order-events
    

    输出结果直接揭示了问题核心:

    "replication": {
      "us-west": {
        "msgRateIn": 0.0,
        "msgRateOut": 0.0,
        "replicationBacklog": 45000000,
        "connected": false,
        "replicationDelayInSeconds": 86400
      }
    }
    

    Pulsar 的 Geo-Replication 底层其实是非常朴素的机制:它本质上是一个跨机房的特殊 Cursor(订阅游标),名字通常叫 pulsar.repl.。 只要这个游标不往前走(比如远端机房失联、专线抖动),这部分数据就会被永远 Retain(保留)在 BookKeeper 中,无论你设置的 TTL 是多少。TTL 只能清理所有游标都已经消费过的数据。

    这就解释了磁盘为什么会满。但这里还有一个极度不合理的现象:在磁盘逼近 95% 的过程中,为什么集群的读写延迟会出现剧烈抖动?

    我调出了底层系统的 iostat -x 1,发现 awaitsvctm 指标高得离谱,util 稳定 100%。再看 BookKeeper 的配置文件 bookkeeper.conf

    journalDirectory=/data/bookkeeper/shared-disk
    ledgerDirectories=/data/bookkeeper/shared-disk
    

    看到这两行配置,我血压直接上来了。把 Journal(WAL 日志,要求极低延迟的顺序写)和 Ledger(数据文件,包含大量随机读写和 Compaction 动作)放在同一个物理挂载点下,这是教科书级别的反面教材。

    当磁盘空间吃紧时,BookKeeper 的 Garbage Collector 和 Compaction 线程会疯狂启动,试图合并碎片、清理数据来腾出空间。大量的后台 IO 瞬间榨干了这块 SSD 的带宽,导致主干流程中处理 addEntry 的 Journal fsync 动作被强行阻塞。Journal 刷盘慢了,Pulsar Producer 端的延迟自然就飙升到了 5000ms,甚至超时熔断。

    止血与防御:如何彻底根除这类隐患

    处理这种烂摊子,第一步永远是先恢复服务,第二步是填补架构上的防御漏洞。

    1. 紧急止血:强行干掉阻塞的游标并清理磁盘 既然跨机房同步已经断了,且本地写盘都成问题了,果断舍弃远端同步进度。通过强制取消订阅或卸载数据,释放 Bookie 空间:

    # 临时绕过限制,先让本地可用
    bin/pulsar-admin namespaces set-retention tenant-a/ns-core --size 10G --time 1h
    
    # 如果确认远端数据可以直接丢弃同步,清理 replication backlog
    bin/pulsar-admin persistent unsubscribe persistent://tenant-a/ns-core/order-events -s "pulsar.repl.us-west"
    

    随着游标被强制推进,BookKeeper 的 GC 终于开始回收空间,磁盘使用率跌回 50%,Bookie 退出 ReadOnly 模式,集群恢复写入。

    2. 核心防御:配置 Backlog Quota(防御性编程思想在运维端的体现) 永远不要信任下游和网络。必须强制设置 Namespace 级别的积压配额。当跨机房同步或本地消费阻塞导致积压达到阈值时,直接干掉旧数据,保集群可用性。

    # 设置最大积压 50G,超过则按 producer_request_hold (阻塞生产) 或 producer_exception (抛出异常),推荐直接丢弃旧数据 consumer_backlog_eviction 保核心链路
    bin/pulsar-admin namespaces set-backlog-quota tenant-a/ns-core \
      --limit 50G \
      --policy consumer_backlog_eviction
    

    注:对于 Geo-Replication,如果网络断开,consumer_backlog_eviction 会强行推进 replication cursor,牺牲远端数据完整性来保住本地存储不被撑爆。

    3. 物理隔离:存储层 I/O 隔离 把机器停机维护,强制将 Journal 目录迁移到独立的高性能 NVMe 盘,Ledger 目录放到容量更大的普通 SSD 上。

    # bookkeeper.conf 正确姿势
    journalDirectory=/data/nvme/bookkeeper/journal
    ledgerDirectories=/data/ssd/bookkeeper/ledgers
    

    排查清单与同类问题速查

    1. Bookie ReadOnly 状态检查
    2. 现象:Broker 报 NotEnoughBookiesException
    3. 动作:检查 Bookie 日志中是否有 Transitioning to ReadOnly mode。排查 diskUsageThreshold (默认 0.95) 与 diskUsageWarnThreshold (默认 0.90) 的触发情况。

    4. 隐藏的积压游标(Cursor)排查

    5. 现象:磁盘满但实际业务消费已经最新。
    6. 动作:执行 pulsar-admin topics stats,重点检查 subscriptions 下是否有未消费完的游标,特别注意 pulsar.repl.xxx(跨机房复制游标)和 pulsar.dedup(去重游标,如果开启了消息去重)。

    7. I/O 争用与 Journal 延迟检查

    8. 现象:pulsar_storage_write_latency_le_* 指标异常,或 P99 延迟极高。
    9. 动作:通过 bookkeeper_server_ADD_ENTRY_latency 监控确认。务必检查 journalDirectoryledgerDirectories 是否挂载在不同的物理磁盘上,防止 Compaction 冲爆 Journal 的顺序写 Fsync。

    10. 防御性 Quota 配置审核

    11. 动作:所有生产环境 Namespace 必须配置 BacklogQuota。不要裸奔,没有配额限制的集群,被上游乱写或下游阻塞打爆只是时间问题。
  • 深入 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 综合评估。