☰
Spring Cloud Gateway限流实战:从令牌桶原理到RedisRateLimiter配置
2026/10/15 8:53:39 网站建设 项目流程

网关这个词这几年在微服务项目里基本成了标配,但很多人把它当反向代理用,路由配好就再也没管过。实际上网关层能做并且应该做的事情远不止转发请求,限流就是其中最典型的一件。今天就把Spring Cloud Gateway限流这个事从头到尾撸一遍,给出能直接用的完整代码案例,再把我实际部署时踩过的几个坑一并交代清楚。

限流的作用不需要再科普了,接口被刷、突发流量把下游打挂、某个调用方异常重试导致服务雪崩,这些场景限流都是第一道防线。但限流放在网关层做,和放在业务接口里做是完全两回事。我建议团队在网关层把通用限流能力先建起来,再针对真正的热点接口做业务级精细化限流,两层配合,既省事也不失真。

适合谁看?正在用Spring Cloud Gateway做统一入口的团队;接口被刷但不想在每个业务服务里重复写限流代码的开发者;以及准备引入网关限流,但对RedisRateLimiter原理不熟悉、担心配置出问题的同学。下面所有代码都是我自己整理过、能在Spring Boot 2.7 + Spring Cloud Gateway 3.x环境跑通的版本,版本不同的话稍作调整即可。

1. 为什么要把限流做在网关层

1.1 网关限流与接口限流的本质区别

想象一下没有网关的情况。三个业务服务各自需要限流,于是每个服务里都加一个拦截器,各自连Redis,各自维护一套限流规则。这种做法不是不行,但有几个非常现实的问题:规则散落在各个服务里,改一个限流阈值要改三个地方;某个服务漏配了限流规则,就成了整个链路的短板;新接入一个服务,还得把限流逻辑再复制一遍。

放在网关层就完全不一样。所有流量本来就是先到网关再转发出去的,在网关统一做限流,等于在唯一入口卡了一道总闸。规则集中在网关里管理,阈值统一调整,新服务接入时什么都不用改,只需要关注路由转发本身。这一点在微服务数量多了之后,体感差异会非常明显。

这么说可能有点抽象,我打个比方。接口限流相当于每间屋子各自装灭火器,网关限流相当于在整栋楼的楼道里装了消防喷淋系统。楼道里的喷淋能拦住大多数火灾,真到屋里的灭火器上场时,说明已经是局部失守了。两者不是互斥关系,而是纵深防御的两个层次。

1.2 Spring Cloud Gateway限流的基本原理

Spring Cloud Gateway内置的RequestRateLimiter过滤器工厂,底层用的是令牌桶算法。令牌桶算法的核心逻辑很直观:桶里按固定速率持续放入令牌,桶放满之后令牌溢出;每个请求进入时尝试从桶里取令牌,取到就放行,取不到就拒绝。

对比漏桶算法的匀速排水,令牌桶允许一定程度的突发流量,只要桶里还有积累的令牌,短时间内的突发请求也能通过,这更贴合大多数互联网应用的流量特征。

网关里具体的载体是Redis。每个请求的限流计数器都存储在Redis中,通过Lua脚本保证高并发下“取令牌-扣减-返回结果”这一整套动作是原子性的。为什么把状态放到Redis而不是网关进程内存里,原因特别实际:生产环境的网关一定是多实例部署的,如果限流状态存在单台网关的内存,每台实例各自记账,总限流效果就会被实例数量稀释。放进Redis后,所有实例共享同一份计数,这是全局限流而不是每台机器的局部限流。

这也是为什么Spring Cloud Gateway的限流必须引入Redis依赖。之前有人在群里只加了spring-cloud-starter-gateway,没加redis reactive,项目启动就报找不到ReactiveRedisTemplate,原因就在这,没Redis就不用谈网关限流。

2. 开工前的准备:依赖、版本与Redis环境

2.1 依赖清单与版本匹配

先看依赖。用Maven的话,pom.xml里至少要包含下面这些:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>

第一行是网关核心依赖。第二行是关键中的关键,限流依赖的Redis响应式客户端就靠它。第三行actuator建议加上,它提供health端点,配合Redis健康检查可以快速定位网关和Redis的连接状态。

版本对应关系我整理过一份实测结论,直接给出来:

Spring Cloud版本Spring Boot版本适配说明
Spring Cloud 2021.0.xSpring Boot 2.6.x - 2.7.x稳定、资料多,新手验证方案推荐
Spring Cloud 2022.0.xSpring Boot 3.0.x - 3.1.x注意javax迁移为jakarta
Spring Cloud 2023.0.xSpring Boot 3.2.x新版网关包名有调整

我本地一直在用Spring Cloud 2021.0.8 + Spring Boot 2.7.x的组合跑演示项目,这个组合的社区讨论量最大、网上可参考的资料也最全,新手建议先用这个复现,再去追新版本。

2.2 Redis配置与Lettuce连接池

Redis不用一开始就上哨兵或者集群,单机实例足够跑通演示。配置文件长这样:

spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 5000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

这里有一个非常容易踩的版本差异:Spring Boot 2.x的Redis配置前缀是spring.redis,到了Spring Boot 3.x变成了spring.data.redis。很多人升级版本后发现限流突然不生效,查半天最后发现是配置前缀没改。

再强调一句,网关是WebFlux响应式模型,默认使用Lettuce连接池,别换成Jedis。Jedis是阻塞式客户端,在网关这种事件循环架构下会拖垮吞吐量。

Redis连接不上时,网关不会启动失败,而是在第一次接收请求并执行限流时抛异常。这个异常如果没有被处理,会以500的形式暴露给客户端。生产环境有必要给网关配置独立的健康检查探针对Redis做连通性探测。

3. 第一个完整案例:基于RedisRateLimiter的令牌桶限流

3.1 application.yml中只需这几行

直接上最简配置:

spring: application: name: gateway-service redis: host: 127.0.0.1 port: 6379 cloud: gateway: routes: - id: example-route uri: http://127.0.0.1:8081 predicates: - Path=/example/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1

这份配置的效果是:对/example/**路径的请求做限流,令牌桶每秒补充10个令牌,桶最大容量20个令牌,每个请求消耗1个令牌。换算成业务语言就是:正常情况下每秒最多放行10个请求,允许短时突发到每秒20个,超出部分直接拒绝。

注意这里的命名。RequestRateLimiter过滤器工厂在读取参数时,限流参数必须带redis-rate-limiter前缀,写成replenishRate会直接报ParameterNotFoundException。这个错误非常常见,我至少帮群里的三五个同学定位过。

3.2 KeyResolver才是限流的关键

上面这份配置看上去能用,但有一个致命问题:所有请求共用一个令牌桶,等于无论请求来自哪个IP、哪个用户,都被当成同一批流量对待。真实场景几乎不会这么用。必须通过KeyResolver告诉网关:按什么维度来区分流量。

最简单的用户维度限流,从请求头里取用户ID作为key:

package com.example.gateway.config; import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import reactor.core.publisher.Mono; @Configuration public class RateLimiterConfig { @Bean public KeyResolver userKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getHeaders().getFirst("X-User-Id") ); } }

然后在路由配置里通过SpEL表达式引用这个Bean:

spring: cloud: gateway: routes: - id: account-route uri: http://127.0.0.1:8081 predicates: - Path=/account/** filters: - name: RequestRateLimiter args: key-resolver: "#{@userKeyResolver}" redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1

这个配置的意思是:同一个用户ID每秒最多请求10次,突发可到20次。不同的用户ID各自独立计数,互不影响。

按IP限流也是一类非常常见的方式,只需要换一个KeyResolver实现:

@Bean public KeyResolver ipKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() ); }

按接口路径维度限流,把请求的URI路径作为key:

@Bean public KeyResolver pathKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getURI().getPath() ); }

这里要提醒一下:userKeyResolver里如果请求头X-User-Id不存在,Mono里会传一个null,这种空值key可能导致限流行为不符合预期,甚至引起不必要的报错。稳妥的做法是取不到用户ID时回退到IP。我给一个处理过的版本:

@Bean public KeyResolver userFallbackIpKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getHeaders().getFirst("X-User-Id") != null ? exchange.getRequest().getHeaders().getFirst("X-User-Id") : exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() ); }

内在逻辑就是先取用户ID,取不到再用IP顶上,宁可让匿名用户共享限流桶,也不能把null写进Redis key。

4. RedisRateLimiter的工作机制深入解读

4.1 令牌桶在Redis里是怎么算的

很多人配置完能用,但对内部机制一知半解,出问题时只能瞎猜。RedisRateLimiter的核心计算过程,简单概括是Redis里存了两个值:当前可用令牌数,以及上次补充令牌的时间戳。

每次请求进来时都会执行一段Lua脚本,脚本内部做的事大致是:

  1. 从Redis读取当前桶里的令牌数和上次补充时间。
  2. 以当前时间和上次补充时间的时间差,乘以每秒补充速率,算出这段时间应该补多少个令牌。
  3. 把补完后的令牌数写入Redis,但不得超过桶容量。
  4. 判断当前桶里的令牌数是否大于等于本次请求要消耗的令牌数。
  5. 返回是否允许放行,以及下次可取令牌的大致时间。

整个过程在Redis服务端原子执行,所以不存在并发扣减出错的问题。这也是网关多实例部署时限流仍然准确的根本原因。

4.2 三个参数怎么搭配才合理

replenishRate代表每秒补充的令牌数,决定平均速率;burstCapacity代表桶容量,决定瞬时能承受的突发请求量;requestedTokens代表每个请求消耗的令牌数,默认为1。

有一个关键使用口径值得单独说:burstCapacity建议大于等于replenishRate。如果桶容量比每秒补充量还小,就会出现一种很尴尬的情况:桶很快被填满,然后因为容量太小又把令牌溢出,请求进来时反而拿不到足够的令牌,导致限流效果比预期严苛得多。

我实际见过一个同事把replenishRate配成100、burstCapacity配成50,结果线上限流效果变成了每秒只有大约50个请求通过,而不是预期的100。查了很久才发现是这个参数关系搞反了。

4.3 当请求被限流时会发生什么

网关对超出限制的请求,默认直接返回HTTP 429状态码,响应体的内容与之相关。我加一个经验性的例子:

我在本地验证时,连续快速请求/example接口,前20个请求正常返回,第21个开始立刻收到429。而且响应头里带上了这些信息:

响应头含义
X-RateLimit-Remaining当前桶里剩余令牌数
X-RateLimit-Burst-Capacity桶容量
X-RateLimit-Replenish-Rate每秒补充速率
X-RateLimit-Requested-Tokens每次请求消耗的令牌数

Debug时这些响应头非常有价值,可以直观看到桶里还剩多少令牌。生产环境如果不想暴露限流细节,可以通过参数控制是否输出这些头。

实际业务里,被限流直接给一个空白429页面体验很差。更通用的做法是把429响应体改成统一的JSON错误信息。我通常的做法是在网关里加一个全局过滤器,检测到响应状态码为429时,主动写入自定义响应体:

package com.example.gateway.filter; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; @Component public class RateLimitResponseGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return chain.filter(exchange).then(Mono.defer(() -> { ServerHttpResponse response = exchange.getResponse(); if (response.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { String json = "{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}"; byte[] bytes = json.getBytes(StandardCharsets.UTF_8); DataBuffer buffer = response.bufferFactory().wrap(bytes); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); return response.writeWith(Mono.just(buffer)); } return Mono.empty(); })); } @Override public int getOrder() { return -1; } }

这样客户端拿到的就是结构化的JSON提示,前端可以直接统一解析并弹出提示。

5. 多维度限流与配置动态刷新

5.1 组合维度限流的编排

实际项目里很少只用单一维度限流,更多是组合策略:普通用户按用户ID限流,匿名用户按IP限流,某个风口接口要单独设置更高的上限。

按不同路由配置不同的KeyResolver和不同的限流参数,这个用网关路由级别的配置就能做。同一个路由下做多级限流,比如既要按用户限流又要按IP限流,可以用两个RequestRateLimiter过滤器叠加:

filters: - name: RequestRateLimiter args: key-resolver: "#{@ipKeyResolver}" redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 - name: RequestRateLimiter args: key-resolver: "#{@userKeyResolver}" redis-rate-limiter.replenishRate: 5 redis-rate-limiter.burstCapacity: 10

这里有个顺序问题必须说清楚:前置的过滤器会先执行,所以IP限流会先于用户限流生效。如果你想让用户维度先被限住,再把两个过滤器的顺序反过来。过滤器的顺序就是配置里的声明顺序,这个坑不踩一次根本想不起来。

5.2 配置固定死的痛点与应对

Gateway默认的限流配置写在yaml里,改参数必须重启网关,这在生产环境是不可接受的。对于参数调整频率不高的团队,可以先接受这种情况;但如果你所在的业务促销节奏快、阈值经常改,那就要考虑把限流参数放到配置中心里管理。

用配置中心管理时,大体思路是把三个限流参数从本地yaml中抽出来,通过配置中心的动态刷新机制实时更新。市面上几个主流配置中心都支持Spring Cloud的配置刷新,网关的RedisRateLimiter本身会重新读取已更新的参数,不需要额外写代码。

这里提示一点:就算参数能动态刷新,单个KeyResolver的维度调整还是需要发版。比如从按IP限流改成按用户限流,这属于代码逻辑变化,动态配置救不了,得走正常的发布流程。

6. 实战中那些坑,我一个个说

6.1 常见问题速查表

把我在交流群和实际项目中见过的高频问题列成一张表,方便排查:

现象大概率原因处理建议
启动报ReactiveRedisTemplate找不到缺少redis reactive依赖引入spring-boot-starter-data-redis-reactive
请求不走限流直接放行过滤器未挂到对应路由上检查filters配置是否写在目标路由下
限流时Redis抛异常返回500Redis连接不稳定或未配置连接信息检查Redis连接串和网络,网关增加降级处理
限流效果比预期严格burstCapacity小于replenishRate调整桶容量大于等于每秒补充速率
多个网关实例限流总量不对key格式冲突或Redis key被多个环境共用网关限流的key加上环境前缀隔离
配置了key-resolver还是共用桶SpEL表达式拼写错误检查是否写成#{@beanName}格式

6.2 “配置了限流但是不生效”的排查路径

遇到过最多的场景就是限流挂上去了,压力测试时发现请求全部正常通过。排查路径我建议按这个顺序来:

第一,确认过滤器确实挂在了目标路由下,尤其是当路由用了多个filter时,注意是不是写进了default-filters而不是routes。第二,检查KeyResolver的Bean能否被正确注入,SpEL表达式拼写错误时启动时不一定立刻报错。第三,验证Redis里是否产生了限流相关的key,手动刷一两个请求后打开Redis客户端查看。有key说明逻辑已触发,没key说明网关压根没执行限流逻辑。第四,压测时的并发请求是否低于限流阈值,这个听起来可笑,但我真见过把限流阈值配成每秒10万,然后拿每秒1000的流量压测,回来报告说限流不生效。

6.3 限流误伤与动态调配

还有一类问题是限流本身效果正常,但误伤正常用户。典型场景是某个低峰时段用户正常操作,突然连续触发几个慢请求,被限流挡了几个小时。原因通常是限流的key维度太粗,把几个不同业务的请求集中到了一个桶里,任何一个业务出现突刺都会影响其他业务。

处理思路是把不同业务的路由拆分出来,各自使用独立的限流配置。路由拆分得越细,限流影响面越小,但配置量也会增加,需要根据团队维护成本做取舍。一般来说,核心交易链路和边缘查询链路不要共用限流桶,这是底线。

另外一个容易被忽略的点是固定窗口周期内的“时间边界”问题。令牌桶算法的补充是连续平滑的,但如果你的业务方感知是“每秒最多10次”,他们在边界处连续打两次可能误以为限流器不准。这种问题通常不用改代码,给业务方讲清楚突发容量的设计意图就好。

7. 更通用的方案:结合熔断与降级

7.1 网关限流和熔断的关系

限流和熔断是一对经常一起出现的词,但解决的不是同一个问题。限流是整个入口的总闸,防止瞬间流量冲垮下游;熔断则是单条链路的保险丝,当下游某个服务出现故障或者响应过慢时,直接短路请求,避免故障在服务间蔓延。

Spring Cloud Gateway本身没有内置熔断器,需要借助Spring Cloud CircuitBreaker过滤器工厂实现。在Gateway中配置的流程和后端服务里的用法不太一样,它是在网关层对转发到下游的请求做熔断探测,一旦下游连续失败超过阈值,网关直接返回降级响应,不再把请求发出去。

7.2 限流与降级响应的组合设计

一个比较完整的网关方案通常是这样的:先做限流,控制总流量入口;再做熔断,保护关键下游链路;最后做降级,给用户一个可理解的兜底结果。

限流返回429,熔断返回503,两者的客户端提示文案应该有所区分。429代表“你的请求太多了”,503代表“服务暂时不可用”。前端可以根据状态码分别展示“操作过于频繁”和“系统繁忙”两种提示,这两种反馈对用户体验的影响差别很大。

我见过一个项目把所有异常都统一返回成500,结果前端把所有异常都弹成“系统错误”,用户根本分不清是自己操作太快还是服务端出了问题。所以从这个角度讲,哪怕只是把状态码区分清楚,都值得在做网关限流时顺手完成。

8. 我对这套方案的使用体会

用到目前这个阶段,我最大的感受是:网关限流不是配个参数就结束的功能,它是一个需要持续观察、持续调参、持续迭代的整体能力。限流阈值设置多少,要结合网关的CPU、下游服务的压力指标以及业务方对延迟的容忍度综合判断。刚开始可以把流量放大到压测环境里去测,多跑几轮不同的并发数,找到下游服务还能稳定扛住的那个临界点,再反推网关的限流参数。

另一个体会是关于异常和监控。生产环境一定要把限流触发次数、Redis命中情况、429响应量作为指标监控起来。没有监控的限流等于摸黑走路,哪天业务流量涨上来了,你只看到监控面板上大片的报错,却说不清是限流阈值不够还是Redis拖慢了性能,到时候排查成本非常高。

最后再分享一个小技巧:Redis里每个限流key都有固定前缀,用Redis客户端按前缀搜索就能快速核对当前一共有多少个限流桶。多环境部署时记得给限流key加上环境标识,防止测试环境和生产环境共用Redis导致限流数据互相污染。这个细节看着不起眼,但真的能让以后排查问题轻松一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询