分类: 架构设计与高可用

  • 深入 API 网关限流与熔断:从 Token Bucket 突发击穿看 Envoy 熔断器状态抖动排查实战

    结论先行:网关层单一使用 Token Bucket 限流,极易因 burst(突发)参数配置过大导致下游在流量毛刺下被击穿。某次排查发现,瞬间高并发耗尽令牌桶后直接透传,打挂了后端服务,进而引发 Envoy v1.27.0 熔断器(Outlier Detection)频繁弹射健康节点,触发 Panic 路由机制导致全局雪崩。核心解法:引入 Leaky Bucket 平滑流量,并精确调优 Envoy 驱逐窗口与熔断阈值。

    案发现场:P99 飙升与诡异的 503 UO 报错

    排查某核心交易链路问题时,监控大盘显示网关入口 QPS 平稳维持在 3000 左右,但 P99 延迟却在某些瞬间飙升至 4000ms 以上。紧接着,下游服务开始大面积报警,CPU 使用率出现锯齿状波动,Load Average 瞬间飙升至宿主机核心数的 3 倍。

    抓取入口网关与 Sidecar 的日志,发现海量的 503 报错。提取关键的 Envoy (v1.27.0) Access Log:

    {
      "response_code": "503",
      "response_flags": "UO",
      "upstream_cluster": "outbound|8080||order-svc",
      "duration": "2",
      "upstream_service_time": null
    }
    

    注意这里的 Response Flag UO (Upstream Overflow)。在 Envoy 的语义中,UO 意味着请求不仅没有到达后端应用代码,甚至连连接池都没建起来,直接被 Envoy 的 Circuit Breaker 拦截了。但进一步看,日志中还夹杂着大量 UC (Upstream Connection Termination) 和 503 URX (Upstream Retry Limit Exceeded)。

    这就很有意思了:流量大盘是平稳的,网关层配置了 5000 QPS 的全局限流,按理说后端集群(20个 Pod,单 Pod 容量 300 QPS)完全吃得消,为什么会被打出 UOUC

    为什么 Token Bucket 算法无法应对瞬间毛刺流量?

    排查网关层的分布式限流实现,发现业务研发基于 Redis + Lua 实现了一个标准的 Token Bucket(令牌桶)算法。核心 Lua 脚本片段如下:

    -- KEYS[1]: rate_limit_key
    -- ARGV[1]: capacity (桶容量)
    -- ARGV[2]: rate (每秒生成令牌数)
    -- ARGV[3]: current_timestamp (当前时间戳)
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    
    local last_time = tonumber(redis.call('hget', KEYS[1], 'last_time') or '0')
    local current_tokens = tonumber(redis.call('hget', KEYS[1], 'tokens') or capacity)
    
    -- 计算这期间生成的令牌
    local delta_tokens = math.floor((now - last_time) * rate)
    local tokens = math.min(capacity, current_tokens + delta_tokens)
    
    if tokens > 0 then
        redis.call('hset', KEYS[1], 'tokens', tokens - 1)
        redis.call('hset', KEYS[1], 'last_time', now)
        return 1 -- 放行
    else
        return 0 -- 限流
    end
    

    当时的配置是:capacity = 2000rate = 1000。 这就是典型的防御盲区。Token Bucket 的核心特性是允许突发流量(Burst)。如果系统在过去 2 秒内极其空闲,桶里积攒了 2000 个令牌。此时一个瞬间的流量毛刺(Microburst)打过来,这 2000 个请求会在 10 毫秒 内全部被网关放行,直接砸向后端。

    对于后端来说,这不是 1000 QPS,这是瞬时 2000 / 0.01s = 200,000 QPS 的冲击。 微服务的连接池瞬间被打满,TCP Accept Queue 溢出,导致部分请求超时(产生 504/503)。

    如果是 Leaky Bucket(漏桶) 算法,由于其恒定速率流出的特性(类似 Nginx 的 limit_req 且不带 nodelay),这 2000 个请求会被强制在队列中排队,以绝对平滑的 1000 QPS 速率向后端转发,起到真正的削峰填谷作用。

    Envoy 熔断器(Outlier Detection)的雪崩效应

    流量毛刺击穿网关后,真正的灾难在 Envoy 代理层爆发。微服务由于瞬时过载,部分 Pod 开始返回 5xx 错误或连接超时。Envoy 的 Outlier Detection(异常点检测)机制被触发。

    当时配置的 DestinationRule 如下:

    apiVersion: networking.istio.io/v1alpha3
    kind: DestinationRule
    metadata:
      name: order-svc-dr
    spec:
      host: order-svc
      trafficPolicy:
        connectionPool:
          http:
            http1MaxPendingRequests: 1024
            maxRequestsPerConnection: 100
        outlierDetection:
          consecutive5xxErrors: 3
          interval: 10s
          baseEjectionTime: 30s
          maxEjectionPercent: 50
    

    当突发流量导致某几个 Pod 连续返回 3 个 5xx 时,Envoy 毫不犹豫地将它们拉黑(Eject)30 秒。 随着被拉黑的 Pod 越来越多(很快达到了 maxEjectionPercent: 50% 的上限),剩余 50% 的 Pod 必须承受全部流量,瞬间雪崩。

    更致命的是,当 Envoy 发现健康后端节点比例低于 Panic Threshold(默认 50%)时,会触发恐慌路由(Panic Routing)。Envoy 会认为:“既然健康检查机制可能出错了,那我就无视驱逐状态,把流量均匀分发给所有节点”。 于是,处于假死状态的 Pod 再次迎来海量流量,彻底 OOM,Envoy 连接池爆满,最终向上游网关抛出开篇看到的 503 UO503 UC

    体系化修复与架构加固

    为了彻底根治这种“毛刺流量 -> 网关击穿 -> 熔断驱逐 -> 恐慌路由 -> 全局雪崩”的连环雷,我们从网关层和 Mesh 层做了以下防御性调整:

    1. 网关层:平滑限流(Leaky Bucket 变体)替代纯令牌桶

    废弃了原有的自研 Lua 纯令牌桶,在 Nginx/OpenResty 入口层启用基于共享内存的严格限流。即使保留一定的并发度,也必须通过 delay 参数强制平滑:

    # 定义 1000r/s 的速率,桶容量为 500
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=1000r/s;
    
    # burst=500 允许一定突发,但 delay=200 表示超过 200 的突发流量将被严格按速率排队延迟,拒绝瞬间砸穿后端
    limit_req zone=api_limit burst=500 delay=200;
    

    2. Mesh 层:压制重试风暴与调优熔断参数

    调整 Envoy 的 Outlier Detection 与连接池控制,防止误杀:

    1. 放大连续错误阈值:将 consecutive5xxErrors 从 3 调整为 15。在高并发微服务中,3 个连续 5xx 极易被网络抖动误触发。

    2. 细化驱逐条件:启用 splitExternalLocalOriginErrors,明确区分应用自身抛出的 5xx(如 500 业务报错)和本地网络/Envoy 产生的 5xx(如 503 连接超时)。只对真正的网络连接异常进行物理节点驱逐。

    3. 调整恐慌阈值:在 Envoy Cluster 配置中,通过 EnvoyFilter 将 Panic Threshold 从 50% 降低至 20%(如果 80% 节点都挂了,再开启无差别盲发请求)。

    # EnvoyFilter 局部核心配置片段
    name: envoy.filters.network.http_connection_manager
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
      common_http_protocol_options:
        idle_timeout: 60s
      route_config:
        virtual_hosts:
        - name: order_route
          routes:
          - match: { prefix: "/" }
            route:
              cluster: outbound|8080||order-svc
              max_stream_duration:
                max_stream_duration: 3s # 强制设置全局绝对超时
    

    常见问题 (FAQ)

    Q1:Token Bucket 和 Leaky Bucket 在真实网关选型时究竟怎么选? 面对对外网关(防刷、防爬),首选 Leaky Bucket(或者带强排队机制的令牌桶变体),这能把刺猬一样的流量彻底削平。面对内部微服务间的限流(RPC 调用),由于内部流量更可控且对 RT 敏感,通常使用 Token Bucket 以容忍短时间的并发调用,但必须严格限制 burst 上限,burst 绝对不能超过目标服务连接池容量的 1/3。

    Q2:分布式限流用 Redis + Lua 有什么性能隐患? 最大的隐患是单点网络瓶颈和 Redis CPU 阻塞。Lua 脚本在 Redis 中是单线程原子执行的,如果网关单机并发极高,所有请求都在等待 Redis 响应,会导致网关 Worker 进程被严重阻塞。对于 10万+ QPS 的限流,千万别用纯 Redis 强一致限流,必须退化为本地内存限流为主,Redis 异步同步配额为辅的架构(类似 Sentinel 的集群限流机制)。

    Q3:Envoy 的 Circuit Breaker 和 Outlier Detection 有什么本质区别? 这是个极度容易混淆的概念。Envoy 的 Circuit Breaker 本质上是“连接池限制”,比如 max_requests: 1000,超出了直接本地决断拦截返回 503(抛出 UO)。它防御的是“我(Client)发出的并发太多了”。 而 Outlier Detection 才是传统意义上的熔断(类似 Netflix Hystrix),它通过统计后端节点返回 5xx 的频率,将坏节点剔除出负载均衡池。它防御的是“他(Server)坏了,我不要再把请求发给他”。排查时必须严格区分这两种动作产生的不同报错标识。

  • 跨AZ专线抖动引发的全局雪崩:揭穿“伪双活”架构的遮羞布

    某次生产环境突发全站504报错,核心交易链路QPS从2万直降为0,监控大屏一片通红。排查结论极度低级:所谓的“同城双活”架构,仅仅是接入层和无状态计算层的双活,底层核心数据依然强依赖AZ1(可用区1)的单点主库。AZ2到AZ1的跨机房专线仅仅出现了持续约3秒、峰值200ms的延迟抖动,就直接耗尽了AZ2业务线的DB连接池;随后,全局网关层触发“无脑重试风暴”,将原本毫无问题的AZ1主库瞬间打挂,引发全局雪崩。

    解决跨机房架构问题,不从“故障域隔离”入手,光在接入层搞几个VIP负载均衡,纯属自欺欺人的PPT架构。

    现场还原:一根光纤引发的血案

    排查过程中,最直观的现象是全局入口Nginx疯狂抛出504:

    [error] 24155#0: *13444521 upstream timed out (110: Connection timed out) while reading response header from upstream...
    

    登录AZ2的业务容器抓取堆栈,发现大量线程处于 WAITING 状态,全部阻塞在 HikariCP 连接池获取连接上:

    "http-nio-8080-exec-15" #45 daemon prio=5 os_prio=0 tid=0x00007f8a1c0b8800 nid=0x2b waiting on condition [0x00007f89d413a000]
       java.lang.Thread.State: TIMED_WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
        at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:188)
        ...
    

    进一步查看AZ1核心MySQL主库状态,平时常态下 Threads_running 只有几十,此时已经飙升到系统极限,Load Average 直接破百:

    mysql> show global status like 'Threads_running';
    +-----------------+-------+
    | Variable_name   | Value |
    +-----------------+-------+
    | Threads_running | 2548  |
    +-----------------+-------+
    
    $ uptime
     14:22:13 up 145 days,  2:11,  1 user,  load average: 184.32, 138.11, 89.45
    

    致命的逻辑漏洞:为什么犯错不可原谅?

    这套被吹得天花乱坠的“同城双活”架构,存在两个极其致命的设计缺陷,这也是为什么我会说它不可原谅:

    1. 掩耳盗铃的“跨机房同步写” 在双活架构设计中,最大的大忌就是跨AZ同步RPC/DB调用。 业务侧在AZ2处理请求,却要跨越几十公里的物理专线去读写AZ1的MySQL主库。光速不可变,物理专线常态延迟在 2-3ms 左右,看似很快,但只要遇到网络设备的微小抖动(丢包重传导致延迟突增至200ms+),单个请求占用数据库连接的时间就被放大了100倍。 高并发场景下,连接池(通常配置 maximumPoolSize=50)会在几百毫秒内被彻底抽干。随之而来的就是应用层线程全量阻塞,引发AZ2假死。

    2. 盲目自信的全局重试策略 如果仅仅是AZ2挂了,还不至于全站崩溃,毕竟AZ1依然存活。真正补上致命一刀的,是网关层的重试配置:

    proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
    proxy_next_upstream_tries 3;
    

    当网关发现AZ2的节点超时,它体贴地将流量全部重试到了AZ1。同时,C端用户的焦躁疯狂刷新,导致系统的实际请求量瞬间飙升了数倍。 此时的AZ1不仅要承受原有的流量,还要接管AZ2的灾备流量,外加几倍的重试洪峰。AZ1的数据库在没有做好任何限流、降级准备的情况下,瞬间被连接数打爆,彻底陷入死锁。

    真正的多活架构,核心是故障域的严格物理隔离。如果AZ2的生存强依赖于AZ1,那么它们在逻辑上依然属于同一个单点故障域。

    破局与架构纠偏

    针对这类“伪双活”架构的改造,没有捷径可走。以下是止血和根治的几个核心落地点:

    配置层面的防御性加固(快速止血):

    • 严控连接池超时机制: 绝不允许应用无限制地等待连接。将 HikariCP 的 connectionTimeout 严格控制在 1000ms 以内,拿不到连接直接 Fast Fail,保住Tomcat/Undertow的工作线程。

    • 砍掉无意义的网关重试: 对于非幂等或高耗时的核心写接口,一律禁止在网关层做 proxy_next_upstream 重试。重试只会让本就拥堵的链路雪上加霜。

    • 引入断路器: 在微服务侧或网关侧全面接入 Resilience4j/Sentinel,当检测到目标AZ的接口处于高延迟或高失败率时,果断熔断降级。

    架构层面的重构(彻底根治):

    • 单元化改造(Set化): 真正的双活必须将数据层也切分。通过路由网关(如基于 UserID 哈希),将用户固定在某个AZ。AZ内形成闭环(App -> Cache -> DB 均在本AZ),AZ之间通过 DRC(如 Canal/Otter)进行底层Binlog的异步双向同步,彻底切断跨AZ的强依赖同步调用。

    💡 排查清单:跨机房/双活架构高可用速查

    1. 链路依赖闭环检查: 梳理核心链路,确认单个可用区(AZ)内部的计算、缓存、数据库调用是否形成闭环,是否存在隐藏的跨机房同步读写。

    2. 连接池超时配置审查: 检查所有服务端的数据库连接池(HikariCP/Druid)、Redis连接池(Jedis/Lettuce)以及 HTTP Client 的连接/读取超时时间,确保没有任何一项使用默认的无限期等待配置。

    3. 网关/RPC重试策略排查: 检查 Nginx/Envoy 及 Dubbo/gRPC 的重试次数配置,评估在单机房故障时,重试机制是否会引发倍数级流量放大导致雪崩。

    4. 数据库连接堆积监控: 在监控大盘强化针对 Threads_runningThreads_connected 的突增告警,结合网络层的跨AZ丢包率指标(Ping Loss)进行组合分析。