凌晨一点半,办公室只剩敲击键盘的白噪音。监控大屏突然闪红,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)如下:
-
请求进入 Bookie 的 Netty 线程,分发给
SyncThread。 -
写 WAL:追加到 Journal 内存队列,由单独的 Journal 线程
fdatasync到 NVMe 盘。 -
写缓存:同时将数据插入到内存中的
WriteCache。 -
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 操作极其缓慢。
那么连锁反应来了:
-
Flush 变慢,
WriteCache内存无法及时释放。 -
前端高频的热点写入继续涌入,瞬间填满剩余的
WriteCache。 -
WriteCache一旦满了,新的AddEntry请求在尝试插入WriteCache时,就会被同步阻塞(Backpressure)。 -
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 秒,iostat 中 sdb 的 %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(服务质量限制)像烙印一样打在每一个数据流转的节点上,才能在复杂的生产环境中睡个安稳觉。