标签: 消息队列

  • 深入 RocketMQ 陷阱排查:海量消费重试引发的 CommitLog 随机读风暴与 PageCache 抖动实战

    核心结论:RocketMQ 的混合存储模型在海量消费重试与冷数据积压场景下,会打破 CommitLog 的顺序读写假象。当随机读导致 OS PageCache 命中率暴跌时,频繁的 Major Page Fault 会打满磁盘 IO,直接引发 [PCBUSY_CLEAN_QUEUE] Broker busy 拒绝服务。破局点在于开启 transientStorePoolEnable 实现读写内存隔离,调优 accessMessageInMemoryMaxRatio 强制冷读下沉至 Slave,并在 OS 层优化 PageCache 淘汰策略。

    故障现场:Broker 陷入假死与 PCBUSY 风暴

    某次排查过程中,生产环境核心 RocketMQ 集群(版本 4.9.4,部署于 CentOS 7,内核 3.10.0-1160.el7)突发大面积消息发送超时。客户端日志被 MQBrokerException: CODE: 2 DESC: [PCBUSY_CLEAN_QUEUE]broker busy 刷屏。

    登录 Broker 节点,系统 Load Average 飙升至 40+,但 CPU 使用率不到 30%。直觉告诉我,IO 瓶颈出现了。

    拉起 iostat 查看底层设备状态:

    iostat -dx 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           0.00     0.00 4850.00  120.00 75800.00 12400.00    35.45    25.80    5.15    4.90   15.25   0.20 100.00
    

    磁盘 %util 长期处于 100%,且读操作 r/s 远高于写操作 w/s。 通过 pidstat -d -p 1 确认,高频发起的读 IO 全部来自 RocketMQ 进程。但这极不合理:RocketMQ 的核心优势就是 CommitLog 顺序写与 PageCache 带来的无盘读,为什么会出现如此密集的物理磁盘读取?

    查看 RocketMQ store.log 发现内存命中率暴跌:

    [PAGECACHERT] TotalPut 120543, PutOK 120500, PutRT > 1s 43
    [PAGECACHERT] PageCache hit ratio: 35.2%
    

    为什么海量重试会导致 CommitLog 退化为随机读?

    RocketMQ 的所有 Topic 数据混合写入同一个 CommitLog,这保证了极致的写入吞吐。消费时,通过 ConsumeQueue 进行逻辑定位,再回查 CommitLog 读取实际消息。

    正常情况下,生产者追加最新消息,消费者紧跟其后拉取,读取的 Offset 都在 OS PageCache 的热数据区间内。但当业务出现大面积异常,触发海量消费重试时,存储引擎的灾难就开始了:

    1. 重试队列的跳跃性读取:RocketMQ 的重试消息会被放入 %RETRY%ConsumerGroup Topic,并按延迟级别转入 SCHEDULE_TOPIC_XXXX。这些延迟消息在到期后,需要从历史 CommitLog 文件中被捞出并投递。

    2. 冷热数据交替污染 PageCache:大量延迟到期的重试消息分布在几小时甚至几天前的 CommitLog 中(冷数据)。消费者同时拉取实时热消息与重试冷消息。

    3. OS 层面的驱逐:Linux 内核通过 LRU 算法(Active/Inactive List)管理 PageCache。当 Broker 通过 mmap 读取冷数据时,触发缺页中断(Major Page Fault),内核被迫从磁盘读取数据到内存,并把热数据挤出 PageCache。

    4. 锁争用雪崩:RocketMQ 写入消息时需要获取 putMessageLock。当 PageCache 处于高压抖动状态时,mmap 内存映射区的写入或 fsync 刷盘耗时突增。一旦持锁时间超过 osPageCacheBusyTimeOutMills(默认 1000ms),Broker 就会立刻触发快速失败,抛出 PCBUSY_CLEAN_QUEUE。

    深度排查与调优实战

    为了彻底解决这一设计层面的物理瓶颈,我们需要从 Broker 配置与 OS 内核两端进行防御性加固。

    1. 开启 TransientStorePool 读写隔离

    默认情况下,RocketMQ 的读写都操作同一个 MappedByteBuffer(即 PageCache)。在随机读风暴下,写操作极易被阻塞。对于采用异步刷盘(ASYNC_FLUSH)的集群,必须开启 transientStorePoolEnable。

    修改 broker.conf:

    # 开启堆外内存池
    transientStorePoolEnable=true
    # 异步刷盘模式
    flushDiskType=ASYNC_FLUSH
    

    底层原理:开启后,RocketMQ 预分配一块 DirectByteBuffer 池。消息写入时直接进入 DirectByteBuffer(纯内存操作,无锁无缺页中断),然后由后台线程异步 commit 到 MappedByteBuffer 并 flush 到磁盘。消费时的读取仍走 MappedByteBuffer。这样彻底解耦了写入链路与 PageCache,即使发生缺页风暴,也不会阻塞 putMessage。

    2. 强制冷数据读取下沉至 Slave

    既然无法避免业务重试带来的冷读,就要把冷读的爆炸半径控制在从节点。通过调优内存水位阈值,强制拉取冷数据请求重定向给 Slave。

    # 允许从 Slave 读取数据
    slaveReadEnable=true
    # 当物理内存中消息容量占比超过 30% 时,认为读取的是冷数据,重定向至 Slave
    accessMessageInMemoryMaxRatio=30
    

    判定逻辑:在 DefaultMessageStore#getMessage 中,Broker 会计算当前读取的 MaxOffset 与被拉取 Offset 的差值。如果差值对应的物理文件大小超过了系统可用物理内存的 30%(即预估该数据不在 PageCache 中),就会给客户端返回 PullResult.SUGGEST_PULL_FROM_SLAVE。客户端下一次 Pull 会自动路由到同 Broker Name 的 Slave 节点,从而保护 Master 的 IO。

    3. Linux 内核 PageCache 防御性参数调优

    默认的 Linux 内核参数对高吞吐的数据库/中间件极不友好,必须调整脏页回写与内存分配策略,防止内核在缺页中断时发生长时间停顿。

    编辑 /etc/sysctl.conf:

    # 提高后台异步刷盘触发时机,平滑 IO 突刺
    vm.dirty_background_ratio = 5
    # 降低同步刷盘阈值,防止一次性刷入海量脏页卡死系统
    vm.dirty_ratio = 15
    # 增加保留空闲内存,防止发生直接内存回收(Direct Reclaim)导致的严重卡顿
    vm.min_free_kbytes = 1048576
    # 倾向于回收文件缓存而不是 swap anonymous pages
    vm.swappiness = 1
    # 禁用 Zone Reclaim 模式,防止 NUMA 架构下无谓的内存回收
    vm.zone_reclaim_mode = 0
    

    执行 sysctl -p 生效。通过提高 min_free_kbytes 到 1GB,可以给内核缓冲更多时间进行 kswapd 异步回收,避免触发代价极其高昂的同步回收。

    常见问题 (FAQ)

    Q1:开启 transientStorePoolEnable 会导致数据丢失吗? 会增加一定的风险。普通异步刷盘时,数据只要写入 PageCache,即便 JVM 崩溃(进程死亡),只要 OS 不宕机数据依然存在。但开启 transientStorePoolEnable 后,最新数据暂存在 DirectByteBuffer(JVM 进程空间),一旦 Broker 进程 OOM 或被 kill -9 强杀,未 commit 到 PageCache 的数据将会丢失。因此它仅适用于容忍极端情况下微量数据丢失且追求极致吞吐的业务。

    Q2:出现 [TIMEOUT_CLEAN_QUEUE] broker busy 和 [PCBUSY_CLEAN_QUEUE] 有什么区别? 两者都是由于 Broker 处理慢触发了自我保护。TIMEOUT_CLEAN_QUEUE 表示请求在 Broker 的 SendThreadPool 队列中排队等待处理的时间超过了设定的阈值(默认 200ms)。而 PCBUSY 则是指实际执行 CommitLog.putMessage 时,获取互斥锁的耗时超过了 1000ms(往往是底层 IO 卡顿导致)。前者是队列积压,后者是底层引擎阻塞。

    Q3:RocketMQ 5.x 的 Tiered Storage(分级存储)能解决这个问题吗? 能从根本上解决。RocketMQ 5.x 引入了冷热数据分离的架构。当数据超过一定时间后,会自动上传到 S3 或低成本 HDD 集群。客户端拉取冷数据(如延时重试消息)时,Broker 直接通过底层分级存储接口提供服务,不再读取本地 CommitLog,彻底切断了冷数据对热点 PageCache 的污染路径。

  • 深入 RabbitMQ 陷阱排查:滥用双向 Shovel 引发的环路风暴与全局水位阻塞实战

    某次核心支付系统的异步回调链路突发大面积超时,API 网关 99 线从 50ms 直接飙升至 30s 并伴随大量 504 Gateway Timeout。排查结论令人啼笑皆非:某位业务开发为了实现所谓的“跨机房双活容灾”,在没有任何路由防环设计的情况下,通过 RabbitMQ 管理控制台手动配置了双向 Shovel 插件。结果导致消息在两个集群间形成无限死循环复制,瞬间产生的消息风暴击穿了节点内存,触发了 Erlang VM 的 vm_memory_high_watermark 告警,底层的 TCP 背压(Backpressure)机制直接将所有 Producer 的 Connection 强行置为 blocking 状态,最终引发了波及全业务线的全局雪崩。

    不要把消息队列当成可以随意拉线的网络集线器,在没有深刻理解 AMQP 路由拓扑和底层流控机制前,任何“高可用”架构的尝试都无异于自掘坟墓。

    案发现场:全线假死与消失的吞吐量

    故障发生时,监控大盘上呈现出极其诡异的景象:

    1. QPS 归零:业务网关请求堆积,RabbitMQ 集群的 Inbound 流量在经历了几秒钟的垂直飙升后,瞬间掉底为 0。

    2. CPU 与 Load 暴增:宿主机 Load Average 飙升至 80+,epmd 和 beam.smp 进程 CPU 占用率满载。

    3. 海量 Connection 被 Block:应用侧疯狂打印 java.util.concurrent.TimeoutException。

    登录 RabbitMQ 节点,敲下排查命令,惨烈的情况一览无余:

    # 查看当前连接状态,发现大量连接处于 blocking 或 blocked 状态
    $ rabbitmqctl list_connections pid name port state | awk '{print $4}' | sort | uniq -c
        152 running
       2048 blocking
        512 blocked
    
    # 查看资源告警状态
    $ rabbitmq-diagnostics alarms
    Alarms on node rabbit@mq-node-01:
    [x] memory alarm: true (Memory high watermark set to 0.4. Current usage: 14.2 GB / 32 GB)
    

    查看核心日志 /var/log/rabbitmq/[email protected],满屏的红色警告:

    202X-XX-XX 14:05:12.123 [warning] <0.1453.0> memory resource limit alarm set on node rabbit@mq-node-01.
    202X-XX-XX 14:05:12.124 [info] <0.1455.0> blocking connection <0.2312.0> (10.0.5.12:45123 -> 10.0.2.10:5672)
    202X-XX-XX 14:05:12.124 [info] <0.1455.0> blocking connection <0.2313.0> (10.0.5.13:42123 -> 10.0.2.10:5672)
    ...
    

    很明显,Erlang VM 的内存使用率超过了设定的阈值(默认 40%),RabbitMQ 启动了极端的自我保护机制:全局内存告警阻塞。

    拨开迷雾:愚蠢的“跨机房双活”拓扑

    RabbitMQ 的 vm_memory_high_watermark 触发后,所有发布消息(Publish)的连接都会被底层的 TCP 层面挂起。这不是针对单个 VHost 或 Queue 的限制,而是全局核武级别的熔断,只要连在这个节点上发消息的 Client,全部都要死。

    是什么打爆了内存? 通过 rabbitmqctl list_queues name messages memory 发现,两个机房的核心 Topic Exchange 下绑定的队列消息堆积量在以每秒数十万的速度递增。

    进一步排查拓扑配置,真相大白。业务侧通过 Shovel 插件做了如下配置:

    • 机房 A (Shovel-A): Source: Exchange 'pay.topic' (RoutingKey: '#') -> Dest: URI of DC-B / Exchange 'pay.topic'

    • 机房 B (Shovel-B): Source: Exchange 'pay.topic' (RoutingKey: '#') -> Dest: URI of DC-A / Exchange 'pay.topic'

    这就是典型的“无脑双向复制”引发的广播风暴。

    AMQP 协议中的 Shovel 本质上是一个运行在 Erlang VM 内部的客户端。它在源端执行 basic.consume,在目的端执行 basic.publish。 当一条路由键为 pay.success 的消息在机房 A 产生时:

    1. 机房 A 的 Exchange 将其路由到本地队列,同时 Shovel-A 将其拉取。

    2. Shovel-A 将该消息 basic.publish 到机房 B 的 pay.topic。

    3. 机房 B 的 Exchange 接收到消息,不仅路由给 B 的本地队列,同时被 Shovel-B 捕获。

    4. Shovel-B 再次将其发回给机房 A…

    一条消息在毫秒级内变成了几万条,呈指数级放大,瞬间榨干网络带宽并击穿了 14GB 的内存水位。

    为什么说这个错误不可原谅?

    如果是单纯为了做高可用和跨集群复制,官方早就提供了 Federation 插件。为什么 Federation 不会环路而 Shovel 会?这是协议层设计的降维打击。

    Federation 插件在跨节点投递消息时,会在 AMQP Header 中注入 x-received-from 属性。 当机房 B 的 Federation 收到来自机房 A 的消息时,检查 Header 发现这条消息曾经来过,或者达到了配置的 max_hops 阈值,就会直接丢弃,从根源上阻断了环路。

    而该业务团队因为“嫌 Federation 配置策略复杂,Shovel 看起来就像个搬运工比较简单”,直接用了 Shovel。要知道,Shovel 是无状态的,它不管消息从哪里来,只负责傻瓜式地搬运,根本没有防环机制。更要命的是,他们在 Topic 匹配上用了最暴力的 #,将整条业务线推向了深渊。

    破局与防御性架构落地

    应急恢复非常粗暴:

    1. 立刻通过 CLI 强制删除双向的 Shovel 链路:rabbitmqctl clear_parameter -p / shovel shovel-a。

    2. 执行 rabbitmqctl purge_queue 清空由于环路产生的海量垃圾消息,让内存水位降至 0.4 以下。

    3. 观察 alarm 解除,TCP 连接恢复 running 状态,业务网关自动重连恢复。

    针对此类惨案,运维和架构层面必须落地以下防御性策略:

    1. 废弃控制台 ClickOps,收归配置权限: 禁止任何人通过 Management UI 手动拉取跨机房链路。所有的 Shovel/Federation Policy、Exchange、Binding 配置,必须通过 Terraform 或 Ansible 以 IaC(基础设施即代码)的形式进入 GitOps 流程,强制进行拓扑评审。

    2. 正确使用高可用组件: 跨集群双活/复制,首选 Federation,并严格配置 max-hops = 1。如果非要用 Shovel,路由键必须加上机房前缀(如 dc-a.pay.#),并且 Shovel 目的端只允许写入带有特定后缀的隔离 Exchange。

    3. 多租户与 VHost 物理隔离: 所有核心业务线必须拆分物理集群,至少也要做到 VHost 级别的隔离,并对每个 VHost 限制 max-length 和 max-length-bytes,防止单一野鸡业务把全局水位打爆。

    排查清单:RabbitMQ 内存阻塞与环路问题速查

    1. 确认全局资源告警阻塞 (TCP Backpressure) rabbitmq-diagnostics alarms 如果存在 memory alarm: true 或 disk_free alarm: true,说明 Broker 已启动自我保护,所有发布消息的 Connection 已被挂起(State: blocking/blocked)。

    2. 快速定位堆积/异常队列 rabbitmqctl list_queues name messages memory message_bytes | sort -k4 -nr | head -n 10 查出占用内存或消息体总和最大的 Top 10 队列,如果是极短时间内暴增,高度疑似环路风暴。

    3. 排查 Shovel / Federation 配置状态 rabbitmqctl list_parameters -p [vhost] 检查是否存在双向配置的参数。对于 Federation,检查 rabbitmqctl federation_status 的链路是否有报错。

    4. 验证连接状态统计 rabbitmqctl list_connections state | grep -c blocking 当出现大量 blocking 连接时,切勿盲目重启应用,需优先解决 MQ 服务端的资源水位问题,否则应用重启后仍会卡死在建立 AMQP Channel 的握手阶段。

  • 深入 Apache Pulsar 跨机房雪崩排查:Geo-Replication 断连引发的 Cursor 堆积与 BookKeeper GC 死亡螺旋实战

    某次跨地域容灾演练引发断网,Pulsar 集群 P99 写入延迟飙升至 5s。核心结论:Geo-Replication 复制链路断开导致 Replicator Cursor 停止推进,底层 BookKeeper Ledger 无法回收,磁盘满载后触发极端激进的 EntryLog Compaction,将 Ledger 盘 IOPS 彻底打满,导致 Write Cache 无法 Flush,最终阻塞写入。解决方案为配置 Backlog Quota 降级策略并隔离 GC IO。

    事故现场与指标异动

    排查过程中,监控大盘发出严重告警。集群(Pulsar 2.10.4, BookKeeper 4.14.7)在地域 A(主)和地域 B(备)配置了 Geo-Replication。地域 B 的专线网络模拟切断 30 分钟后,地域 A 的本地生产者全部收到 ProduceTimeout 异常。

    查看监控指标,发现以下几个异常:

    1. Broker 侧:pulsar_broker_publish_latency P99 从 5ms 突增到 5000ms+。

    2. Broker 侧:pulsar_replication_backlog 持续线性增长,Replicator 处于断开状态。

    3. Bookie 侧:Journal 盘(NVMe SSD)的 iostat 正常,但 Ledger 数据盘(普通 SSD)的 util% 持续 100%。

    4. Bookie 侧:Write Cache 满载,bookkeeper_server_ADD_ENTRY_QUEUE_SIZE 严重积压。

    登录其中一台 Bookie 节点,直接抓取磁盘 IO 现场:

    # iostat -xz 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  120.00  450.00  1536.00  4096.00    19.78     0.12    0.21    0.15    0.23   0.10   5.70
    sda (Ledger)      0.00     0.00 4850.00 2100.00 65536.00 28672.00    27.11    34.50   14.20   12.10   19.05   0.14  100.00
    

    可以看到,负责同步刷盘的 Journal 毫无压力,但负责异步刷盘和冷数据存储的 Ledger 盘 IOPS 被完全打满。查阅 Bookie 日志,满屏的 Compaction 狂暴运作:

    20:15:33.123 [GarbageCollectorThread-1-1] INFO  org.apache.bookkeeper.bookie.GarbageCollectorThread - Suspending compaction, disk usage 92% is above warn threshold 90%.
    20:15:33.456 [GarbageCollectorThread-1-1] INFO  org.apache.bookkeeper.bookie.GarbageCollectorThread - Running major compaction on entrylog 14552...
    

    为什么 Geo-Replication 断连会引发本地写雪崩?

    很多刚接触 Pulsar 计算存储分离架构的人会有个误区:BookKeeper 的 Journal 盘和 Ledger 盘是物理隔离的,只要 Journal 写得快,Pulsar 就能一直维持低延迟。

    但在实际生产的复杂拓扑(尤其是跨机房双活/多活)中,这个防御假设不堪一击。本次故障的底层原理是一场由 Cursor 停滞引发的连环死亡螺旋:

    1. Replication Cursor 卡死:Pulsar 的 Geo-Replication 本质上是 Broker 内部维护的一个特殊的 Subscription(游标)。当专线断开,地域 B 无法接收数据,地域 A 的 Broker 侧 pulsar_replication_backlog 会不断堆积。

    2. ManagedLedger 无法回收:Broker 侧的数据清理完全依赖 ManagedLedger 的 Cursor 推进。由于 Replication Cursor 是全量保留的,Zookeeper 中的 Ledger 元数据无法被标记为 Deleted。

    3. Bookie 磁盘水位告警:未删除的 Ledger 持续占据 Bookie 空间,导致 Ledger 磁盘使用率触碰 diskUsageWarnThreshold(默认 90%)。

    4. 触发 GC 死亡螺旋:BookKeeper Garbage Collector 线程检测到磁盘高水位,企图通过激进的 Major Compaction 腾出空间。但由于 绝大部分 Ledger 仍在存活期,Compaction 需要从旧的 EntryLog 中读取出存活的 Entry,重新写入新的 EntryLog,并更新 RocksDB 索引。这引发了极其庞大的无用读写(Read-Modify-Write)放大。

    5. Write Cache 阻塞:Ledger 盘的 IOPS 被 GC 榨干。由于 Pulsar 写入时不仅写 Journal,还要写入内存的 Memtable(Write Cache)。Memtable 满了后必须 Flush 到 Ledger 盘。Ledger 盘 IO 打满导致 Flush 极慢,最终阻塞整个 Write 链路,甚至引发 Broker 内存爆满和重连。

    核心配置调优与防御性加固

    花里胡哨的跨机房双活,最后往往死在最基础的磁盘保护和背压(Backpressure)机制上。解决此类问题,必须在 Broker 和 Bookie 双侧同时实施防御性配置。

    1. Broker 侧:强制接管 Backlog Quota (破局点)

    绝不能允许一个远端机房的断连拖死本地主干业务。必须对 Namespace 设置基于时间或大小的 Backlog Quota,并采取丢弃策略(Eviction),确保本地磁盘的生存权。

    # 查看当前的 backlog-quota
    pulsar-admin namespaces get-backlog-quotas my-tenant/my-ns
    
    # 强制配置 backlog-quota:超过 50GB 直接开始按最旧数据丢弃
    pulsar-admin namespaces set-backlog-quota my-tenant/my-ns \
      --limit 50G \
      --policy consumer_backlog_eviction
    

    在 broker.conf 中,针对 Replication 场景开启默认强制容忍限制(重要):

    # 如果订阅方掉线,最多保留多久的数据(分钟)
    managedLedgerDefaultMarkDeleteRateLimit=1.0
    backlogQuotaDefaultLimitGB=50
    backlogQuotaDefaultRetentionPolicy=consumer_backlog_eviction
    

    2. Bookie 侧:GC 隔离与限速 (防爆点)

    Bookie 的默认 GC 策略过于理想化,在极端容量下,GC 必须被限速,否则它自己就会成为压垮 IO 的最后一根稻草。修改 bookkeeper.conf:

    # 磁盘使用率达到 90% 时触发告警,达到 95% 时 Bookie 直接变为 Read-Only(拒绝新写入,保命)
    diskUsageWarnThreshold=0.90
    diskUsageThreshold=0.95
    
    # 严格限制 GC 线程的 IO 速率,避免 Compaction 打满磁盘
    # 每秒最多重写 1000 个 Entry 或 10MB 数据
    compactionRateByEntries=1000
    compactionRateByBytes=10485760
    
    # 开启 GC EntryLog 元数据缓存,减少 GC 时的随机读盘
    gcEntryLogMetadataCacheEnabled=true
    
    # 调小 Minor/Major Compaction 的阈值,平摊日常 GC 压力,防止堆积
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. DbLedgerStorage 读写隔离 (底层优化)

    Pulsar 默认使用 DbLedgerStorage(基于 RocksDB)。在高并发落盘时,必须确保 RocksDB 的 Write Buffer 和 Block Cache 合理分配。在 bookkeeper.conf 调整分配策略:

    # 使用直接 IO,绕过 PageCache,防止 Catch-up 读或 GC 污染 PageCache,挤占 Flush 性能
    dbStorage_directIOEntryLogger=true
    
    # 为 RocksDB 设置合理的 BlockCache 大小(假设节点内存较大)
    dbStorage_rockdbBlockCacheSize=2147483648
    dbStorage_rockdbWriteBufferSizeMB=64
    

    常见问题

    Q: Broker 侧开启了 TTL(Time To Live),为什么断网后 Bookie 磁盘空间还是没有释放? A: 这是 Pulsar 最常见的认知误区。TTL 默认只对 没有 Cursor 引用 的数据有效。如果你的 Replication Cursor(或者任何下游 Consumer Cursor)卡住了,数据会被标记为 Retained。此时 TTL 是无效的。如果想要 TTL 强制覆盖 Cursor,必须配合设置 ttlDurationDefault 并且使用 Namespace 级别的强制 Retention 策略,但这会导致复制链路丢失数据,需业务层评估接受度。

    Q: Bookie 日志盘 (Journal) 和数据盘 (Ledger) 已经分离,为什么 Ledger 盘高 IO 还是会影响实时写延迟? A: 因为 BookKeeper 的写入模型中,数据除了追加到 Journal 盘(负责 WAL 可靠性),同时还会写入内存中的 Write Cache(Memtable)。Write Cache 满了需要 flush 到 Ledger 数据盘。如果 Ledger 盘因为 GC/大批量 Catch-up 读导致 IOPS 耗尽,Write Cache 无法被清理,此时新的写入请求就会被阻塞在内存池外,最终导致客户端超时。

    Q: 跨机房同步场景下,如何监控并提前预警这种问题? A: 必须强监控 Broker 端的三个关键指标:

    1. pulsar_replication_backlog:复制延迟超过阈值必须立即告警,不能放任。

    2. pulsar_storage_backlog_quota_evictions:一旦触发丢弃,说明集群已经进入自保降级状态。

    3. bookkeeper_server_GC_ACTIVE_THREADS 和 Ledger 磁盘 util%:在正常运行期如果这两个指标飙升,说明你的节点容量或者负载规划已经严重不足。

  • 深入 RocketMQ 顺序消息雪崩排查:无限重试引发的队列阻塞与 CommitLog PageCache 抖动惨案

    近期处理了一起由边缘业务引发的全局 RocketMQ 集群雪崩事故。故障现象非常典型:核心链路的 Producer 突然出现大量 [TIMEOUT_CLEAN_QUEUE]broker busy 和 system busy 报错,消息发送 P99 延迟从平时的 2ms 飙升到 3000ms 以上,最终触发限流降级,核心业务受损。

    直接抛出结论: 这不是集群容量不足的问题,而是一次典型的“业务代码低级失误 + 底层机制连锁反应”引发的惨案。某业务团队滥用 MessageListenerOrderly(顺序消费),且在 Listener 中未做全局异常捕获。一条“毒药消息”(Poison Pill)触发空指针异常,导致该 MessageQueue 无限重试并被死锁。 随着积压加剧,Consumer 触发冷读(Cold Read),疯狂从磁盘拉取历史数据,引发底层 PageCache 颠簸(Thrashing)。这直接导致 Broker 写 CommitLog 时发生严重的 Major Page Fault(缺页中断),写入线程被阻塞,集群为了自我保护触发了 BrokerFastFailure 机制,全盘拒绝了所有 Producer 的写入请求。

    解决这种问题,光靠扩容 Broker 是没用的,必须从业务消费逻辑兜底和 Broker 存储层防御两端同时下刀。

    故障现场与排查推演

    排查过程中,我们首先查阅了核心 Producer 的报错日志,满屏都是这个极其刺眼的异常:

    MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 876
        at org.apache.rocketmq.client.impl.MQClientAPIImpl.processSendResponse(MQClientAPIImpl.java:682)
    

    看到 TIMEOUT_CLEAN_QUEUE,有经验的架构师脑子里应该立刻条件反射出它的触发机制:RocketMQ 的 BrokerFastFailure 后台线程会定时清理发送队列,如果发现请求在队列中等待处理的时间超过 200ms(默认值),就会直接丢弃该请求并返回 broker busy。

    为什么会等待超过 200ms?说明 Broker 处理写请求的线程池卡住了。 我立即登录主 Broker 节点,用 vmstat 1 和 iostat -xz 1 扫了一眼,Load Average 飙到了 80+,CPU 使用率并不高,但 %wa (IO Wait) 高达 60%,磁盘 util 长时间顶在 100%。

    查看 Broker 的 store.log,果不其然,刷盘耗时严重超标:

    WARN flush disk log [CommitLog] cost: 450 ms
    WARN flush commit log cost: 455 ms
    

    RocketMQ 是基于 mmap 实现的高效顺序写,CommitLog 直接写入 PageCache,通常在微秒级。这种几百毫秒的延迟,说明 PageCache 被污染了,触发了严重的缺页中断,导致同步等待磁盘 I/O。

    顺藤摸瓜,查看监控大盘的 Consumer Lag 指标,发现某非核心服务的滞后量达到了数百万条。 登录该业务的 Pod 抓取线程栈(jstack),发现大量的 ConsumeMessageThread 处于阻塞状态。

    愚蠢的 Root Cause

    翻看该业务的代码,血压直接飙升。他们为了保证所谓的“严格顺序”,使用了 MessageListenerOrderly,代码如下:

    consumer.registerMessageListener(new MessageListenerOrderly() {
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            // 没有任何 try-catch 兜底逻辑
            String payload = new String(msgs.get(0).getBody());
            processStrictly(payload); // 这里抛出了 NullPointerException
            return ConsumeOrderlyStatus.SUCCESS;
        }
    });
    

    为什么这在普通消费中不是致命问题,但在顺序消费中却是灾难?

    在普通并发消费(MessageListenerConcurrently)中,如果抛出异常或返回 RECONSUME_LATER,RocketMQ 会将消息发往 %RETRY%Group 的重试队列,并带有阶梯重试间隔,重试 16 次后进入死信队列(DLQ),当前队列会继续消费下一条消息。

    但在顺序消费(MessageListenerOrderly)中,底层逻辑是严格保序的。为了防止乱序,如果 Listener 抛出异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT,RocketMQ 会认为这条消息没处理完,绝对不会跳过它。它会将当前 MessageQueue 挂起,默认等待 1 秒后,再次投递这条一模一样的消息,陷入死循环(无限重试)。

    在这个场景下:

    1. 队列被锁死:毒药消息无限重试,后续几万条正常消息全部被阻塞在该队列后面。

    2. K8S 重启风暴:业务方发现积压,习惯性地去删 Pod 重启。Pod 的频繁上下线导致 Consumer Group 疯狂触发 Rebalance。在顺序消费模式下,Rebalance 需要向 Broker 申请分布式锁,频繁的锁争抢进一步增加了 Broker 的 CPU 压力。

    3. 冷读触发雪崩:因为消息积压时间太长,这些数据早就从 OS PageCache 中淘汰。当积压的队列试图拉取消息时,触发了大量的磁盘随机读取(冷读)。这些大量的冷读数据挤占了宝贵的 PageCache,导致 CommitLog 写入时找不到空闲页,触发 Major Fault 落盘,最终阻塞了全局的发送请求。

    一段没有写 try-catch 的几十行边缘代码,直接干翻了整个大集群,这就是缺乏防御性编程意识的代价。

    修复与底层防御加固

    对于这种问题,必须实施双端改造。

    1. 业务侧:顺序消费的防御性兜底

    严禁在 MessageListenerOrderly 中裸奔。必须全局捕获异常,并设定自定义的最大重试次数(利用 Message 的 ReconsumeTimes 属性)。当重试超过阈值时,手工将其告警并写入本地死信表或旁路处理,强制返回 SUCCESS 让位给后续消息。

    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        MessageExt msg = msgs.get(0);
        try {
            process(msg);
            return ConsumeOrderlyStatus.SUCCESS;
        } catch (Exception e) {
            log.error("Consume orderly error, msgId: {}", msg.getMsgId(), e);
            // 防御性编程:判断重试次数,避免无限阻塞队列
            if (msg.getReconsumeTimes() >= 3) {
                moveToCustomDLQ(msg); // 降级处理
                return ConsumeOrderlyStatus.SUCCESS; // 强行放行
            }
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }
    }
    

    2. Broker 侧:隔离冷热读写,保护 PageCache

    即使业务再拉胯,基础设施也必须坚挺。调整 OS 和 Broker 配置以提升抗雪崩能力。

    • OS 层内核参数调优: 调整 vm.extra_free_kbytes 和 vm.min_free_kbytes,强制内核保留一定的空闲内存用于应对突发的 IO 请求分配,避免 Page Reclaim 引发阻塞。 bash sysctl -w vm.zone_reclaim_mode=0 sysctl -w vm.swappiness=1

    • Broker 存储层调优: 强制开启预热和 mmap 内存锁定。 “`properties # 强制将 mmap 映射的内存锁定在物理内存中,避免被 Swap 出去 (mlockall) warmMapedFileEnable=true

      开启异步刷盘下额外的堆外内存池。

      写请求先写入 DirectByteBuffer,再异步 commit 到 PageCache。

      极大地缓冲了 PageCache 抖动对 Producer 写入请求的影响。

      transientStorePoolEnable=true “`

    • 开启冷热分离(RocketMQ 5.x 推荐,或 4.x SSD+HDD 架构): 如果磁盘条件允许,将 CommitLog 和 ConsumeQueue 部署在高性能 NVMe 上,或者利用 RocketMQ 的 Cold Data 机制,将长期积压的数据下沉,确保热点读取完全命中内存。

    排查清单 (同类问题速查)

    1. [TIMEOUT_CLEAN_QUEUE] broker busy 报错:意味着 Broker 处理写入请求的耗时超过 200ms。不要急于怀疑网络,第一优先级检查 Broker 磁盘 %wa 和 store.log 中的 Flush Cost,大概率是 PageCache 抖动导致 mmap 写入缺页阻塞。

    2. 顺序消费死锁陷阱:MessageListenerOrderly 不受最大重试 16 次的限制。Listener 抛出未捕获异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT 会导致该队列无限重试。必须由业务层判断 ReconsumeTimes 进行主动放行。

    3. 冷读风暴污染内存:Consumer 拉取长时间积压的历史消息(冷读),会将磁盘文件重新加载到 PageCache,直接挤占 CommitLog 的内存页空间。可通过启用 transientStorePoolEnable=true 彻底解耦业务冷读对热点发送写入的直接冲击。

    4. K8S Rebalance 抖动:顺序消费依赖向 Broker 侧申请全局锁。Pod 的频繁起停会导致 Consumer 假死,引发长时间的 Rebalance 等待(锁续期与超时机制),表现为队列有堆积但没有消费速率。

  • RocketMQ 生产环境 P99 抖动排查实战:PageCache 剧烈回收引发的 Broker Busy 与 Mmap 预热机制解析

    排查过程中,某高并发压测场景下的 RocketMQ 集群(v4.9.4)频繁爆出 [TIMEOUT_CLEAN_QUEUE]broker busy,发送延迟 P99 从 5ms 突增至 2000ms+。核心原因是 Linux PageCache 脏页回写与 mmap 缺页中断(Page Fault)阻塞了 Broker 写线程。结论先行:通过开启 RocketMQ 的 warmMapedFileEnable=true 和 transientStorePoolEnable=true,配合下调 OS 内核的 vm.dirty_background_ratio,可彻底斩断内核级阻塞,将 P99 稳定压制在 10ms 以内。

    故障现场与指标观测

    某次大促前夕的全链路压测中,单 Broker 节点 QPS 压到 4w 时,客户端开始出现大量的 MQBrokerException: broker busy 与 RemotingTooMuchRequestException 报错。

    查看 Broker 端 store.log 与 broker.log,满屏如下报错:

    202X-XX-XX XX:XX:XX WARN [SendMessageThread_1] - [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 853
    202X-XX-XX XX:XX:XX WARN [SendMessageThread_2] - OS page cache busy, osPageCacheBusyTimeOutMills=1000
    

    调出监控看板:

    1. CPU Load:平时 4-5 左右,故障发生瞬间 Load Average 飙升至 40+。

    2. 磁盘 IO:iostat -xdm 1 显示 await 偶尔飙高,但 util% 只有 50% 左右,磁盘并未彻底被打满。

    3. 内存指标:free -m 显示 buff/cache 占用接近 85%,物理空闲内存(free)极少。

    此时通过 strace -p -T -e trace=mmap,munmap,write,pwrite64 抓取底层系统调用,发现部分写操作耗时极其离谱,甚至超过 1 秒。这就引出了一个经典的架构错觉:我都全异步了,为什么还会卡?

    为什么异步刷盘(ASYNC_FLUSH)依然会阻塞写线程?

    很多开发人员认为,只要 RocketMQ 配置了 flushDiskType=ASYNC_FLUSH,消息只要写到内存(PageCache)就算成功,磁盘 IO 慢绝不会影响发送延迟。这是一个极其致命的认知盲区。

    RocketMQ 的 CommitLog 默认采取 1GB 固定大小,通过 mmap(Memory Mapped Files)将物理文件映射到用户态的虚拟内存中。Broker 处理写请求的核心路径是: SendMessageProcessor -> CommitLog.putMessage() -> MappedFile.appendMessagesInner() -> ByteBuffer.put(data)

    问题就出在这个 ByteBuffer.put() 上。这虽然是内存操作,但在 Linux 内核视角下,它随时可能被阻塞,原因有二:

    1. 缺页中断(Minor/Major Page Fault): 当 Broker 滚动创建新的 1GB CommitLog 并执行 mmap 时,Linux 采用的是“延迟分配”策略。仅仅是建立了虚拟内存地址映射,并未分配实际物理页。当写线程第一次往这个地址 put 数据时,会触发内核缺页中断,内核需要去寻找空闲物理页并建立页表。如果此时系统物理内存紧张,内核触发直接回收(Direct Reclaim),写线程就会被死死卡住。

    2. PageCache 脏页回写阻塞: 当脏页积累到内核阈值(vm.dirty_ratio,默认 20%)时,Linux 会挂起所有尝试生成新脏页的用户进程,强行同步刷盘。此时你的 ByteBuffer.put() 会直接退化为同步阻塞写。

    深度解析:CommitLog Mmap 与 读写分离预热机制

    为了规避上述内核级别的阻塞,RocketMQ 提供了几项极为核心的防御性存储机制。

    1. 强制预热与内存锁定(warmMapedFileEnable)

    配置 warmMapedFileEnable=true 后,Broker 在创建新的 1GB MappedFile 时,会提前在后台线程中将其填满 0,强行触发所有的缺页中断,真正分配物理内存。 不仅如此,RocketMQ 还会调用 JNA 执行 mlock 和 madvise:

    // 核心源码示意 (MappedFile.java)
    LibC.INSTANCE.mlock(pointer, 1024 * 1024 * 1024);
    LibC.INSTANCE.madvise(pointer, 1024 * 1024 * 1024, LibC.MADV_WILLNEED);
    

    mlock 直接告诉内核:“这 1GB 内存你给我锁死在 RAM 里,绝对不允许 Swap 出去!”。这就彻底消除了写消息时发生 Page Fault 的可能性。

    2. 堆外内存写池(transientStorePoolEnable)

    这是应对 PageCache 毛刺的终极武器(仅限异步刷盘有效)。 开启后,RocketMQ 会预先向 OS 申请一块 DirectByteBuffer 内存池(不受 JVM GC 影响,也暂时不进 PageCache)。 写数据路径变为:写请求 -> DirectByteBuffer -> 立即返回客户端成功。 后台 CommitRealTimeService 线程定期将 DirectByteBuffer 的数据写入 FileChannel(进入 PageCache),再由 FlushRealTimeService 线程异步刷盘。 这是一种极致的读写分离策略,彻底将“接收消息的写线程”与“PageCache 分配/刷盘”解耦。

    极客实战:RocketMQ 存储与内核参数双向调优

    解决此类抖动问题,绝不能只改应用配置,必须深入 OS 层联动调优。以下是我在生产环境经过验证的黄金配置标准。

    RocketMQ 核心配置 (broker.conf)

    # 强制使用异步刷盘
    flushDiskType=ASYNC_FLUSH
    # 开启堆外内存池缓冲,彻底解耦写请求与PageCache抖动
    transientStorePoolEnable=true
    # 开启Mmap预热与内存锁定,消除运行时缺页中断
    warmMapedFileEnable=true
    # 优化PageCache锁超时机制(如果发生抖动,快速失败,依赖重试)
    osPageCacheBusyTimeOutMills=1000
    

    Linux 内核 IO 参数调优 (/etc/sysctl.conf)

    光配 Broker 不够,必须改造内核的脏页回写策略:

    # 脏页占总内存的 5% 时,pdflush 后台线程开始异步刷盘(原默认10%)
    # 目的:提早刷盘,细水长流,避免积压
    vm.dirty_background_ratio = 5
    
    # 脏页占总内存的 40% 时,强制阻塞所有用户态写进程(原默认20%)
    # 目的:拉开与 background_ratio 的差距,给突发流量留足 Buffer
    vm.dirty_ratio = 40
    
    # 坚决不使用 Swap(避免mmap的内存被换出)
    vm.swappiness = 1
    
    # 预留给 OS 应急的物理内存(例如 128G 内存机器配 2G)
    # 目的:避免缺页中断时因无空闲内存触发直接回收(Direct Reclaim)引发系统停顿
    vm.min_free_kbytes = 2097152
    

    执行 sysctl -p 生效。经过这一套连招组合拳,压测 P99 稳如泰山,再也没有出现过 broker busy。

    常见问题 (FAQ)

    Q1:开启 transientStorePoolEnable=true 后,如果 Broker 进程直接 Crash(如 OOM Killer),数据会丢失吗? 会。这就是享受极致低延迟的代价。该模式下数据首先写入 DirectByteBuffer,这是用户态进程的堆外内存。如果进程被 kill -9 或者 Crash,这部分尚未 commit 到 OS PageCache 的数据将会丢失。如果你对数据一致性要求极度苛刻(如金融交易),只能忍受延迟,关闭此项并使用 SYNC_FLUSH。

    Q2:为什么消费重试队列(%RETRY%)里的消息会导致明显的磁盘 IO 升高和 Broker 负载增加? RocketMQ 是基于 CommitLog 的混合存储。正常消费是顺序读写(刚写完的数据大概率还在 PageCache 中,命中率极高)。但重试队列消费的是过去某个时间点的冷数据。这就迫使 Broker 产生大量的随机 IO(读磁盘),导致 PageCache 污染,驱逐掉热数据,从而引发全局性能下降。应对策略通常是单独隔离重试服务,或使用 NVMe SSD 扛随机 IO。

    Q3:遇到 [TIMEOUT_CLEAN_QUEUE]broker busy,除了存储层问题,还有什么原因? 如果磁盘 IO 不高,PageCache 也没问题,你需要检查是不是 JVM 发生了长时间的 Stop-The-World (STW)。尤其是 G1 GC 配置不当,或是业务代码向 RocketMQ 发送超大消息(如几 MB 的报文),导致 Broker 在反序列化/网络传输时消耗大量 CPU 和内存资源,阻塞了 Netty 的 Worker 线程。