某次核心支付系统的异步回调链路突发大面积超时,API 网关 99 线从 50ms 直接飙升至 30s 并伴随大量 504 Gateway Timeout。排查结论令人啼笑皆非:某位业务开发为了实现所谓的“跨机房双活容灾”,在没有任何路由防环设计的情况下,通过 RabbitMQ 管理控制台手动配置了双向 Shovel 插件。结果导致消息在两个集群间形成无限死循环复制,瞬间产生的消息风暴击穿了节点内存,触发了 Erlang VM 的 vm_memory_high_watermark 告警,底层的 TCP 背压(Backpressure)机制直接将所有 Producer 的 Connection 强行置为 blocking 状态,最终引发了波及全业务线的全局雪崩。
不要把消息队列当成可以随意拉线的网络集线器,在没有深刻理解 AMQP 路由拓扑和底层流控机制前,任何“高可用”架构的尝试都无异于自掘坟墓。
案发现场:全线假死与消失的吞吐量
故障发生时,监控大盘上呈现出极其诡异的景象:
-
QPS 归零:业务网关请求堆积,RabbitMQ 集群的 Inbound 流量在经历了几秒钟的垂直飙升后,瞬间掉底为 0。
-
CPU 与 Load 暴增:宿主机 Load Average 飙升至 80+,
epmd和beam.smp进程 CPU 占用率满载。 -
海量 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 产生时:
-
机房 A 的 Exchange 将其路由到本地队列,同时 Shovel-A 将其拉取。
-
Shovel-A 将该消息
basic.publish到机房 B 的pay.topic。 -
机房 B 的 Exchange 接收到消息,不仅路由给 B 的本地队列,同时被 Shovel-B 捕获。
-
Shovel-B 再次将其发回给机房 A…
一条消息在毫秒级内变成了几万条,呈指数级放大,瞬间榨干网络带宽并击穿了 14GB 的内存水位。
为什么说这个错误不可原谅?
如果是单纯为了做高可用和跨集群复制,官方早就提供了 Federation 插件。为什么 Federation 不会环路而 Shovel 会?这是协议层设计的降维打击。
Federation 插件在跨节点投递消息时,会在 AMQP Header 中注入 x-received-from 属性。
当机房 B 的 Federation 收到来自机房 A 的消息时,检查 Header 发现这条消息曾经来过,或者达到了配置的 max_hops 阈值,就会直接丢弃,从根源上阻断了环路。
而该业务团队因为“嫌 Federation 配置策略复杂,Shovel 看起来就像个搬运工比较简单”,直接用了 Shovel。要知道,Shovel 是无状态的,它不管消息从哪里来,只负责傻瓜式地搬运,根本没有防环机制。更要命的是,他们在 Topic 匹配上用了最暴力的 #,将整条业务线推向了深渊。
破局与防御性架构落地
应急恢复非常粗暴:
-
立刻通过 CLI 强制删除双向的 Shovel 链路:
rabbitmqctl clear_parameter -p / shovel shovel-a。 -
执行
rabbitmqctl purge_queue清空由于环路产生的海量垃圾消息,让内存水位降至 0.4 以下。 -
观察
alarm解除,TCP 连接恢复running状态,业务网关自动重连恢复。
针对此类惨案,运维和架构层面必须落地以下防御性策略:
-
废弃控制台 ClickOps,收归配置权限: 禁止任何人通过 Management UI 手动拉取跨机房链路。所有的 Shovel/Federation Policy、Exchange、Binding 配置,必须通过 Terraform 或 Ansible 以 IaC(基础设施即代码)的形式进入 GitOps 流程,强制进行拓扑评审。
-
正确使用高可用组件: 跨集群双活/复制,首选 Federation,并严格配置
max-hops = 1。如果非要用 Shovel,路由键必须加上机房前缀(如dc-a.pay.#),并且 Shovel 目的端只允许写入带有特定后缀的隔离 Exchange。 -
多租户与 VHost 物理隔离: 所有核心业务线必须拆分物理集群,至少也要做到 VHost 级别的隔离,并对每个 VHost 限制
max-length和max-length-bytes,防止单一野鸡业务把全局水位打爆。
排查清单:RabbitMQ 内存阻塞与环路问题速查
-
确认全局资源告警阻塞 (TCP Backpressure)
rabbitmq-diagnostics alarms如果存在memory alarm: true或disk_free alarm: true,说明 Broker 已启动自我保护,所有发布消息的 Connection 已被挂起(State: blocking/blocked)。 -
快速定位堆积/异常队列
rabbitmqctl list_queues name messages memory message_bytes | sort -k4 -nr | head -n 10查出占用内存或消息体总和最大的 Top 10 队列,如果是极短时间内暴增,高度疑似环路风暴。 -
排查 Shovel / Federation 配置状态
rabbitmqctl list_parameters -p [vhost]检查是否存在双向配置的参数。对于 Federation,检查rabbitmqctl federation_status的链路是否有报错。 -
验证连接状态统计
rabbitmqctl list_connections state | grep -c blocking当出现大量blocking连接时,切勿盲目重启应用,需优先解决 MQ 服务端的资源水位问题,否则应用重启后仍会卡死在建立 AMQP Channel 的握手阶段。