某次接手处理一个跨机房双活架构的突发故障,业务端疯狂报错 java.util.concurrent.TimeoutException,所有往 RabbitMQ 集群投递消息的生产者全部卡死。登录管控台一看,双机房的 RabbitMQ 节点内存全部顶到告警线,连接状态齐刷刷显示为 blocked。
最终排查发现,这是一个极其低级的架构配置失误:业务侧通过 HTTP API 动态下发了双向 Shovel 任务进行跨机房消息同步,但既没有规划隔离的 Routing Key,也没有利用 Header 进行防环判断。一条消息在两个机房之间构成了无限死循环(Infinite Routing Loop),引发指数级的消息放大。RabbitMQ 在触发 vm_memory_high_watermark 保护机制后,无差别封杀所有生产者 TCP 连接,随后触发海量内存数据 Paging 刷盘,直接把底层存储 IOPS 打满,导致整个消息总线瘫痪。
跨机房同步不用自带防环机制的 Federation,反而去手捏底层的 Shovel,捏完还不做防环逻辑。这种把插线板插在自己身上企图获得无限能源的操作,是对分布式系统基本功的严重亵渎。
案发现场:诡异的 Blocked 连接与暴涨的内存
监控大屏上的指标非常刺眼:
-
Message Rate 异常:入队速率(Publish)从平时的 3k/s 瞬间飙升到 80k/s,而出队速率(Deliver/Get)几乎跌零。
-
连接状态死锁:执行
rabbitmqctl list_connections pid client_properties state,发现数万个生产者连接的 state 全部处于blocking或blocked状态。 -
节点内存报警:系统内存 32G,RabbitMQ 进程占用飙破 12.8G(默认 40% 阈值)。
-
日志报警:核心日志里疯狂刷出
alarm_handler触发的告警:log [warning] <0.324.0> memory resource limit alarm set on node 'rabbit@node1'. [info] <0.326.0> connection <0.1122.0> (10.x.x.x:54321 -> 10.x.x.y:5672): connection is blocked
深度剖析:环形风暴与 Erlang VM 内存防御机制
为什么一条循环消息能让整个 RabbitMQ 集群雪崩?这涉及 AMQP 协议的路由盲区以及 Erlang VM 激进的防御机制。
1. Shovel 双向死环的形成
在跨机房同步场景中,RabbitMQ 官方推荐的 Federation 插件会在消息 Header 中隐式追加 x-received-from 标记。当节点发现消息的流转链路中已经包含自己的集群名时,会主动丢弃,从而天然防环。
但排查过程中发现,业务侧为了“灵活控制路由”,选择使用了更底层的 Shovel 插件。Shovel 的本质是一个伪装成客户端的 Erlang 进程,它在一端 Consume,在另一端 Publish。
配置示例还原:
-
机房 A Shovel:源端
Exchange=order.topic,目标端 机房 BExchange=order.topic -
机房 B Shovel:源端
Exchange=order.topic,目标端 机房 AExchange=order.topic
由于两者监听的 Routing Key 均为 # 且目标 Exchange 相同,机房 A 产生的一条真实订单消息,被 Shovel 搬运到机房 B 后,立刻被机房 B 的 Shovel 捕获,再次搬回机房 A。消息在两条千兆专线间以网卡极限速度疯狂打乒乓球。
2. vm_memory_high_watermark 的“休克疗法”
RabbitMQ 不是以丢消息为代价来保命的系统。当节点内存达到 vm_memory_high_watermark(默认总内存的 0.4 倍)时,RabbitMQ 会触发一种近乎物理断电的保护机制:
底层 Erlang 会调用 erlang:setopts(Socket, [{active, false}]),直接停止读取所有发布消息的 TCP Socket。
这导致操作系统的 TCP 接收缓冲区迅速填满,TCP 窗口滑动为 0(Zero Window),反压(Backpressure)传导至客户端,最终导致所有的 Spring AMQP / Celery 生产者线程因等不到 ACK 甚至无法建立 Socket 发送而全部 Block 阻塞,业务雪崩。
3. Paging 刷盘引发的 IO 惨案
内存触顶后,噩梦才刚刚开始。为了腾出内存,RabbitMQ 会根据 vm_memory_high_watermark_paging_ratio(默认 0.5,即达到内存水位线的 50% 时触发)策略,将内存中的瞬态消息(Transient Messages)和队列索引强行 Page Out 到磁盘的 msg_store_transient 目录。
# 查看内存破拆情况
rabbitmq-diagnostics memory_breakdown
# 输出显示 msg_index 和 queue_procs 占据了绝大部分内存
几十万条循环堆积的消息瞬间引发极高频率的随机写 IO,导致磁盘 %%util 打满 100%,iowait 飙升。此时哪怕你想通过命令行去删除队列,都会因为底层 Mnesia 数据库及 Erlang 进程的 IO 阻塞而超时失败。
破局与防御性修复
在 IO 打满、连接全卡死的状态下,常规操作已经失效,必须通过底层干预进行“放水排雷”。
1. 紧急提水位,恢复管控权 必须先骗过 Erlang VM,让它以为内存还够,从而恢复 TCP 处理和管控台响应:
# 临时将内存告警阈值从 0.4 提至 0.6,争取操作窗口
rabbitmqctl set_vm_memory_high_watermark 0.6
2. 斩断死环,清理积压 在争取到的几分钟窗口期内,立刻删掉引发风暴的 Shovel 配置,并暴力清空积压队列:
# 删除恶意 Shovel (注意:需在目标 VHost 下执行)
rabbitmqctl clear_parameter -p /my_vhost shovel my_evil_shovel_a2b
# 清洗队列(比从 UI 点 Purge 更稳)
rabbitmqctl purge_queue -p /my_vhost loop_queue_name
3. 架构级防御加固 恢复后,必须进行彻底的架构重构,杜绝此类问题二次发生:
-
弃用双向 Shovel,改用 Federation:如果非要用双向同步,强制使用 Federation 插件,利用其内置的
x-received-fromHeader 实现拓扑防环。 -
如果是 Shovel 刚需,必须做 Header 路由过滤:在 Shovel 配置中注入特定的 Header(例如
add_forward_headers),并在接收端的 Exchange 之前挂载一个 Headers Exchange 进行逻辑判断,拒收带有该机房标记的消息。 -
死信与 TTL 兜底:任何跨系统调用的队列,绝对不允许无限期堆积。强制设置
x-message-ttl和x-max-length。消息堆满立刻进 DLX(死信交换机),并配合报警,将故障控制在局部。
总结排查清单
为了避免后续运维和开发再踩坑,总结同类问题速查清单如下:
-
连接 Blocked 速查:遇到大量连接呈
blocking/blocked,第一时间看管控台右上角 Node 状态,如果是红色Memory,说明已触发内存高水位封控,直接查vm_memory_high_watermark。 -
路由死环预警:排查有无异常的高 Message Publish 速率。如果有,且入队等于出队,极大概率是 Dead Letter Exchange (DLX) 配置成了死环,或者是 Shovel/Federation 跨机房配置了镜像拓扑。
-
Paging 引起的性能雪崩:如果 CPU Load Average 极高,且执行
rabbitmqctl命令频繁超时,检查磁盘 IO 是否被 RabbitMQ 的msg_store_transient或msg_store_persistent目录写满。必要时临时调高内存阈值进行急救。 -
生产者防阻塞策略:业务代码严禁对 MQ 同步阻塞等待。必须配置
ConnectionFactory的超时时间,并在框架层捕获AmqpException进行降级,防止 MQ 抖动直接把业务 Tomcat/Netty 线程池拖死。