网关层接口被突发流量打爆,是微服务落地后迟早会遇到的事。我之前在一个模拟电商项目里就吃过教训:订单服务高峰期响应从几十毫秒飙升到几秒,数据库连接池直接被占满,最后整个链路雪崩。后面我把Spring Cloud Gateway限流完整搭起来,用Redis令牌桶挡住了大部分异常流量,服务才恢复稳定。这套方案不需要改业务服务代码,只需要在网关层配置过滤器,按IP、用户、接口维度做限制,就能起到很直接的保护作用。本文就把完整代码案例拆开来讲,适合正在做微服务网关、被突增流量困扰的后端开发,以及对网关限流原理想系统搞清楚的读者。
1. 网关层限流:到底解决什么问题
1.1 真实场景:突发流量把服务打瘫之后
先还原一下我当时遇到的场景。项目里有几个核心服务,平时请求量平稳,但有一次做活动,流量瞬间涨了十倍,网关后面的商品服务、订单服务同时扛不住。事后复盘发现,真正的问题不是单台服务性能差,而是没有在入口做任何限制,所有请求全量打到了下游。服务还没扩容到位,数据库连接、线程池就被打满,甚至影响到其他共用基础组件的服务。
这个场景在微服务架构里非常典型。业务服务往往只关心自己的逻辑,不会主动去做全局性的流量管控。网关作为所有外部请求的第一道入口,天然适合承担限流职责。在网关层做限流,好处很直接:第一,不需要每个服务都实现一套限流逻辑,代码零侵入;第二,可以在流量进入业务服务之前就把超出的部分拒掉,保护的是整个下游链路,而不只是某一个接口;第三,网关配置集中,调整限流阈值只需要改一处,不用逐个服务发版。
需要想清楚的是,网关限流保护的到底是“服务不被打倒”,还是“用户体验公平”。大多数时候我们限流是为了前者,让系统在极端流量下还能保持可用,宁可拒绝一部分请求,也不能让整个服务挂掉。这就决定了限流的维度、阈值设计都应当围绕“保护链路”来展开。
1.2 网关限流的三种实现方式
在Spring Cloud Gateway里做限流,主流方案大致有三种,我实际都调研过,也给新同事讲解时做过横向对比:
- 第一种是使用内置的RequestRateLimiter过滤器工厂,配合RedisRateLimiter实现令牌桶限流。这是最快速的做法,只需要在配置文件里声明过滤器,再提供一个KeyResolver的Bean,不需要写任何算法逻辑。我当前项目的默认方案就是它。
- 第二种是自定义GlobalFilter,在过滤器里直接实现计数逻辑或者基于内存的令牌桶。这种方式灵活,可以拿到请求头、路径、查询参数等所有上下文信息,但要注意:内存限流只对单实例有效,网关多实例部署时统计不共享,限流效果会大打折扣。
- 第三种是接入第三方限流框架,比如Alibaba的Sentinel适配Gateway,或者用Resilience4j的Bulkhead。这类框架功能更强,支持控流、熔断、系统自适应保护,但引入成本和配置复杂度更高,适合团队有统一中间件治理需求的情况。
从性价比和上手速度来看,我推荐大多数项目先用内置RequestRateLimiter,把限流的基础能力跑通,后续再按需引入更重的组件。本文的案例正是基于第一种方案展开。
1.3 限流算法怎么选:一张表对比四种主流方案
限流算法本身是另一个需要理解的关键点。搞清楚了算法差异,才知道内置方案为什么选了令牌桶,也才好判断什么场景该用哪种。
| 算法 | 核心思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 统计每个固定时间窗口内请求数,超过阈值就拒绝 | 实现最简单,内存消耗小 | 窗口边界会出现突发双倍流量 | 粗粒度控制,要求不严格的场景 |
| 滑动窗口 | 把时间窗口细分,逐步滑动统计 | 比固定窗口平滑,能削弱边界问题 | 需要存储窗口内请求时间戳,内存成本高 | 需要较平滑限流的场景 |
| 漏桶 | 请求进桶后以固定速率流出,桶满则丢弃 | 输出速率绝对稳定,保护下游效果强 | 无法应对突发流量,流量高峰期大量丢弃 | 下游对流量稳定性要求极高的场景 |
| 令牌桶 | 按固定速率生成令牌,桶内可积累令牌,请求消耗令牌才能通过 | 既能限制总体速率,又允许一定程度的突发 | 实现比窗口算法复杂,需要共享存储记录状态 | 既要限速又允许短时突发的互联网业务 |
Spring Cloud Gateway内置的RedisRateLimiter采用的就是令牌桶思想,这也是业界口碑很好的选择:它不像漏桶那样把所有突发都掐掉,也不像固定窗口那样容易在边界被击穿,而是允许“桶里有余量的时候放行一批集中在短时间内到达的请求”。这一点很契合网关场景,因为正常用户的一次页面操作往往会并发请求多个接口,完全禁止突发反而会影响体验。
另外要说明的是,任何限流算法的核心都是在“保护系统”和“留住用户”之间做权衡。没有绝对最优的算法,只有适合具体场景的选择。这也是我建议先理解算法原因、再动手配置参数的原因。
2. RedisRateLimiter核心原理:令牌桶在网关里是怎么跑的
2.1 内置限流器如何被加载
Spring Cloud Gateway把“限流”做成了一个过滤器工厂,叫作RequestRateLimiterGatewayFilterFactory。当路由规则里声明了这个过滤器,网关处理请求时会创建对应的过滤器实例,并在过滤器链的某个环节调用限流器判断是否放行。
这个过滤器工厂内部依赖一个RateLimiter接口。默认配置下,Spring Cloud Gateway会自动装配RedisRateLimiter,这个类实现了接口的isAllowed方法,核心逻辑就是把“当前请求是否允许通过”的判断交给Redis里的令牌桶数据完成。允许通过,过滤器什么也不做,继续走后续转发;不允许通过,网关会直接返回HTTP 429状态码,请求根本进不到下游服务。
看到这里应该能理解为什么网关限流对业务服务是透明的:业务服务收到的请求数量已经被严格控制,即使某个瞬间网关收到几百个请求,最终能转发到下游的也就是桶容量允许的那一批。这也带来一个设计上的启示:上限流要尽量往前放,越靠近入口,保护效果越好。
2.2 三个关键参数:replenishRate、burstCapacity、requestedTokens
配置RedisRateLimiter时,有三个参数必须搞明白,这也是这个完整代码案例里最重要的一段:
| 参数名 | 作用 | 通俗理解 |
|---|---|---|
| replenishRate | 令牌桶每秒补充的令牌数量 | 允许的平均每秒请求数 |
| burstCapacity | 令牌桶容量上限 | 允许短时间内突发通过的最大请求数 |
| requestedTokens | 每次请求消耗的令牌数量 | 一个请求占多少“配额” |
举个例子:设置replenishRate为10,burstCapacity为20,requestedTokens为1,表示系统平均每秒允许10个请求,但桶里最多能积攒20个令牌。假设系统空闲了一段时间,桶里已经积满20个令牌,此时突然来了一波20个并发请求,这些请求全部可以通过;紧接着再来请求,就必须等新令牌生成,平均每秒只能多放10个。
参数之间是乘法关系吗?需要注意:burstCapacity不是replenishRate的倍数,二者是独立的。只要容量够大、请求足够密集,突发瞬间放行的请求数由burstCapacity决定,而长期平均速率由replenishRate决定。实际配置时我习惯先根据压测确定下游单机承受的QPS,再把replenishRate设为它的70%左右,burstCapacity设为2到3倍,留一定的突发口子,但要防止把突发开得太大导致下游被打满。
requestedTokens这个参数通常保持为1,它更适合“一个请求顶多个配额”的场景。比如说某个接口代价很高,希望每来1个请求消耗3个令牌,从而让同样的速率下通过的请求数降为三分之一。
2.3 底层Lua脚本与Redis存储结构
RedisRateLimiter之所以用Redis存储,是因为网关在集群部署时多个实例要共享同一个限流状态,不能各自统计。内置实现通过一段Lua脚本完成“读令牌、判断、扣减、写回”的整套操作,借助Redis单线程执行Lua脚本的特性保证原子性,避免并发请求下出现超发。
脚本的核心逻辑简化之后是这样:
-- 从Redis读取当前令牌数和上次补充时间 local tokens = redis.call('GET', KEYS[1]) local timestamp = redis.call('GET', KEYS[2]) if tokens == false then tokens = ARGV[2] -- 初始化为桶容量 timestamp = ARGV[3] -- 初始化为当前时间 else -- 根据时间差补充令牌,但不超过桶容量 local delta = math.max(0, tonumber(ARGV[3]) - tonumber(timestamp)) tokens = math.min(tonumber(ARGV[2]), tonumber(tokens) + delta * tonumber(ARGV[1])) end -- 判断桶里令牌是否够本次请求消耗 if tokens < tonumber(ARGV[4]) then return {0, tokens} else tokens = tokens - tonumber(ARGV[4]) -- 写回Redis return {1, tokens} endRedis存储的是两个key:一个保存当前令牌数,一个保存上次补充令牌的时间戳。每次请求都会计算从上次补充到现在这段时间内新生成的令牌,然后一次性补充上去,再判断够不够扣。
这个过程中有一个容易忽略的细节:如果限流的维度是“每个IP一个桶”,那Redis里会为每个IP各存一套key;如果是“每个用户一个桶”,那就为每个用户各存一套。所以KeyResolver的定义不仅决定了“按什么维度限流”,还直接影响Redis key的数量和内存占用。
3. 完整代码案例:从零搭建一个可运行的限流网关
3.1 项目结构、依赖版本与踩坑
我搭建这个案例时用的是一套很标准的Spring Cloud Gateway项目结构。为了聚焦限流逻辑,这里只保留最核心的模块:
gateway-demo ├── pom.xml └── src/main/java/com/example/gateway ├── GatewayApplication.java └── config/KeyResolverConfig.javapom.xml里需要引入Gateway和Redis的依赖。这里有个容易踩的坑:Gateway基于WebFlux,必须用reactive版本的Redis依赖,用错了会启动报错或者无法自动装配限流器。另外Spring Boot和Spring Cloud版本尽量用兼容的组合,我用的是一套比较稳妥的版本搭配:Spring Boot 2.7系列对应Spring Cloud 2021.0.x。
<dependencies> <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> </dependencies>需要特别提醒的是,如果是Spring Boot 3.x,配置项前缀从spring.redis变成了spring.data.redis,官方文档里的老配置直接复制过来会踩坑。我的案例展示的是2.7版本的写法,用法逻辑在3.x下完全一致,只是前缀不同。
3.2 路由与限流配置application.yml
配置文件是把限流能力“接”进网关的核心位置。下面这份配置里,我定义了一条路由:凡是请求路径以/api/开头的流量,转发到本地8081端口的模拟服务,并且经过限流过滤器。
server: port: 8080 spring: application: name: gateway-demo redis: host: localhost port: 6379 timeout: 2000ms cloud: gateway: routes: - id: rate-limit-demo uri: http://localhost:8081 predicates: - Path=/api/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 2 redis-rate-limiter.burstCapacity: 5 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@ipKeyResolver}"这段配置里最关键的是key-resolver。它通过SpEL表达式#{@ipKeyResolver}引用一个Spring容器里的Bean,这个Bean的类型是KeyResolver。如果没有这个配置,内置限流器会尝试从请求的Principal中获取限流标识,在大多数场景下取不到,请求就会直接以503返回,而且不报任何明显错误,排查很难受。
如果你希望所有路由都启用限流,可以把过滤器放到default-filters下面,这样每条路由都会应用同一套限流规则。我建议刚开始调试时先用单条路由,效果确认后再扩展到全局。
3.3 自定义KeyResolver:IP、用户、组合维度
KeyResolver是决定“按谁限流”的开关。它接收当前请求的ServerWebExchange,返回一个Mono 作为限流桶的标识。标识相同的一批请求,共享同一个令牌桶。
先看最常见的IP维度实现:
@Configuration public class KeyResolverConfig { @Bean public KeyResolver ipKeyResolver() { return exchange -> { String ip = exchange.getRequest().getRemoteAddress() != null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown"; return Mono.just(ip); }; } }IP维度的优点是无需业务改造,天然覆盖所有请求;缺点是同一个公司出口IP下可能有大量用户,容易误伤。实际项目中我还见过更细的IP+接口维度,保证不同接口之间互不影响。
用户维度也很常用。如果请求头里带用户ID,就能精确到人,防止某个用户刷接口拖累别人:
@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); if (userId == null || userId.isEmpty()) { return Mono.just("anonymous"); } return Mono.just(userId); }; }这里一定要处理用户ID为空的情况。我见过不少线上事故就是KeyResolver返回了空字符串或者直接报NPE,导致限流器判断异常。统一兜底成“anonymous”,至少能让未登录请求也受限于自己的桶。
如果按单一维度不够,还可以组合:把IP和用户ID拼接成一个字符串作为key。组合维度在实现上只是多一步拼接,但限流效果会精准很多,尤其适合同时约束“单个IP总频率”和“单个用户频率”的场景。
3.4 启动并压测验证
把上面的配置和代码准备好后,先在本地启动一个Redis实例,再启动Gateway应用。为了模拟下游服务,我在8081端口跑了一个简单的Spring Boot接口,返回一个固定字符串。
启动成功后,用curl连续发几次请求验证:
curl -i http://localhost:8080/api/test第一次、第二次请求会正常返回后端内容。由于配置的replenishRate是2,burstCapacity是5,短时间内桶里初始有5个令牌,因此前5次请求都能通过。从第6次开始,因为每秒只能补充2个令牌,请求频率超过补充速度,就会看到HTTP 429,响应体里还能看到RedisRateLimiter返回的“Request denied by rate limiter”之类的提示。
压测时推荐用ab工具,一次并发施压更接近真实场景:
ab -n 50 -c 10 http://localhost:8080/api/test观察结果里的非2xx数量,就能直观看到网关在突发流量下拦截了多少请求。我第一次跑这个压测时,发现请求通过数量比预期多了一层,后来才意识到是桶里初始积满了令牌,这属于正常现象。
4. 进阶玩法:动态限流与自定义RateLimiter
4.1 内置方案的最大限制
内置RedisRateLimiter虽然开箱即用,但有一个明显短板:它把配置固定在application.yml里,改参数必须重启网关。如果限流阈值需要根据线上流量动态调整,比如流量高峰时段放宽、平时收紧,重启会打断网关转发,而且很难精细到某条路由或某个用户组。
另外,内置方案对“拒绝后怎么办”的定制能力有限。默认是直接返回429,没有可扩展的降级逻辑。实际业务中,用户被限流时往往希望返回一个友好提示,或者把请求转发到排队页,而不是看到一个光秃秃的状态码。
要突破这些限制,有两种做法:一是自定义RateLimiter实现,把规则读取改成动态获取;二是直接写一个自定义GlobalFilter,完全绕过内置过滤器,自己控制限流与降级逻辑。下面分别讲。
4.2 实现一个支持动态规则的RateLimiter
先看自定义RateLimiter的做法。RateLimiter接口需要实现一个isAllowed方法,返回是否放行以及剩余令牌数。我在某个项目里就这么做过:规则存到配置中心,网关通过监听配置变更实时更新限流阈值。
实现思路可以用代码骨架来描述:
@Component public class DynamicRedisRateLimiter implements RateLimiter<RequestRateLimiterConfig> { private final StringRedisTemplate redisTemplate; private final RuleProvider ruleProvider; public DynamicRedisRateLimiter(StringRedisTemplate redisTemplate, RuleProvider ruleProvider) { this.redisTemplate = redisTemplate; this.ruleProvider = ruleProvider; } @Override public Mono<Response> isAllowed(String routeId, String id) { RateLimitRule rule = ruleProvider.getRule(routeId); if (rule == null) { return Mono.just(new Response(true, new HashMap<>())); } return executeScript(routeId, id, rule); } }这里要点有两个:第一,RuleProvider负责从数据库、配置中心或内存缓存里拿规则,可以设计成支持动态刷新的结构;第二,Lua脚本里重新传入当前的rate和capacity,每次请求都执行最新的规则,从而实现不重启网关的动态限流。
Lua脚本和内置的逻辑类似,我在2.3节已经贴了简化版。真正落地时建议把过期时间、Redis连接池、脚本预热都考虑进去,否则高并发下链路会不稳定。
4.3 自定义GlobalFilter做全局限流
如果不想动内置过滤器那一套,自定义GlobalFilter是另一个可行方向。GlobalFilter可以拿到最完整的请求上下文,并且它的执行顺序可以通过Ordered接口控制。我常用的场景是:针对特定API路径做限流,同时在被限流时返回自定义响应体。
代码轮廓如下:
@Component public class CustomRateLimitFilter implements GlobalFilter, Ordered { private final RedisTemplate<String, String> redisTemplate; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String key = buildRateLimitKey(exchange); // 调用Lua脚本判断是否放行 // 通过则 chain.filter(exchange),不通过则返回429或自定义响应 } @Override public int getOrder() { return -1; } }自定义GlobalFilter最大的好处是逻辑完全可控。比如可以实现“同一用户对同一接口限流”“不同接口不同阈值”“限流后跳转到指定降级页面”等精细逻辑,而不受内置过滤器参数限制。代价是这部分代码需要自己维护,复杂度也更高,适合对网关定制化需求强的团队。
我自己的实践顺序是:先用内置方案快速落地,等确实需要动态规则或特殊降级时,再切换到自定义实现。不要一开始就搞复杂方案。
5. 常见问题排查与性能调优实录
5.1 问题速查与解决对照表
这部分我结合自己实际操作中遇到过的坑,整理了一张问题对照表,按出现频率排序:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 配置了限流但完全不生效 | key-resolver没配置,或Redis连接失败 | 确认container里有KeyResolver Bean,检查SpEL表达式名称 |
| 请求直接返回503 | 内置限流器取不到限流标识 | 配置自定义KeyResolver,返回值别为null |
| 连续请求全部通过,没有被拒 | burstCapacity设置过大,或测试请求数小于桶容量 | 压测时请求数要超过burstCapacity,观察第N+1次请求 |
| 改配置参数后不生效 | yaml缩进错误、参数名拼错或配置在错误层级 | 对照官方参数名,确认过滤器在routes或default-filters下 |
| 网关启动报Redis连接失败 | Redis未启动,或Spring Boot 3.x配置前缀写错 | 启动Redis,使用spring.data.redis前缀 |
| 限流状态在多个网关实例间不一致 | 有节点没接同一个Redis,或使用内存限流方案 | 统一接入Redis,使用共享存储 |
5.2 我踩过的几个典型坑
第一个坑是KeyResolver返回null导致503。当时我以为没配置key-resolver也能默认按IP限流,结果所有请求都返回503,网关日志里也没有明显的错误堆栈,查了很久才发现是内置实现取Principal失败了。这个坑在官方文档里提得不多,但非常常见。
第二个坑是关于初始令牌的误解。内置令牌桶在Redis key不存在时,会直接把令牌初始化成burstCapacity,所以每次重启KeyResolver维度对应的Redis key会重新生成,桶里又是满的。这意味着刚上线时系统会短暂放行一批突发请求,不是bug,但压测时要考虑到,测试完要清理Redis key,否则下一个测试结果会失真。
第三个坑是版本配置差异。我之前在一套Spring Boot 3.x工程里直接复制老项目的spring.redis.host配置,结果启动时Redis参数根本没生效,限流器判断时连不上Redis,表现就是限流时灵时不灵。网上很多教程都是两三年前的写法,落地时一定要先确认自己Spring Boot大版本对应的配置前缀。
5.3 限流参数与Redis连接的调优建议
限流参数怎么定,没有标准答案,只能根据实际压测和业务容忍度调。我个人的一套参考做法是:先用ab或JMeter对下游接口做压测,找出QPS拐点,之后把replenishRate设为拐点QPS的60%到70%,burstCapacity设置为replenishRate的2到3倍。这个比例保证平均速率低于下游上限,又允许业务上常见的瞬时聚集请求通过。
Redis连接方面有两个容易被忽略的细节。一是要配置合理的连接池参数,不要让每个请求都去新建连接;二是Lua脚本执行耗时极短,但网络往返时间会放大,所以网关和Redis尽量走同一个内网,不要跨机房调用。如果Redis是单节点,还存在单点故障风险,高可用场景下建议部署Redis哨兵或集群模式。
最后提一个生产建议:限流日志和指标一定要记录。我在网关里同时把被限流的请求IP、路径、命中规则输出到日志,方便后续做分析。如果没有这些数据,限流阈值调起来就是拍脑袋,出了问题也复盘不了。动手之前先把最小可跑通的案例装起来,再去调参数和做扩展,实践中是最稳妥的路径。