标签: 微服务架构

  • 深入 API 网关限流陷阱排查:Token Bucket 容量失控引发的微突发穿透与熔断震荡实战

    排查某业务线核心 API 网关间歇性 503 告警时,发现了一个极其经典的防御性配置失效案例。结论先行:网关层的 Token Bucket(令牌桶)限流算法中,burst(桶容量)配置过大,导致毫秒级微突发流量直接穿透网关,瞬间击垮下游服务线程池。更要命的是,熔断器配置缺乏防抖逻辑,导致在“熔断-半开-放行-再次击垮”之间形成了高频震荡(Thrashing)。

    解决思路很简单:将 burst 值严格限制在下游最大并发承载能力之内,或者针对这种重计算型下游改用 Leaky Bucket(漏桶)算法进行流量整形(Traffic Shaping),同时为熔断器加入指数退避冷却时间。

    案发现场与指标异常

    监控大盘上的表象非常诡异。网关层统计的平均 QPS 稳定在 2,000 左右,并未达到设定的 3,000 QPS 报警水位。但下游订单服务的 P99 延迟却会毫无规律地从 50ms 飙升至 5,000ms 以上,随后伴随大量的 context deadline exceeded 报错。

    查看下游宿主机的 Node Exporter 指标,Load Average 在几秒内冲高到正常核心数的 4 倍,CPU 软中断(si)陡增。

    抓取网关层的 Envoy(或基于 Go 自研的网关)日志,看到大量 503 状态码以及频繁的熔断状态切换:

    [gw-error] downstream_connection_error: upstream_reset_before_response_started
    [circuit_breaker] state changed: CLOSED -> OPEN, reason: 5xx_rate_exceeded
    [circuit_breaker] state changed: OPEN -> HALF_OPEN
    [circuit_breaker] state changed: HALF_OPEN -> CLOSED
    [circuit_breaker] state changed: CLOSED -> OPEN, reason: 5xx_rate_exceeded
    

    愚蠢的配置与技术逻辑剖析

    直接调出网关的限流配置片段,问题一目了然:

    # 某网关 Token Bucket 限流配置片段
    rate_limit:
      token_bucket:
        max_tokens: 15000       # 桶的总容量 (Burst)
        tokens_per_fill: 3000   # 每次填充的令牌数
        fill_interval: 1s       # 填充间隔
    

    写出这段配置的研发可能对限流算法有什么误解。他认为“平时流量 2,000,限制在 3,000,为了防止大促时的流量尖刺,把 max_tokens 设成 15,000 留足缓冲”。

    这是毫无系统边界意识的典型表现。 Token Bucket 的核心特性是允许一定程度的突发流量。当业务处于低谷时,令牌桶会很快被填满(15,000 个令牌)。此时如果客户端利用并发压测工具或因为重试风暴,在 10 毫秒内打过来 10,000 个请求,网关会认为“桶里有足够的令牌”,瞬间全部放行。

    下游订单服务是个典型的 Java Spring Boot 配合 Tomcat 的同步阻塞架构,Tomcat 最大线程数才配置了 800,数据库连接池 200。这 10,000 个并发砸下去,直接导致:

    1. Tomcat 线程池瞬间打满,后续请求全部进入 Accept 队列。

    2. 数据库连接池耗尽,获取 Connection 发生等待。

    3. 线程疯狂上下文切换,CPU 资源被内耗殆尽。

    网关层的限流不仅没有保护下游,反而成了帮凶。你以为的“缓冲”,其实是积攒了一发核弹交给了客户端。

    熔断震荡(Circuit Breaker Thrashing)的雪上加霜

    下游瘫痪后,网关的熔断器(Circuit Breaker)触发了 OPEN 状态,切断流量,下游开始缓慢恢复。但随后的恢复过程暴露了熔断器设计的另一个缺陷。

    现有的熔断半开(HALF_OPEN)策略是:等待 5 秒后,放行 10 个请求,如果全成功,则关闭熔断器(CLOSED)。 因为这 10 个探路请求下去时,下游服务刚刚喘过气,处理极快。熔断器一看,非常健康,瞬间切回 CLOSED。结果桶里早就又攒满了 15,000 个令牌,下一波微突发(Micro-burst)再次穿透,下游再次暴毙。

    这种简单的状态机没有考虑到下游服务的冷启动缓冲和并发水位线,导致系统在可用与不可用之间剧烈震荡,P99 延迟曲线变成了梳子状。

    避坑指南与底层调优

    解决这类问题,必须摒弃“一厢情愿”的配置,回归到底层容量规划上来:

    1. 重新校准 Token Bucket,或者改用 Leaky Bucket 如果下游服务对瞬间并发极度敏感(如涉及复杂事务、慢 SQL 的 DB 操作),首选 Leaky Bucket(漏桶)。漏桶就像一个队列,强行将请求以恒定速率滴漏给下游(Traffic Shaping),完全抹平突发。 如果必须用 Token Bucket,max_tokens(Burst)的绝对上限,必须小于下游服务能够承受的最大瞬时并发数(通常是 线程池大小 + 队列长度的小部分)。

    修正后的配置:

    rate_limit:
      token_bucket:
        max_tokens: 800         # 严格卡住瞬间并发上限
        tokens_per_fill: 3000
        fill_interval: 1s
    

    (注:如果使用 Go 的 golang.org/x/time/rate,Limit 控制速率,Burst 就是这个 max_tokens,务必小心设置。)

    2. 引入滑动窗口与指数退避熔断 放弃拍脑袋的连续错误计数熔断,改用基于时间滑动窗口(Sliding Window)的错误率熔断。半开状态的探测必须具备平滑放量能力,而不是简单的“成功 10 个就完全放开”。冷却时间必须使用指数退避(Exponential Backoff,如 5s -> 10s -> 20s),并引入 Jitter(随机抖动)防止探路请求形成新的微并发。

    3. 监控维度的降维打击:从秒级到毫秒级 只看秒级(QPS)的监控,永远抓不到微突发。在排查阶段,通过抓包或高频采样,将吞吐量统计粒度降到 10ms 或 100ms,你会清楚地看到流量的尖刺。

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

    1. 核对 Burst/Capacity 配置:检查网关限流器中代表突发容量的字段(如 burst, max_tokens),其值是否远大于下游服务的可用线程数/连接池数。

    2. 区分流量整形需求:评估下游是否能处理突发。如果不能,立刻将 Token Bucket 替换为 Leaky Bucket,或在 Nginx 中严格使用 limit_req 且不加 burst(或者加 nodelay 时严格控制大小)。

    3. 检查熔断震荡日志:在日志系统中聚合分析 CLOSED -> OPEN -> HALF_OPEN -> CLOSED 的时间差。如果发生频率在秒级切换,说明探路策略过于激进,需增大退避时间和放量步长。

    4. 排查毫秒级微突发:如果 1s 内的 QPS 正常但 P99 飙升,使用 Prometheus 的 rate(metric[1m]) 是看不出来的,必须拉取网关 Access Log 按照 100ms 粒度做 count 聚合分析。

  • 异地多活网关雪崩实战: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_connections 和 max_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-For、Max-Forwards 或自定义跳数 Header,超过阈值(通常为1或2)必须立即丢弃并返回 508 (Loop Detected)。

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

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

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