在 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_route 和 order_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,就觉得高并发问题解决了。
真正的高可用不是单点防御,网关负责速率整形,下游自己做并发隔离和熔断降级,存储层做连接池的硬保护。每一层守好自己的边界,系统才扛得住真实的大流量。
网关限流保的是入口不被冲垮,下游并发隔离保的是服务内部不被憋死,两件事缺一不可。