标签: 分布式存储

  • 深入 Apache Pulsar 陷阱排查:跨机房 Geo-Replication 积压触发 Bookie 只读模式与 Broker OOM 雪崩实战

    跨机房 Geo-Replication 网络中断会导致复制游标(Replication Cursor)长期滞留,阻塞 BookKeeper 底层 Ledger 的垃圾回收(GC)。当 Ledger 磁盘使用率触及 95% 阈值,Bookie 会强制降级为 ReadOnly 模式,进而引发 Broker 端写入重试风暴与 Netty 堆外内存(Direct Memory)耗尽,最终导致集群全面瘫痪。破局关键在于阻断游标对物理存储的无限期绑架,配置 backlog 驱逐策略并分离 Journal/Ledger 磁盘。

    排查过程基于 Apache Pulsar 2.10.4 与 BookKeeper 4.14.6。

    现场还原:QPS 闪跌与堆外内存溢出

    某次生产环境告警,主数据中心的 Pulsar 集群写入 QPS 在 5 分钟内从 45,000 骤降至 0。Producer 端大量抛出 java.util.concurrent.TimeoutException。

    登录监控面板,发现几个致命指标:

    1. bookkeeper_server_ADD_ENTRY_REQUEST 的 P99 延迟从 3ms 飙升至 5000ms+,随后指标断裂。

    2. Broker 节点的系统 Load Average 飙升至 80+。

    3. 多个 Broker 容器由于 OOMKilled 被 K8S 驱逐重启。

    直奔 Broker 核心日志(/pulsar/logs/pulsar-broker.log),抓到两个关键报错。

    首先是 BookKeeper 客户端无法写入的异常:

    org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.bookkeeper.client.EnsemblePlacementPolicy.newEnsemble(EnsemblePlacementPolicy.java:152)
        at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:125)
    

    紧接着是 Broker 进程崩溃的直接原因——Netty 堆外内存泄露:

    io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 4294967296, max: 4294967296)
        at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:771)
        at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:374)
    

    再看 Bookie 节点(/pulsar/logs/bookkeeper.log),发现了灾难的源头:

    [GarbageCollectorThread-43-1] WARN  org.apache.bookkeeper.bookie.LedgerDirsManager - LedgerDirs disk usage 0.965 > threshold 0.95, state transition to ReadOnly mode
    [main] INFO  org.apache.bookkeeper.bookie.Bookie - Bookie is running in ReadOnly mode.
    

    Bookie 磁盘满了,变成了只读模式。由于集群默认配置了 EnsembleSize=3, WriteQuorum=3, AckQuorum=2,当集群中超过 N 个 Bookie 变成只读后,Broker 无法找到足够的可用 Bookie 来创建新的 Ledger,导致写入链路彻底阻断。

    为什么跨机房网络抖动会撑爆本地集群的 BookKeeper 磁盘?

    Pulsar 吹捧的“计算与存储分离”架构在这个场景下展现了其脆弱的一面。Broker 负责计算和状态管理,Bookie 负责纯粹的追加写存储。这两者之间通过 ZooKeeper(或 Metadata Store)中的 Ledger 元数据进行桥接。

    在 Pulsar 的 Geo-Replication 机制中,主集群 Broker 会为每一个配置了跨地域复制的 Topic 隐式创建一个特殊的持久化游标,名称通常为 pulsar.repl.。

    我们通过命令行查看出事 Topic 的内部状态:

    $ pulsar-admin topics stats persistent://public/default/orders-topic
    

    输出片段如下:

    {
      "publishers": [...],
      "subscriptions": {
        "my-local-sub": {
          "msgBacklog": 120
        }
      },
      "replication": {
        "us-east-cluster": {
          "msgBacklog": 185943022, 
          "connected": false,
          "replicationDelayInSeconds": 86400
        }
      }
    }
    

    原理解析:

    1. 游标(Cursor)在 Pulsar 中代表了消费进度。

    2. 只要有任何一个游标(包括本地消费游标和 replication 游标)没有 Ack,Broker 就认为这部分数据还需要被消费。

    3. Broker 会将包含这些未 Ack 数据的 Ledger 标记为“使用中”。

    4. Bookie 内部有一个 GarbageCollectorThread,它会定期扫描 ZK 中的元数据。如果发现 Ledger 还在被 Broker 引用,就不会执行物理删除。

    近期由于跨国专线光纤故障,导致主集群到 us-east-cluster 的网络中断了 24 小时。Replication 游标 connected: false,导致近两亿条消息积压。这些积压强行“绑架”了底层的物理 Ledger,导致 BookKeeper 的 GC 线程形同虚设,旧数据无法清理,最终打满 Ledger 磁盘,触发 diskUsageThreshold=0.95 阈值,Bookie 强制切换为 ReadOnly。

    深入底层:Bookie 只读模式如何引发 Broker OOM?

    磁盘满了拒绝写入是可以理解的,但为什么 Broker 会发生 OutOfDirectMemoryError?这就涉及到 Pulsar 的内存池管理机制和高并发下的反压(Backpressure)缺陷。

    当 Producer 向 Broker 发送消息时,流程如下:

    1. 消息到达 Broker 端的 Netty IO 线程,数据被写入 Netty 的 Direct ByteBuf 中。

    2. Broker 将消息封装后放入待写入 Bookie 的队列中(PendingAddOp)。

    3. Broker 通过 BookKeeper Client 异步向 Bookie 写入数据(AddEntry)。

    在这个事故中,Bookie 处于 ReadOnly 状态,拒绝了 Broker 的请求。Broker 捕获到 BKNotEnoughBookiesException 后,其内部逻辑会尝试进行 Ledger Rollover(即关闭当前 Ledger,重新申请一批 Bookie 创建新 Ledger)。 但在全量 Bookie 空间耗尽的情况下,Rollover 会无限次失败并重试。

    此时,灾难发生了:

    1. 底层写入被完全阻塞。

    2. 客户端 Producer 仍在疯狂重试发送数据(TCP 连接未断开)。

    3. Broker 端的 Netty 持续从 Socket 中读取数据并分配 Direct ByteBuf。

    4. 由于底层的 PendingAddOp 队列得不到释放(或者释放速度远低于进入速度),导致引用计数无法清零,ByteBuf 无法被垃圾回收。

    5. JVM 参数 -XX:MaxDirectMemorySize=4g 被瞬间吃满,Broker 进程彻底僵死并触发 OOMKilled。

    防御性配置与最佳实践

    针对这种“远程网络抖动拖死本地存储与计算节点”的连环雪崩,必须在 Pulsar 架构的各个层级实施防御性隔离。

    1. 阻断无限期积压:强制 Backlog Quota 驱逐

    绝对不能允许游标无限期地保留数据,哪怕是核心业务数据。必须通过 Namespace 级别的 Backlog Quota 来兜底。

    # 为 namespace 设置最大 backlog,超过 50GB 后自动推进游标,丢弃最老的数据
    $ pulsar-admin namespaces set-backlog-quota public/default \
      -l 50G \
      -p consumer_backlog_eviction
    

    注:对于不能丢数据的金融场景,策略可以设为 producer_request_hold(拒绝 Producer 写入),这样可以在本地集群层面迅速失败,而不是等待 Broker OOM 后引发更大规模的无差别雪崩。

    此外,建议配置强制的 TTL(Time-To-Live),确保超出一定时间的数据游标无论如何都会被向前推进:

    # 强制 3 天未确认的消息也会被过期并允许 GC
    $ pulsar-admin namespaces set-message-ttl public/default -ttl 259200
    

    2. Bookie 存储架构级隔离:Journal 与 Ledger 分离

    Bookie 有两种核心写动作:WAL (Journal) 保证持久性,Ledger 保证按块存储。如果这两种数据混在一个磁盘上,Ledger 空间满不仅会导致 ReadOnly,还会因为 IOPS 争抢导致 Journal fsync 阻塞,让 AddEntry 延迟毛刺频发。

    必须在 bookkeeper.conf 中分离物理磁盘:

    # 使用高 IOPS 的 NVMe 固态硬盘专供 WAL
    journalDirectories=/data/bookkeeper/journal
    journalSyncData=true
    
    # 使用大容量 HDD / 普通 SSD 用于持久化数据
    ledgerDirectories=/data/bookkeeper/ledgers
    
    # 可选:适当调低 ReadOnly 触发阈值,给运维介入留出缓冲
    diskUsageThreshold=0.90
    

    3. Broker 端内存反压兜底

    在 broker.conf 中严格限制 Broker 最大可使用的堆外内存,并在达到阈值时暂停读取新连接的数据:

    # 限制最大的未确认堆外内存总量(比如限制在物理机/容器内存的极小部分)
    maxMessageSize=5242880
    # 开启直接内存的严格限制(结合 JVM 启动参数 -XX:MaxDirectMemorySize)
    bookkeeperClientTimeoutInSeconds=30
    

    确保 Broker JVM 的配置:-XX:MaxDirectMemorySize 不能超过容器 Limit 的 50%,预留足够的物理内存给操作系统 PageCache。

    常见问题 (FAQ)

    Q1:如何快速从 Bookie 只读模式 (ReadOnly) 中恢复?

    Bookie 变为 ReadOnly 的根本原因是物理磁盘使用率超限。快速恢复步骤: 1. 在 Broker 端执行 pulsar-admin topics clear-backlog 或者修改 TTL 强制废弃积压游标。 2. 游标推进后,立刻触发 Bookie 手动 GC:bin/bookkeeper shell gc。 3. GC 清理掉废弃 Ledger 后,磁盘水位下降到 diskUsageWarnThreshold(默认 0.90)以下,Bookie 会自动恢复可写状态,无需重启。

    Q2:多租户场景下,某个业务的 Topic 积压会影响其他业务吗?

    会。虽然 Pulsar 支持多租户逻辑隔离,但底层 BookKeeper 集群的磁盘和 IO 资源是共享池(除非配置了严格的 Bookie Affinity / Isolation Policy)。如果租户 A 的积压导致所有 Bookie 磁盘写满进入 ReadOnly,租户 B 的写入也会立刻失败。因此,必须为每个租户配置严格的 Backlog Quota。

    Q3:为什么有时候通过命令删除了 Topic 的游标,Bookie 磁盘空间还是没有释放?

    Pulsar 具有一种延迟删除机制以及 Compaction 数据。首先确认 pulsar-admin topics stats 中是否还有其他游标(包含系统内部游标)。其次,BookKeeper 的 GarbageCollectorThread 默认运行间隔(gcWaitTime)为 15 分钟,且清理 EntryLog 文件时,只有当文件中的有效数据(Active entries)占比极低时才会进行 Compaction 重写并删除老文件。可以通过调整 minorCompactionThreshold 和 majorCompactionThreshold 来加速空间回收。

  • 深入 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%:在正常运行期如果这两个指标飙升,说明你的节点容量或者负载规划已经严重不足。