找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4825

积分

0

好友

627

主题
发表于 1 小时前 | 查看: 5| 回复: 0

在 Spring Cloud Gateway 的使用中,大家通常会挂上官方提供的 RequestRateLimiter 过滤器,底层配合 Redis 做令牌桶限流。直觉上,只要把入口 QPS 限制住,下游服务就能稳稳当当撑过去。

但实际情况往往不是这样:大促开始那一瞬间,网关控制台一片绿色,限流规则稳定拦截超额请求,429 Too Many Requests 如期返回。可回过头看下游的订单服务,CPU 瞬间飙到 100%,Tomcat 线程池全部打满,数据库连接池被耗尽,接口大面积超时,整条链路迅速雪崩。

限流配置明明没问题,请求也确实被挡住了,下游为什么还是会挂?

这个问题的坑,其实不止一个,我们一层层拆开看。

01 网关限流是怎么配的

Spring Cloud Gateway 中最常见的是基于 Redis 的分布式限流配置,引入 spring-boot-starter-data-redis-reactive 后在路由上挂载 RequestRateLimiter

spring:
  cloud:
    gateway:
      routes:
        - id: order_service_route
          uri: lb://order-service
          predicates:
            - Path=/order/**
          filters:
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@apiKeyResolver}"
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200
                redis-rate-limiter.requestedTokens: 1

配合一个根据用户 ID 分流的 KeyResolver

@Configuration
public class RateLimiterConfig {

    @Bean
    public KeyResolver apiKeyResolver() {
        return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"))
                .defaultIfEmpty("anonymous");
    }
}

参数含义:replenishRate: 100 是每秒补充令牌数,也就是平稳 QPS 上限;burstCapacity: 200 是桶的最大容量,用于吸收短时抖动;requestedTokens: 1 表示每个请求消耗 1 个令牌。

低并发压测阶段,一旦发包速率超过 100 QPS,多余请求收到 429,一切符合预期。但突发流量一来,系统照样扛不住。

网关限流保护下游服务对比示意图

02 原因一:QPS 限流只管进门速度,不管屋里人数

很多人直觉认为,把入口 QPS 限制在 100,下游承受的负载就等同于 100 QPS 的压力。

这个直觉是错的,吞吐量(QPS)和在途并发数(Concurrency)根本不是一回事。

分布式系统里有一个经典结论:

在途并发数 = QPS × 平均响应时间(RT)

正常情况下,下游订单接口平均耗时 20ms

  • 100 QPS 的压力下,同时在处理的请求数是 100 × 0.02 = 2 个并发
  • Tomcat 线程池默认 200 线程,2 个活动线程连热身都谈不上

但突发流量往往伴随着高并发下的资源竞争,比如秒杀扣库存时数据库行锁排队,Redis 热点 key 被打满,接口平均耗时可能瞬间从 20ms 劣化到 2000ms。这时候再看时序:

网关与下游服务交互状态流程图

网关的限流规则一分钟都没有失效,它每一秒都精准地只放行了 100 个请求。但因为下游处理变慢了,请求在下游的内存和线程池里不断堆积。网关管的是请求进入系统的速率,根本无法感知下游当前有多少请求还在途。

限流与并发堆积过程流程图

没有并发上限保护,慢下来的下游就是一个蓄水池,迟早把自己淹死。

03 原因二:burstCapacity 的脉冲流量

再看配置里的 burstCapacity: 200

令牌桶的逻辑是:以恒定速率 replenishRate 往桶里放令牌,桶满了溢出。如果系统有一段时间没有流量,桶里就会积满 burstCapacity 也就是 200 个令牌。

秒杀整点 00:00:00.000,200 个请求在 1ms 内 蜂拥而至:

00:00:00.000  桶内余量 = 200
请求 #1~#200  全部拿到令牌,网关在 1ms 内全部放行

下游服务在 1ms 内瞬间接收了 200 个并发请求,而不是 1 秒内均匀分布的 100 个。如果下游的数据库连接池默认只有 10~20 个连接,这 200 个请求同时打过去,连接池瞬间耗尽,所有线程都在等待获取连接。

令牌桶限流机制脉冲冲击示意图

burstCapacity 本意是吸收流量抖动,但对于依赖关系型数据库、处理耗时波动大的服务,这个桶容量会变成下游无法承受的脉冲冲击。

04 原因三:多路由让限流配额翻倍

生产环境 Gateway 很少单节点部署,通常 2~4 个实例做高可用。虽然 RedisRateLimiter 基于 Redis + Lua 脚本实现分布式限流,多节点共享同一个令牌桶,集群总流量理论上不会超标。

很多团队会根据业务把路由拆细,比如订单详情和订单支付各一个 Route,分别挂上限流:

spring:
  cloud:
    gateway:
      routes:
        - id: order_normal_route
          uri: lb://order-service
          predicates:
            - Path=/order/detail/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100

        - id: order_pay_route
          uri: lb://order-service
          predicates:
            - Path=/order/pay/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100

RedisRateLimiter 在生成 Redis Key 时,默认会把 Route ID 拼进去:

request_rate_limiter.{routeId}.{apiKey}

order_normal_routeorder_pay_route 用的是两个完全独立的令牌桶。两个接口同时面临流量的时候,下游 order-service 承受的总流入速率是 100 + 100 = 200 QPS,直接翻倍,但每个 Route 看自己的桶都觉得"没超"。

还有一种更容易被忽视的:有些项目为了减少 Redis RTT 损耗,改用 Guava RateLimiter 或 Bucket4j 做节点本地限流。这时候,单机配置 100 QPS,部署了 5 台网关实例,实际放进下游的总流量就是 5 × 100 = 500 QPS。每台网关都觉得自己没超限,下游接到的却是预期的 5 倍。

05 原因四:KeyResolver 的两个隐形漏洞

回头看最开始那段 KeyResolver

@Bean
public KeyResolver userKeyResolver() {
    return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"))
            .defaultIfEmpty("anonymous");
}

隐患一:未登录请求、爬虫、恶意刷接口的流量,请求头里没有 X-User-Id,全部 fallback 成 anonymous。这些请求共享同一个令牌桶,几十万个恶意请求瞬间把 100 个令牌抢光,正常未登录用户全被误杀,批量收到 429。

隐患二:如果 KeyResolver 某些情况下返回的是 Mono.empty() 而不是一个具体的 Key:

@Bean
public KeyResolver emptyKeyResolver() {
    // 没有 defaultIfEmpty,header 不存在时返回的是 Mono.empty()
    return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"));
}

Spring Cloud Gateway 对空 Key 的处理由 denyEmptyKey 参数控制,如果这个值被误设为 false

spring:
  cloud:
    gateway:
      filter:
        request-rate-limiter:
          deny-empty-key: false # 被误设为 false

任何解析不到 Key 的请求将直接跳过限流过滤器,全部放行。突发流量如果正好全是不带 Token 的未鉴权请求,整条限流规则形同虚设。

06 原因五:高峰时刻 Redis 自己先撑不住

Spring Cloud Gateway 基于 Netty + Reactor,是纯异步非阻塞模型,吞吐量极高。每个请求到达 RequestRateLimiter,网关都要通过 Lettuce 执行一段 Lua 脚本:

-- request_rate_limiter.lua 核心逻辑片段
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)

local last_tokens = tonumber(redis.call("get", tokens_key))
-- 计算补充并扣减...

几万 QPS 的突发流量瞬间涌入,Redis 单线程 CPU 被密集的 Lua 脚本执行打满,响应延迟从正常的 0.5ms 飙到 20ms 以上。网关 Netty 的 EventLoop 线程开始积压,大量 Redis 命令触发超时,RedisCommandTimeoutException 开始批量出现。

限流过滤器一旦和 Redis 通信异常,基于高可用设计的容错逻辑会选择放行请求:

[WARN] Error determining whether user is allowed: Redis command timed out
-> 触发容错逻辑,请求被直接透传至下游服务!

最需要限流的关键时刻,限流组件自己先被拖垮,闸门大开,下游瞬间崩了。

07 怎么做才能真正防住

要彻底解决这个问题,光靠网关配一个令牌桶远远不够。速率、并发、下游自保,三个维度都得守。

把 burstCapacity 收紧,别让脉冲打穿下游。 对依赖数据库的核心接口,突发容量贴近生成速率,不要留太大余量:

redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 110 # 只允许 10% 左右的抖动空间
redis-rate-limiter.requestedTokens: 1

宁可让客户端重试,也不要一次性放行几倍的脉冲流量。

网关做速率整形,下游自己做并发隔离。 接入 Sentinel 或 Resilience4j,按并发线程数而不是 QPS 做限流:

@SentinelResource(value = "createOrder", blockHandler = "handleBlock")
public OrderVO createOrder(OrderDTO dto) {
    // 即使网关放进来 200 个请求,下游最多只允许 20 个线程同时在跑
    // 超出的当场快速失败,绝不拖垮数据库连接池
    return orderService.doCreate(dto);
}

public OrderVO handleBlock(OrderDTO dto, BlockException ex) {
    throw new BusinessException("系统繁忙,请稍后再试");
}
[
  {
    "resource": "createOrder",
    "grade": 0,     # 0 表示并发线程数,1 表示 QPS
    "count": 20     # 最多允许 20 个并发线程
  }
]

并发隔离之后,就算下游因为行锁排队响应时间从 20ms 劣化到 2s,也只占 20 个线程,其余 180 个 Tomcat 线程照常服务其他接口,不会全盘崩。

系统架构与并发隔离流量控制流程图

KeyResolver 的健壮性要认真对待。 denyEmptyKey 必须设成 true,解析不到 Key 的请求一律拒绝,不能默默放行:

spring:
  cloud:
    gateway:
      filter:
        request-rate-limiter:
          deny-empty-key: true
          empty-key-status-code: 401

KeyResolver 本身也要做复合策略,有登录态按用户 ID 限流,没有登录态按真实客户端 IP 兜底:

@Bean
public KeyResolver compositeKeyResolver() {
    return exchange -> {
        String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
        if (StringUtils.hasText(userId)) {
            return Mono.just("user_" + userId);
        }
        String clientIp = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
        return Mono.just("ip_" + clientIp);
    };
}

08 说在最后

不是在网关配了 RateLimiter,就觉得高并发问题解决了。

真正的高可用不是单点防御,网关负责速率整形,下游自己做并发隔离和熔断降级,存储层做连接池的硬保护。每一层守好自己的边界,系统才扛得住真实的大流量。

网关限流保的是入口不被冲垮,下游并发隔离保的是服务内部不被憋死,两件事缺一不可。




上一篇:John Ternus接任苹果CEO,秋季发布会首秀将推iPhone 18 Pro与折叠屏
下一篇:NaviDC-OCR 文档解析系统解读:从矩形框 OCR 到形变感知与结构解耦
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-6 10:11 , Processed in 0.805850 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表