跨机房 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。
登录监控面板,发现几个致命指标:
-
bookkeeper_server_ADD_ENTRY_REQUEST的 P99 延迟从 3ms 飙升至 5000ms+,随后指标断裂。 -
Broker 节点的系统 Load Average 飙升至 80+。
-
多个 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
}
}
}
原理解析:
-
游标(Cursor)在 Pulsar 中代表了消费进度。
-
只要有任何一个游标(包括本地消费游标和 replication 游标)没有 Ack,Broker 就认为这部分数据还需要被消费。
-
Broker 会将包含这些未 Ack 数据的 Ledger 标记为“使用中”。
-
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 发送消息时,流程如下:
-
消息到达 Broker 端的 Netty IO 线程,数据被写入 Netty 的 Direct ByteBuf 中。
-
Broker 将消息封装后放入待写入 Bookie 的队列中(
PendingAddOp)。 -
Broker 通过 BookKeeper Client 异步向 Bookie 写入数据(AddEntry)。
在这个事故中,Bookie 处于 ReadOnly 状态,拒绝了 Broker 的请求。Broker 捕获到 BKNotEnoughBookiesException 后,其内部逻辑会尝试进行 Ledger Rollover(即关闭当前 Ledger,重新申请一批 Bookie 创建新 Ledger)。
但在全量 Bookie 空间耗尽的情况下,Rollover 会无限次失败并重试。
此时,灾难发生了:
-
底层写入被完全阻塞。
-
客户端 Producer 仍在疯狂重试发送数据(TCP 连接未断开)。
-
Broker 端的 Netty 持续从 Socket 中读取数据并分配
Direct ByteBuf。 -
由于底层的
PendingAddOp队列得不到释放(或者释放速度远低于进入速度),导致引用计数无法清零,ByteBuf 无法被垃圾回收。 -
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来加速空间回收。