分类: 架构与运维实战

  • 深入 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 游标阻塞引发的 Bookie ReadOnly 风暴与 Journal 夯死实战

    排查过程中最让人脑溢血的,往往不是什么惊天动地的内核 Bug,而是对基础架构的运行机制一知半解,最后被默认配置反噬。近期处理了一起极度典型的 Pulsar 跨机房同步(Geo-Replication)雪崩事故。故障现象是主可用区所有 Producer 突然大面积报 TimeoutException 和 NotEnoughBookiesException,消息写入 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 的 EnsembleSize 和 WriteQuorum 时,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,发现 await 和 svctm 指标高得离谱,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 监控确认。务必检查 journalDirectory 和 ledgerDirectories 是否挂载在不同的物理磁盘上,防止 Compaction 冲爆 Journal 的顺序写 Fsync。

    10. 防御性 Quota 配置审核

    11. 动作:所有生产环境 Namespace 必须配置 BacklogQuota。不要裸奔,没有配额限制的集群,被上游乱写或下游阻塞打爆只是时间问题。