深入 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 来加速空间回收。