标签: 高可用

  • 深入 RabbitMQ 跨机房雪崩排查:Shovel 环形路由风暴引发的内存高水位封控与 Paging IO 抖动实战

    某次接手处理一个跨机房双活架构的突发故障,业务端疯狂报错 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 连接与暴涨的内存

    监控大屏上的指标非常刺眼:

    1. Message Rate 异常:入队速率(Publish)从平时的 3k/s 瞬间飙升到 80k/s,而出队速率(Deliver/Get)几乎跌零。

    2. 连接状态死锁:执行 rabbitmqctl list_connections pid client_properties state,发现数万个生产者连接的 state 全部处于 blockingblocked 状态。

    3. 节点内存报警:系统内存 32G,RabbitMQ 进程占用飙破 12.8G(默认 40% 阈值)。

    4. 日志报警:核心日志里疯狂刷出 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,目标端 机房 B Exchange=order.topic

    • 机房 B Shovel:源端 Exchange=order.topic,目标端 机房 A Exchange=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-from Header 实现拓扑防环。

    • 如果是 Shovel 刚需,必须做 Header 路由过滤:在 Shovel 配置中注入特定的 Header(例如 add_forward_headers),并在接收端的 Exchange 之前挂载一个 Headers Exchange 进行逻辑判断,拒收带有该机房标记的消息。

    • 死信与 TTL 兜底:任何跨系统调用的队列,绝对不允许无限期堆积。强制设置 x-message-ttlx-max-length。消息堆满立刻进 DLX(死信交换机),并配合报警,将故障控制在局部。

    总结排查清单

    为了避免后续运维和开发再踩坑,总结同类问题速查清单如下:

    1. 连接 Blocked 速查:遇到大量连接呈 blocking/blocked,第一时间看管控台右上角 Node 状态,如果是红色 Memory,说明已触发内存高水位封控,直接查 vm_memory_high_watermark

    2. 路由死环预警:排查有无异常的高 Message Publish 速率。如果有,且入队等于出队,极大概率是 Dead Letter Exchange (DLX) 配置成了死环,或者是 Shovel/Federation 跨机房配置了镜像拓扑。

    3. Paging 引起的性能雪崩:如果 CPU Load Average 极高,且执行 rabbitmqctl 命令频繁超时,检查磁盘 IO 是否被 RabbitMQ 的 msg_store_transientmsg_store_persistent 目录写满。必要时临时调高内存阈值进行急救。

    4. 生产者防阻塞策略:业务代码严禁对 MQ 同步阻塞等待。必须配置 ConnectionFactory 的超时时间,并在框架层捕获 AmqpException 进行降级,防止 MQ 抖动直接把业务 Tomcat/Netty 线程池拖死。

  • 异地多活网关雪崩实战:5 秒配置同步延迟引发的跨城路由死环与 Envoy 线程耗尽

    近期排查了一起极其典型的“异地多活”架构翻车事故。某业务在做全链路压测与流量切流演练时,双中心网关集群在 10 秒内接连雪崩,P99 延迟从 15ms 直接飙升至网关超时上限(10s),最终导致两个可用区同时瘫痪。

    结论先行:这不是什么深奥的底层 Bug,而是一个极其低级的架构设计缺陷。控制面在下发“单元化路由规则(UID -> AZ)”时存在跨城同步延迟。在这短短 5 秒的数据不一致窗口期内,AZ-A 认为请求该去 AZ-B,而 AZ-B 还在使用旧规则认为请求该回 AZ-A。网关层完全没有做防环处理(Loop Detection),导致请求在两地专线间无限次 Ping-Pong 转发,瞬间打爆了 Envoy 的连接池和跨城专线带宽。

    伪多活架构的遮羞布,就这样被区区 5 秒的延迟扯得粉碎。

    故障现场:从 P99 飙升到全局 502

    排查过程中,监控面板的异动非常诡异:

    1. 外部流量未突增:入口 QPS 正常,没有遭受 DDoS 攻击。

    2. 专线带宽被打满:两地机房之间的 10G 专线监控显示,出入带宽在几秒内呈直线上升至 100%。

    3. 网关层资源枯竭:Envoy 节点的 CPU Load Average 飙升至 80+,envoy_cluster_upstream_rq_pending_overflow 指标疯狂报错。

    4. 后端业务毫无波澜:底层的微服务和 DB 监控一片祥和,甚至 QPS 还下降了——因为流量全死在网关了。

    直接拉取 Envoy 的 Access Log,发现令人窒息的现象,同一个 x-request-id 在同一秒内出现了数百次日志打印:

    {"time": "...", "x-request-id": "a7b2c9-...", "upstream_cluster": "outbound|80||gateway-az-b", "response_code": "504"}
    {"time": "...", "x-request-id": "a7b2c9-...", "upstream_cluster": "outbound|80||gateway-az-b", "response_code": "504"}
    

    而在 AZ-B 的网关日志里,同样的 Request ID 正在被疯狂转发回 AZ-A。

    根因拆解:分布式的“阿喀琉斯之踵”

    该业务号称实现了“异地双活”,其实质是经典的单元化架构(Cell-based Architecture)。路由规则按用户 UID 取模或查表分配:UID_Range_1 在 AZ-A 闭环,UID_Range_2 在 AZ-B 闭环。如果用户访问错了机房,入口网关会负责将其 Proxy 到正确的机房。

    演练时,运维执行了 UID 搬迁操作:将某一批 UID 从 AZ-A 迁移至 AZ-B。 正常的迁移状态机应该是:禁止写入 -> 数据同步 -> 变更路由规则 -> 开放写入

    问题出在路由规则下发环节。全局控制面(Global Control Plane)将新的路由表通过 xDS 下发给两地的 Envoy 集群。 由于跨城网络抖动和底层配置中心的同步机制,AZ-A 的网关瞬间收到了新规则,而 AZ-B 的网关存在约 5 秒的同步延迟。

    这 5 秒内,逻辑变成了这样:

    1. 用户流量进入 AZ-A。

    2. AZ-A 网关查最新路由表:“该 UID 已迁至 AZ-B”,于是将请求通过专线转发给 AZ-B 的网关。

    3. 请求抵达 AZ-B。AZ-B 网关查旧路由表:“该 UID 属于 AZ-A”,于是将请求再转发回 AZ-A 的网关。

    4. AZ-A 再次收到请求,再次转发给 AZ-B……

    一次简单的 HTTP 请求,在没有 Max-Forwards 限制的情况下,变成了跨城专线上的死循环。几千个这样的请求,瞬间裂变成数百万次的内部 RPC 调用,直接击穿 Envoy 的 max_connectionsmax_pending_requests 限制,导致网关假死,进而引发全量业务 502。

    为什么犯错不可原谅?

    真正的多活,不仅是画在 PPT 上的两套对等集群,而是骨子里对分布式系统“弱一致性”的敬畏。 CAP 定理早就告诉我们,跨越 WAN 网络的节点,绝对不可能实现原子的状态变更。只要存在时间差,就一定会出现路由视角的不一致。

    在架构设计时,不假设“配置下发绝对同时生效”,而是假设“一定会出现路由环路并进行兜底拦截”,这叫防御性编程。花了几百万拉跨城专线,却连一个最基础的 Hop Limit 都不加,这种架构翻车纯属人祸。

    止血与防御性修复

    当时在现场的紧急止血操作非常粗暴:直接切断了 AZ-A 到 AZ-B 的专线路由转发(牺牲跨城纠错能力,强行阻断环路),网关雪崩立刻停止。随后紧急排查控制面同步组件并修复延迟。

    彻底的修复方案(防环机制落地):

    1. 网关层强制拦截:引入 Max-Forwards 机制 无论使用 Nginx 还是 Envoy,在进行跨机房流量 Proxy 时,必须注入并校验自定义 Header(如 X-Multi-Active-Hop)。在 Envoy 中,可以通过原生机制或极简的 Lua Filter 实现:

    # Envoy Lua Filter 防环片段
    name: envoy.filters.http.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
      inline_code: |
        function envoy_on_request(request_handle)
          local hop_count = tonumber(request_handle:headers():get("x-multi-active-hop") or "0")
          if hop_count >= 2 then
            request_handle:respond({[":status"] = "508"}, "Loop Detected in Multi-Active Routing")
            return
          end
          request_handle:headers():replace("x-multi-active-hop", tostring(hop_count + 1))
        end
    

    2. 路由变更状态机:平滑过渡 不要做“一刀切”的路由变更。UID 迁移的路由切换必须存在中间态(Transit State)。 当 UID 正在迁移时,路由状态设为 MIGRATING,此时新旧机房的网关对该 UID 的请求应统一 Hold 住(挂起等待)或降级处理,直到两端均确认收到最新配置(ACK)后,再将状态切为 COMMITTED 放行。

    3. 隔离爆炸半径 为跨城 Proxy 流量配置独立的 Cluster 和 Connection Pool,绝对不能与处理本地域内流量的线程池混用。这样即使专线打满或跨城目标假死,本地域内的“正确流量”依然不受影响。

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

    1. 跨城/跨 AZ 路由环路检测:检查所有跨域转发是否携带并校验了 X-Forwarded-ForMax-Forwards 或自定义跳数 Header,超过阈值(通常为1或2)必须立即丢弃并返回 508 (Loop Detected)。

    2. 配置中心弱一致性容灾:检查下发控制面(etcd / Consul / 自研 xDS 服务)在脑裂或跨城延迟 > 10s 的情况下,Data Plane 是否能优雅降级,还是会触发雪崩逻辑。

    3. 隔离与限流(Bulkhead):检查网关对于“跨城纠错流量”是否配置了独立的连接池(Connection Pool)和并发数限制(Circuit Breaker),防止小比例的纠错流量耗尽全局 Worker 资源。

    4. UID 状态机原子性:在单元化架构中,检查 UID 归属地切换是否有明确的“过渡态”,严防因配置生效时间差导致的“两地都不认”或“两地互相抛”的脏读问题。