当跨机房同步遇上存储分离:一次 Pulsar BookKeeper WriteCache 背压与雪崩的底层剖析

凌晨一点半,办公室只剩敲击键盘的白噪音。监控大屏突然闪红,Pulsar 集群的 Producer P99 延迟监控曲线像被猛抽了一鞭子,从平稳的 5ms 直接飙升到了 3000ms+,部分高频写入业务开始报 ProducerSendError

切到终端,快速拉取 Broker 的指标,发现并不是 Broker 层的 GC 或网络拥塞,而是底层存储层(Bookie)的 pulsar_storage_write_latency_le 出现了严重的长尾。

在 Pulsar 的计算存储分离架构中,Broker 是无状态的路由与分发层,真正的脏活累活都在 BookKeeper。当 Bookie 的写入延迟飙升,通常意味着磁盘 IO 或者内存管线被彻底堵死了。

现场排查:冰火两重天的 IO 状态

我挑了一台延迟最高的 Bookie 节点 SSH 上去,习惯性地打出一套组合拳:

# 观察磁盘 IO 状态
iostat -x 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    0.00   85.00     0.00  1205.00    28.35     0.01    0.15    0.00    0.15   0.12   1.02%
sdb     (Ledger)  0.00     0.00 8540.00   12.00 546560.00 4500.00   128.00    32.50   18.50   18.60   12.40   0.11 100.00%

在标准的 BookKeeper 部署最佳实践中,我们将 Journal(类似 MySQL 的 Redo Log)独立部署在高吞吐低延迟的 NVMe 盘上(nvme0n1),而把 Ledger(实际的数据文件和 RocksDB 索引)放在普通 SSD 上(sdb)。

目前的现象是:Journal 盘闲得发慌(%util 1%),但 Ledger 盘的读 IO 已经被彻底打满(r/s 飙到 8500+,%util 100%)。

Journal 盘负责处理实时的写入请求(fdatasync),理论上只要 Journal 盘没满,写入就不该阻塞。那为什么 Broker 端感受到了巨大的写入延迟?

顺藤摸瓜:是谁在疯狂读取?

Ledger 盘被海量的读请求击穿,只有两种可能:一是某个消费者在大量回溯历史消息(Catch-up Read);二是集群在做数据均衡或者副本修复。

通过 pulsar-admin 查看集群整体状态,我锁定了罪魁祸首:

pulsar-admin topics stats-internal tenant-a/namespace-1/topic-x

在一堆 JSON 输出中,我看到了 replication 节点的异常:

"replication" : {
  "cluster-b" : {
    "msgThroughputOut" : 52428800.0,
    "msgRateOut" : 12500.0,
    "replicationBacklog" : 15800450,
    "connected" : true,
    "replicationDelayInSeconds" : 3600
  }
}

真相浮出水面:租户 A 配置了 Geo-Replication,将数据跨地域异步复制到 cluster-b。一小时前,跨机房的专线出现了短暂的物理网络抖动,导致复制链路断开。网络恢复后,Broker 侧的 Replication Cursor 开始疯狂地向后追平这 1500 万条积压数据。

这股突发的冷数据读取洪流,直接打穿了 Bookie 的 ReadAheadCache,穿透到了底层的 Ledger 磁盘。

深度解析:Ledger 读风暴如何引发 Journal 写阻塞?

这似乎是个多租户隔离失效的经典案例:一个租户的跨机房冷读,影响了全局的实时热写。但在计算分离架构下,Journal 和 Ledger 盘是物理隔离的,读写究竟在哪一层发生了交叉碰撞?

这就必须深入到 BookKeeper 的 DbLedgerStorage 底层管线。

在 Bookie 中,一条 Message 的写入路径(AddEntry)如下:

  1. 请求进入 Bookie 的 Netty 线程,分发给 SyncThread

  2. 写 WAL:追加到 Journal 内存队列,由单独的 Journal 线程 fdatasync 到 NVMe 盘。

  3. 写缓存:同时将数据插入到内存中的 WriteCache

  4. Journal 落盘且 WriteCache 插入成功后,向 Broker 返回 ACK。

这里的关键在第 3 步和后续的异步刷盘机制。

内存中的 WriteCache 是有容量上限的(由 dbStorage_writeCacheMaxSizeMb 控制,默认通常是系统内存的 1/4)。当 WriteCache 写满时,会触发 Flush 动作:

  • 将数据序列化并追加到 Ledger 磁盘的 EntryLog 文件中。

  • 将 Entry 的位置索引信息(LedgerId, EntryId -> EntryLogId, Offset)写入 RocksDB。

如果此时 Ledger 磁盘正在被 Geo-Replication 的海量随机读(追冷数据)严重占用,IOPS 饱和,导致 Flush 操作极其缓慢。

那么连锁反应来了:

  1. Flush 变慢,WriteCache 内存无法及时释放。

  2. 前端高频的热点写入继续涌入,瞬间填满剩余的 WriteCache

  3. WriteCache 一旦满了,新的 AddEntry 请求在尝试插入 WriteCache 时,就会被同步阻塞(Backpressure)。

  4. Netty 工作线程被挂起,无法处理新的网络请求,最终导致请求在队列中超时,抛出 Transaction timeout 异常,Broker 端观察到的 P99 延迟直接原地起飞。

这就是为什么 Journal 盘空闲,但写入依然被卡死的根本原因。资源在物理磁盘上是隔离的,但在内存管线(WriteCache)和存储引擎(DbLedgerStorage)上却存在强耦合。

现场止血与架构调优

既然找到了症结在于 Geo-Replication 产生的读取洪流没有被限流,破坏了底层存储的 IO 节奏,止血方案就非常明确了。

1. 动态下发 Dispatch 限流策略(止血)

Pulsar 提供了灵活的多租户资源隔离能力,我立即通过 pulsar-admin 对租户 A 的特定 Namespace 下发了流量 Dispatch 限制,掐断它的读取速率,给磁盘留出喘息的空间。

# 限制该 namespace 的 Dispatch 速率为 50MB/s,或者 10000 msg/s
pulsar-admin namespaces set-dispatch-rate tenant-a/namespace-1 \
  --byte-dispatch-rate 52428800 \
  --dispatch-rate 10000

# 限制跨机房 Replication 的读取速率(关键配置)
pulsar-admin namespaces set-replicator-dispatch-rate tenant-a/namespace-1 \
  --byte-dispatch-rate 20971520 \
  --dispatch-rate 5000

指令下发后大约 10 秒,iostatsdb%util 开始回落到 60% 左右。Bookie 的 WriteCache 终于能够顺畅地 Flush 到 Ledger 盘,积压的 AddEntry 队列迅速清空,集群 P99 延迟恢复到 5ms。

2. 底层 BookKeeper 参数调优(治本)

为了防止未来其他租户再次触发这种边缘场景引发雪崩,需要对 BookKeeper 的底层配置进行更严谨的调校。

调整 WriteCache 与 ReadAheadCache 的配比 默认情况下,读写缓存的分配可能并不适合重度冷读积压的场景。在 bookkeeper.conf 中显式隔离并调整内存屏障:

# 强制开启 DbLedgerStorage
ledgerStorageClass=org.apache.bookkeeper.bookie.storage.ldb.DbLedgerStorage

# 增加 WriteCache 的比例,提供更大的缓冲池来吸收底层的抖动
dbStorage_writeCacheMaxSizeMb=4096

# 限制 ReadAhead 的内存使用,防止冷数据污染导致 OOM 或频繁的 GC
dbStorage_readAheadCacheMaxSizeMb=2048

# 分离 RocksDB 的读写 BlockCache
dbStorage_rocksDB_blockCacheSize=1073741824

操作系统层面的 IO 提示优化 Geo-Replication 回溯历史数据时,本质上是对 Ledger 文件的顺序读。我们可以通过调整内核的 Read-Ahead 大小,减少底层的 IO 次数,提升吞吐:

# 将 Ledger 盘 sdb 的预读设置为 4096 个扇区 (2MB)
blockdev --setra 4096 /dev/sdb

3. 隔离 Read/Write 线程池

BookKeeper 中可以通过配置将读和写的处理线程池完全拆开,避免冷读耗尽处理线程资源导致心跳或写入响应不及时:

# 开启独立的读线程池
numReadWorkerThreads=16
numAddWorkerThreads=16
numHighPriorityWorkerThreads=8

总结

计算存储分离的架构(如 Pulsar + BookKeeper)在理论上提供了极好的扩展性,但在实际的运维战场上,资源的边界往往比想象中更加模糊。

在这个场景中,跨地域高延迟网络抖动触发了 Broker 端的异步补偿(Geo-Replication Catch-up),补偿机制转化为海量的吞吐导致底层的存储读引擎击穿,进而在 WriteCache 内存模型处形成了反向的写背压(Backpressure),最终导致了全局写入的雪崩。

多租户与存储分离,不是把服务拆开部署就万事大吉了。真正的能力体现在从计算侧的配额下发、网络层的隔离、到存储引擎侧 IO 管线的严格切分。只有把 QOS(服务质量限制)像烙印一样打在每一个数据流转的节点上,才能在复杂的生产环境中睡个安稳觉。