网关接入层做限流,Spring Cloud Gateway 里最常用的就是RequestRateLimiterGatewayFilterFactory。我第一次用它的时候,以为这玩意儿就是个配置项:写两行 YAML,接个 Redis,完事。结果压测第一轮就把后端打挂了,回头把这一整套源码一层层剥开,才发现自己连“限流 key”都没完全配明白。这篇文章我用个人读源码的视角,把RequestRateLimiterGatewayFilterFactory从注册、配置、执行到限流算法内核的完整链路拆一遍,顺带把我在实际项目里踩过的坑和调参经验交代清楚。刚接触网关限流想做原理扫盲的,或者正在线上排查 “为什么限流失效 / 为什么全被拦截” 的,都可以参考。
1. 过滤器从哪来:先弄懂 RequestRateLimiter 在网关里的位置
1.1 理解 GatewayFilterFactory:配置文件里的 name 是怎么找到类的
Gateway 里所有的路由过滤器,都不是直接写死一段逻辑,而是通过GatewayFilterFactory这个工厂类生产的。工厂负责解析配置里的name和args,生成真正的GatewayFilter实例,再塞进路由的过滤器链中。Spring 的命名规则很直白:RequestRateLimiterGatewayFilterFactory对应配置里的name: RequestRateLimiter,框架会自动去掉 “GatewayFilterFactory” 后缀去匹配 Bean。
这一点很重要,因为在排查问题时,如果你发现“配置写了但过滤没生效”,十有八九是路由压根没有挂上这个过滤器,或者挂错了位置。工厂模式的另一个好处是复用:同一个工厂可以根据不同args生成不同的过滤器实例,分别挂在不同路由上,互相不干扰。
1.2 限流过滤器在路由过滤器链中的位置
RequestRateLimiter是一个标准的路由过滤器,它和StripPrefix、AddRequestHeader、RewritePath这类过滤器一样,被放入路由的 filter 列表中,按配置顺序执行。它不像GlobalFilter那样全局自动生效,必须显式配置在某个路由的 filters 里。
在一条路由的过滤器链中,位置不同,效果差异很大。举个我踩过的例子:如果先执行RewritePath把请求路径改写,再执行限流,那么 KeyResolver 里如果基于原始请求路径解析 key,拿到的数据就可能和实际不一致。所以我一般建议把限流过滤器放在路由过滤器列表靠前的位置,尽量让它基于原始的请求信息做判定,避免被其他过滤器影响。
1.3 没有 Redis 时的备选限流器
RequestRateLimiterGatewayFilterFactory本身不实现限流算法,它内部依赖一个RateLimiter接口。默认实现是RedisRateLimiter,通过 Redis 分布式计数。但在较新版本里,框架也内置了InMemoryRateLimiter,这是一个本地内存版的限流器,不需要 Redis 也能跑。
我在本地调试和单元测试时会特意切到InMemoryRateLimiter,避免测试环境还要起一个 Redis 依赖。生产环境如果只有单实例、且对限流精度要求没那么高,本地限流也能用。但多实例部署时,本地限流器是各算各的,总和会变成“实例数 × 单实例阈值”,这时候必须用 Redis 版才能保证全局一致。
2. 工厂的源码逻辑:从 apply 到 isAllowed
2.1 Config 里到底放了哪些参数
RequestRateLimiterGatewayFilterFactory内部定义了一个静态内部类Config,字段不算多,但每个字段背后都有讲究:
| 字段 | 类型 | 含义 |
|---|---|---|
keyResolver | KeyResolver | 限流 key 的解析器,决定“按什么维度限流” |
rateLimiter | RateLimiter | 真正执行限流算法的组件 |
denyEmptyKey | boolean | key 为空时是否直接拒绝请求 |
emptyKeyStatus | HttpStatus | key 为空且拒绝时返回的状态码 |
routeId | String | 当前路由 ID,框架在路由组装时注入 |
routeId这个字段很多人不知道。它是在路由构建阶段被框架通过特殊机制注入到 Config 中的,用来在调用rateLimiter.isAllowed(routeId, key)时标记“这是哪条路由的限流计数”。如果你在看源码时发现Config里有个routeId但不知道哪里来的,就是这里注入的。
2.2 apply 方法核心代码结构逐行解读
apply(Config config)是这个工厂最核心的方法,我在这里给出简化但完整表达主流程的代码结构:
@Override public GatewayFilter apply(Config config) { KeyResolver resolver = config.keyResolver != null ? config.keyResolver : defaultKeyResolver; RateLimiter limiter = config.rateLimiter != null ? config.rateLimiter : defaultRateLimiter; return (exchange, chain) -> resolver.resolve(exchange) .defaultIfEmpty(EMPTY_KEY) .flatMap(key -> { if (EMPTY_KEY.equals(key)) { if (config.denyEmptyKey) { exchange.getResponse().setStatusCode(config.emptyKeyStatus); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } return limiter.isAllowed(config.routeId, key) .flatMap(result -> { for (Map.Entry<String, String> header : result.getHeaders().entrySet()) { exchange.getResponse().getHeaders().add(header.getKey(), header.getValue()); } if (!result.isAllowed()) { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }); }); }这段代码的信息量比表面看起来大得多。
第一步,resolver.resolve(exchange)返回的是一个Mono<String>,也就是限流 key 的解析是异步的。这里有个容易被忽略的点:KeyResolver 里千万不要做耗时操作,比如查数据库、调远程接口,否则它会阻塞整个过滤器链的响应速度。
第二步,defaultIfEmpty(EMPTY_KEY)处理 key 为空的情况。如果解析出来的 key 是空字符串或 null,会被替换成一个特殊占位符。此时如果denyEmptyKey=true,直接返回emptyKeyStatus指定的状态码,默认是 403;如果为 false,就直接放行。这里就是网上常说的“匿名请求被 403 拦截”的根源。
第三步,调用limiter.isAllowed(routeId, key)。返回的Response里包含allowed布尔值和一组响应头。无论允不允许,过滤器都会先把这些响应头写到响应里,方便客户端感知限流状态。如果不允许,设置 429 并setComplete(),请求到此结束,不再往下游转发。
2.3 KeyResolver:限流 key 的解析规则
KeyResolver是决定限流粒度的关键。官方默认提供的是PrincipalNameKeyResolver,它从exchange.getPrincipal()获取当前认证用户的用户名。换句话说,如果你没接任何认证体系,这个 resolver 基本解析不出内容,得到的就是空 key。
自定义 KeyResolver 很常见,核心就是实现接口:
public interface KeyResolver { Mono<String> resolve(ServerWebExchange exchange); }返回什么字符串,就代表按什么维度计数。同一个 key 的请求共享同一个令牌桶,不同 key 各算各的。
3. 限流算法内核:RedisRateLimiter 与令牌桶
3.1 令牌桶的三个核心参数与换算关系
RedisRateLimiter有自己的配置类,最核心的三个参数是:
| 参数 | 配置项 | 默认值 | 含义 |
|---|---|---|---|
replenishRate | redis-rate-limiter.replenishRate | 1 | 每秒向桶内补充的令牌数 |
burstCapacity | redis-rate-limiter.burstCapacity | 1 | 桶的最大容量,允许瞬间放行的最大令牌数 |
requestedTokens | redis-rate-limiter.requestedTokens | 1 | 每个请求消耗的令牌数 |
用一个生活化类比:桶就是一个水池,burstCapacity是池子的最大蓄水量,replenishRate是水龙头的注水速度。请求进来需要从池子里舀走requestedTokens勺水,池子里有水就放行,没水就拒绝。
这个模型解释了令牌桶和固定窗口限流的本质区别:固定窗口在窗口边界会有突刺,而令牌桶允许一定程度的突发流量,只要桶里还有存量。比如replenishRate=10、burstCapacity=20,前两秒内最多能瞬间放 20 个请求,之后每秒恢复 10 个配额。如果瞬时流量超过 20,多余部分只能等桶被慢慢注满。
3.2 Lua 脚本的令牌桶逻辑还原
RedisRateLimiter的真正核心是 Lua 脚本。用 Redis 执行限流脚本,最大的优势是原子性:判断、扣减、补充令牌在同一个脚本里完成,不需要加锁,也不需要多轮网络往返。官方脚本做了不少边界处理,我这里给出核心逻辑的还原版本,方便理解算法本质:
local rate = tonumber(ARGV[1]) local burst = tonumber(ARGV[2]) local requested = tonumber(ARGV[3]) local now = redis.call('TIME')[1] local tokens = tonumber(redis.call('GET', KEYS[1])) if not tokens then tokens = burst end local lastRefill = tonumber(redis.call('GET', KEYS[1] .. ':timestamp') or '0') local delta = math.max(0, now - lastRefill) -- 每次请求时先把这段时间新产生的令牌补充进桶里 tokens = math.min(burst, tokens + delta * rate) if tokens >= requested then redis.call('SET', KEYS[1], tokens - requested) redis.call('SET', KEYS[1] .. ':timestamp', now) return {1, tokens - requested} else -- 还差多少秒才能凑够 requested 个令牌 local retryAfter = (requested - tokens) / rate return {0, retryAfter} end脚本里最关键的是lastRefill时间戳。它记录的是这个 key 上一次补充令牌的时刻,而不是固定窗口的起点。每次请求进来,先算从上次补充到现在过去了多少秒,再按rate补充令牌,但总量不能超过burst。这样设计的好处是:就算某个 key 很久没被访问,令牌也不会一直积攒到超过桶容量,同时恢复速率始终保持稳定。
要注意,这里的时间粒度是秒,使用的是 Redis 的TIME命令,而不是应用服务器的本地时间。原因很直接:多实例部署时,每台机器的时钟可能有偏差,而 Redis 的时钟是唯一的基准,能保证分布式环境下计算一致。
3.3 允许与拒绝时的响应头里到底放了什么
RedisRateLimiter.isAllowed()返回的Response会携带一组响应头。通过这组头,客户端可以清楚感知当前限流状态。比如允许时,大致会带上:
X-RateLimit-Remaining:当前 key 剩余令牌数X-RateLimit-Burst-Capacity:桶容量X-RateLimit-Replenish-Rate:补充速率
拒绝时还会带上X-RateLimit-Retry-After之类的字段,提示调用方大约需要等多久再重试。在实际排查问题的时候,我会用curl -i直接看响应头,一眼就能判断限流是否生效、key 是否按预期隔离。
curl -i http://localhost:8080/order/list如果看到响应头里带了X-RateLimit-*,说明过滤器已经执行了;如果没有,说明路由配置有问题,或者 KeyResolver 返回的是空 key 且被放行了。
4. 配置与实战:从 YAML 到自定义 KeyResolver
4.1 一支配好的 RequestRateLimiter 配置长什么样
先给一份可直接参考的最小配置:
spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: "10" redis-rate-limiter.burstCapacity: "20" key-resolver: "#{@ipKeyResolver}"这里有几个细节需要特别说明。
第一,key-resolver通过 SpEL 表达式引用一个 Spring Bean,所以自定义的KeyResolver必须注册为 Bean,且名称要和表达式里的名字一致。我见过不少新手在这里踩坑,Bean 名写错或没有注册,启动后报错或者限流失效。
第二,redis-rate-limiter.replenishRate和redis-rate-limiter.burstCapacity这两个 key 里的前缀redis-rate-limiter.不是随便写的。RedisRateLimiter在解析配置时会按这个前缀去 Config 属性映射里取值。换成别的前缀,框架就不认识了。
第三,args 里的数字值建议用双引号包起来。YAML 里不包也能解析,但为了避开某些版本里类型转换的怪问题,写成字符串更稳。
4.2 自定义 KeyResolver:按用户、按 IP、按接口取 key
限流维度决定了这套限流方案到底是什么粒度的。最常用的几种维度,我实际都用过。
按客户端 IP:
@Bean public KeyResolver ipKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getRemoteAddress() .map(addr -> addr.getAddress().getHostAddress()) .orElse("unknown") ); }按用户 ID,需要从认证信息中取:
@Bean public KeyResolver userKeyResolver() { return exchange -> exchange.getPrincipal() .map(principal -> principal.getName()) .switchIfEmpty(Mono.just("anonymous")); }按接口维度,直接取请求路径:
@Bean public KeyResolver apiKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getURI().getPath() ); }这里必须提醒一件事:不同维度的 key 解析策略不要混用。比如同一个路由今天挂 IP 维度,明天换用户维度,Redis 里的 key 体系会变得混乱,旧数据也不会自动清理。最好的做法是在项目里统一一个主维度,其他维度通过组合 key 实现,比如"user:" + userId + ":api:" + path。
4.3 多实例部署下的限流影响范围
用 RedisRateLimiter 做限流,影响范围是整个网关集群共享的。只要 key 相同,无论请求打到哪台实例,计数都是同一份。这也是它比本地限流器更适合生产环境的原因。
但要注意反向的一个认知误区:三台实例都配成replenishRate=10,总限流值还是每秒 10,不是每秒 30。很多人以为实例多了配额可以叠加,实际上 Redis 计数是全局的,配置的阈值就是整个集群的阈值。如果你希望三台机器一共承担 30 QPS,正确配法是每台都写 30,而不是写 10。这样设计也带来一个额外好处:动态扩容时不需要重新计算每个实例的配额,网关整体能力保持不变。
另外一个容易被忽略的影响是 Redis 的访问压力。每个经过限流过滤器的请求都会触发一次 Redis Lua 脚本调用。虽然脚本本身很快,但高并发下对 Redis 实例还是会产生不小的 QPS。我们曾经在压测中发现,限流过滤器开启后,Redis 的访问量基本等于网关总请求量。所以生产环境里限流用的 Redis 尽量不要和业务核心 Redis 共用实例,或者至少做好监控。
5. 被限流后返回什么:429 响应定制与过滤器协作顺序
5.1 被限流时默认返回什么
从apply()源码可以看到,一旦result.isAllowed()返回 false,过滤器会执行:
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); return exchange.getResponse().setComplete();也就是说,网关会直接返回 429,且响应体默认是空的。如果你的下游客户端对响应体格式有强要求,比如必须返回一个统一的 JSON 错误结构,那么默认的空 body 是不够用的。
5.2 输出统一 JSON 格式 429 的几种思路
我在项目里试过几种方案,最终留下的是自定义扩展这条路径。
思路一:全局异常处理器。这条路对普通异常有效,但限流的 429 不是通过抛异常触发的,而是直接写状态码并结束响应,所以不少异常处理器根本接不到这个场景。
思路二:在网关后面再包一层过滤器,在链路返回后判断响应状态码。这条路也有问题,因为源过滤器已经setComplete(),响应体已经定型,后置过滤器再想往 body 里写内容会报错。
思路三,也是我实际推荐的:写一个内部扩展类,继承RequestRateLimiterGatewayFilterFactory,重写apply(),在result.isAllowed()为 false 时,先设置响应头、状态码,再写入自定义 JSON 字节流,最后setComplete()。核心代码思路是这样:
if (!result.isAllowed()) { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes = "{\"code\":429,\"msg\":\"too many requests\"}" .getBytes(StandardCharsets.UTF_8); DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes); return exchange.getResponse().writeWith(Mono.just(buffer)); }这种方式虽然代码量多一些,但可控性最强,限流响应的格式完全由自己定义,也方便后续接入监控系统提取错误码。
5.3 过滤器顺序对限流结果的影响
路由过滤器之间的顺序由配置顺序决定。讨论限流时,最常见的问题是:限流过滤器放在RewritePath之前还是之后?
我建议放在前面。原因很简单:限流的目的是保护后端资源,越早拦截,资源消耗越少。如果放在链路后面,请求已经经过了路径改写、参数处理,甚至可能已经建立了一些连接资源,再被限流拦截,成本就高了。另外,有些过滤器会改变请求的 URI、Header,如果 KeyResolver 依赖这些信息,顺序不同会直接影响 key 的稳定性。
6. 限流参数调优与线上避坑:我踩过的 5 个典型问题
6.1 最容易忽略的 denyEmptyKey 与匿名请求冲突
我遇到过最隐蔽的问题:网关限流配置好后,所有匿名请求全部返回 403,但带 token 的请求完全正常。查了半天,发现是默认PrincipalNameKeyResolver解析匿名请求时拿不到用户名,返回了空 key,而denyEmptyKey默认是 true,于是空 key 请求全部被拒绝。
如果你的路由允许匿名访问,要么换成 IP 维度或自定义 KeyResolver,要么在配置里显式设置:
filters: - name: RequestRateLimiter args: denyEmptyKey: "false"这里还要强调:denyEmptyKey和限流本身是两回事。它是空 key 时的兜底策略,不要为了保证限流必生效而盲目开成 true,否则很容易误伤合法匿名流量。
6.2 参数单位是秒,不是分钟
很多人第一次配置时,以为replenishRate=100是每分钟 100 次,实际上它是每秒补充 100 个令牌。两个概念的差别体现在突发流量上:如果每秒补 100、桶容量 200,前两秒可以瞬间放 200 个请求,之后每秒最多 100。如果你需要的是“每分钟最多 100 次”,按每秒补充约 1.67 个令牌来配,同时要考虑令牌桶的突发特性是否满足业务要求。
调参时我给自己的一个参考步骤是:先定业务能承受的瞬时并发峰值,把它设为burstCapacity;再定长期持续的平均 QPS,把它设为replenishRate。这样既允许突发流量,又不会让后端长期过载。
6.3 Redis 抖动时限流器的表现
限流器依赖 Redis,那 Redis 抖动或不可用时会发生什么?这是线上最让人紧张的问题之一。默认行为下,RedisRateLimiter执行 Lua 脚本失败会抛出异常,最终可能导致请求走 500 错误,而不是放行或限流。
所以,在引入限流组件之前,要先想好 Redis 不可用时的降级策略。比较常见的是:在限流器外层加一个熔断开关,Redis 连续失败 N 次后自动降级为放行,等 Redis 恢复再重新启用。也有人选择降级为本地InMemoryRateLimiter,这样至少保证有限流能力,但多实例总和会变成各实例配额叠加。没有标准答案,取决于你的业务是“更怕被打挂”还是“更怕限流失效”。
6.4 配置了 requestedTokens 之后的变化
requestedTokens默认是 1,也就是一个请求消耗一个令牌。但如果在网关层做了聚合透传,一个客户端操作可能触发多次后端调用,这时候可能希望一次前端请求消耗多个令牌。还有一种场景是网关同时承担多类请求,大请求和小请求希望有不同的消耗权重。
一旦把requestedTokens调大,你要重新核算限流阈值。比如原配置想允许每秒 100 个请求,但requestedTokens=2,实际每秒只能通过 50 个请求。这个参数很容易被忽略,因为官方默认不写,很多人根本不知道它存在。排查“限流数值和预期不一致”时,记得看一眼这个值。
6.5 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 网关不生效,所有请求都放行 | 路由没挂过滤器,或 KeyResolver 解析为空且 denyEmptyKey=false | 检查路由配置,确认 KeyResolver 按预期返回 |
| 匿名请求全部 403 | denyEmptyKey=true + PrincipalNameKeyResolver | 换自定义 KeyResolver 或显式设置 denyEmptyKey=false |
| 配了 100 QPS 却只能过 50 | requestedTokens 不是 1,或 burstCapacity 偏小 | 明确每个请求消耗的令牌数,重新核算 |
| 多实例总 QPS 低于预期 | 各实例共享 Redis 计数,配额没有叠加 | 按网关整体目标配置每实例阈值 |
| Redis 抖动导致大量 500 | RedisRateLimiter 调用失败 | 增加降级策略或切换 InMemoryRateLimiter |
| 响应头没有 X-RateLimit-* | 过滤器未执行或 KeyResolver 返回空 | 用 curl -i 请求直连验证,并检查配置 |
最后再分享一个小技巧。给限流 key 加业务前缀是个好习惯,例如"user:" + userId。这样出问题时,直接连上 Redis 用keys命令就能看到当前哪些 key 在被限流、还剩多少令牌,比对着日志猜快得多。我在实际运维里靠这一招定位过好几次“某个用户被误杀”的问题。
源码读完之后,我最大的体会是:限流器的复杂度不在配置,而在你对业务流量模型的理解。先想清楚限流维度,再定好突发容量和持续速率,最后才动手写配置。顺序反了,参数调来调去都只是为了应付表象而已。