电商大促当天,运营发现订单接口被脚本狂刷,服务器 CPU 飙到 95%,正常用户下单全被拖垮。限流放网关层做,还是放业务代码里做?本文用 Spring Cloud Gateway 4.0.7 + Redis 令牌桶,从原理到可运行代码,讲透网关限流怎么做、怎么防刷、怎么避坑。
摘要:接口被刷、系统被打垮,最该先做的是网关层限流。本文以 Spring Cloud Gateway(Spring Boot 4.0.7 + Spring Cloud 2025.1.2)为实战载体,讲清楚令牌桶、滑动窗口、固定窗口三种限流算法的区别,给出基于 Redis 的 RequestRateLimiter 官方限流完整配置、自定义 KeyResolver、自定义 Lua 滑动窗口限流的完整可运行代码,并总结 8 个真实踩坑点。关键词:Spring Cloud Gateway、网关限流、Redis 令牌桶、防刷、Lua 脚本。
一、这个问题到底是什么
接口被刷的本质,是请求量短时间内超过系统的处理能力,而系统缺少一道"先拦后放"的关卡。 限流就是给系统装一个水龙头:单位时间内只放固定数量的请求进去,多出来的直接拒绝,让后端永远工作在能力范围之内。
很多团队的第一反应是把限流写在业务代码里。订单接口里加个计数器、查一下 Redis 再放行,看起来简单,但有两个致命问题。
第一,业务代码只保护了自己这一个接口。一个订单服务有 20 个接口,你每个都写限流?写一遍不累,但限流逻辑散落在各个业务类里,规则不统一、改起来要动几十个文件,早晚出漏子。
第二,流量是在进业务代码之前就已经打到服务器上的。脚本刷的是网关入口,请求先经过网关、路由转发、到达业务层,这中间的带宽、连接数、线程资源全被消耗了。等业务代码里发现"哎呀超了",服务器资源已经被打掉一大半。
正确的做法是把限流放在网关层。Spring Cloud Gateway 是整个微服务流量的统一入口,所有请求都从这里过。在这里做限流,一份配置管所有下游服务,拦截发生在资源被大量消耗之前,这才是"防刷"而不是"事后补救"。
网关限流要解决三个具体问题:按什么维度限(每个用户?每个 IP?还是整个服务?)、用什么算法限(令牌桶?滑动窗口?)、限流结果怎么让客户端看懂(返回 429 还是业务码?)。 本文的实战会把这三点全部落地。
二、底层原理到底怎么回事
限流算法的本质就一句话:用一个"许可"机制,控制单位时间内的放行数量。 主流算法有三种:固定窗口、滑动窗口、令牌桶。先搞懂它们,再看 Spring Cloud Gateway 是怎么把它们落到 Redis 上的。
固定窗口:最简单,但有临界漏洞
固定窗口的思路是把时间切成固定长度的格子,比如每分钟一个格子,每个格子最多放 100 个请求。实现就两个 Redis 命令:INCR 计数,第一次进来时 EXPIRE 设置过期时间。
它的漏洞在窗口边界。假设每分钟限 100 次,用户在 10:00:59 秒发 100 个请求,又在 10:01:00 发 100 个请求------这两个请求群分别落在两个窗口里,每个都合法,但实际 2 秒内打进来 200 个请求,后端照样被冲垮。这就是著名的"临界突刺"问题。
滑动窗口:精确,但占内存
滑动窗口把时间看成一条连续流动的线,窗口随当前时刻向前滑动。实现上用 Redis 的 ZSET(有序集合):每个请求的时间戳作为 score 存进去,限流时先删掉窗口外的旧记录,再数窗口内还剩多少。
它的优点是精确,没有临界突刺;缺点是每个请求都要往 ZSET 里写一条记录,窗口内请求量越大,内存占用越高,还要定期清理过期成员。
令牌桶:平滑限速,还能容忍突发
令牌桶像一个匀速滴水的桶:一个定时器以固定速率(比如每秒 10 个)往桶里放令牌,桶有容量上限(比如 20 个),满了就溢出不增加。每个请求来的时候必须取走一个令牌,取到就放行,取不到就拒绝。
它的妙处在于"平滑 + 突发兼顾":平时匀速限流,但桶里攒下的令牌允许短时间的突发流量(比如瞬间来 20 个请求,桶里正好有 20 个令牌,全放行),突发过后又回到匀速。这正是网关限流最想要的特性------活动刚开始的流量尖峰能扛一下,但整体速率被锁死。
Spring Cloud Gateway 内置的 RequestRateLimiter 过滤器用的就是令牌桶,而且是基于 Redis Lua 脚本实现的,原子性有保证。
Gateway 限流的完整执行链路
Gateway 的限流请求从客户端发出后,要经过这样一条链:
客户端请求
↓
Gateway 路由匹配(Predicate 判断走哪条路由)
↓
过滤器链(Filter Chain)按 order 依次执行
↓
RequestRateLimiter 过滤器(限流核心)
↓ 调用
KeyResolver(决定限流 key:按 IP?按用户?)
↓ 调用
RedisRateLimiter(执行 Lua 脚本,令牌桶判定)
↓
放行 → 转发到下游服务 或 拒绝 → 返回 429
其中三个组件各干一件事:
- KeyResolver:决定"这一桶令牌算在谁头上"。同一个 key 的请求共享一个令牌桶。按 IP 限流,key 就是 IP;按用户限流,key 就是 userId。这个接口返回一个 Mono,字符串就是 Redis 里的 key。
- RedisRateLimiter:真正执行限流算法的组件,内部用 Lua 脚本保证"取令牌"是原子操作,不会出现并发下两个请求同时拿到最后一个令牌的问题。
- RequestRateLimiter:Gateway 的过滤器,把 KeyResolver 和 RedisRateLimiter 串起来,根据判定结果决定放行还是返回 HTTP 429。
为什么用 Lua 脚本? 因为"检查令牌数 + 扣减令牌"是两个操作,如果分开执行,两个并发请求可能同时读到"还剩 1 个令牌",然后都放行,限流失效。Lua 脚本在 Redis 里是单线程执行的,整个脚本一气呵成,不会被打断,这就是原子性。官方 RedisRateLimiter 用的 request_rate_limiter.lua 脚本就是这个思路:读当前令牌数、按速率补充、扣减、写回,全部在一个脚本里完成。
搞懂了这三层,再看配置就非常容易:配置里写的 replenishRate、burstCapacity,对应到令牌桶上就是"每秒放多少令牌"和"桶容量多大"。
三、实战:手把手写代码
这一节用 Spring Boot 4.0.7 + Spring Cloud 2025.1.2 搭一个完整的网关服务,实现两种限流:官方 RequestRateLimiter 令牌桶限流(按 IP),以及自定义 Lua 滑动窗口限流(按接口路径)。 所有文件都是完整的、复制即可运行的。
3.1 项目结构
gateway-rate-limit/
├── pom.xml
├── src/main/resources/
│ ├── application.yml
│ └── sliding-window.lua
└── src/main/java/com/example/gateway/
├── GatewayApplication.java
├── config/RateLimitConfig.java
└── filter/SlidingWindowRateLimitFilter.java
3.2 完整 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.7</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>gateway-rate-limit</artifactId>
<version>1.0.0</version>
<name>gateway-rate-limit</name>
<description>Spring Cloud Gateway 限流实战</description>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2025.1.2</spring-cloud.version>
</properties>
<dependencies>
<!-- 网关核心:Gateway 5.0.2,由 Spring Cloud BOM 统一管理版本 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- 响应式 Redis 客户端:RequestRateLimiter 和自定义 Lua 限流都依赖它 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
这里要特别说明两点。第一,spring-cloud-starter-gateway 和 spring-cloud-dependencies 都没有写版本号,因为版本由 spring-cloud-dependencies BOM 统一管理,2025.1.2 这个版本对应 Gateway 5.0.2。第二,Spring Cloud 2025.1.2 只兼容 Spring Boot 4.0.7,parent 版本写成 4.0.7,不要自己升级到 4.1.x,否则依赖解析会报错。
3.3 完整 application.yml
yaml
server:
port: 8080
spring:
application:
name: gateway-service
data:
redis:
host: localhost
port: 6379
timeout: 3s
cloud:
gateway:
routes:
# 订单服务路由:先过 RequestRateLimiter 令牌桶限流,再转发
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- name: RequestRateLimiter
args:
# 每秒补充 10 个令牌(平均每秒最多 10 个请求)
redis-rate-limiter.replenishRate: 10
# 桶容量 20:允许瞬间最多 20 个请求的突发
redis-rate-limiter.burstCapacity: 20
# 每个请求消耗 1 个令牌
redis-rate-limiter.requestedTokens: 1
# 用哪个 KeyResolver 决定限流 key,这里用按 IP 的
key-resolver: "#{@ipKeyResolver}"
logging:
level:
org.springframework.cloud.gateway: INFO
这份配置的含义拆开讲:replenishRate: 10 是令牌补充速率,相当于水龙头每秒滴 10 滴水;burstCapacity: 20 是桶的容量,相当于桶最多存 20 滴水。请求过来必须取走 1 滴水,桶里没有就返回 429。注意 burstCapacity 必须大于等于 replenishRate,否则配置不合法。
3.4 完整启动类
java
package com.example.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
3.5 完整 KeyResolver 配置类
java
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 org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 限流 Key 解析器:决定令牌桶算在谁头上。
* KeyResolver 接口只有一个方法:给定一次请求,返回这个请求的限流 key。
*/
@Configuration
public class RateLimitConfig {
/**
* 按客户端 IP 限流。
* 同一个 IP 的所有请求共享一个令牌桶,可以防单个 IP 的脚本刷量。
*/
@Bean
public KeyResolver ipKeyResolver() {
return new KeyResolver() {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
String ip = exchange.getRequest().getRemoteAddress() == null
? "unknown"
: exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
return Mono.just("rate-limit:ip:" + ip);
}
};
}
/**
* 按用户 ID 限流。
* userId 从请求参数里取,适合已登录用户维度的限流。
*/
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = exchange.getRequest().getQueryParams().getFirst("userId");
return Mono.just("rate-limit:user:" + (userId == null ? "anonymous" : userId));
};
}
}
这段代码在干什么:KeyResolver.resolve 方法接收一次请求,返回一个字符串 key。RedisRateLimiter 用这个 key 去 Redis 里读写令牌桶,所以 key 怎么设计,限流就按什么维度生效。两个 Bean 都定义了,application.yml 里用 #{@ipKeyResolver} 指定走 IP 那个;如果只定义一个 Bean,配置里甚至可以省略 key-resolver 这一行。
3.6 自定义 Lua 滑动窗口限流(完整 GlobalFilter)
官方 RequestRateLimiter 是令牌桶,适合平滑限速。如果你想精确统计"每 60 秒内最多 30 次"这种固定窗口语义,可以用滑动窗口。下面这个完整的全局过滤器,按接口路径限流,核心逻辑写在 Lua 脚本里保证原子性。
先写 Lua 脚本 sliding-window.lua,放在 src/main/resources/ 目录下:
lua
-- 滑动窗口限流脚本
-- KEYS[1] 限流 key(这里按接口路径)
-- ARGV[1] 窗口大小(毫秒)
-- ARGV[2] 窗口内最大请求数
-- ARGV[3] 当前时间戳(毫秒)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 1. 把窗口外(太旧)的请求记录删掉
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 2. 数一下窗口内还剩多少条记录
local count = redis.call('ZCARD', key)
-- 3. 没超限:记下本次请求,放行;超限:拒绝
if count < limit then
-- score 存时间戳,member 加随机后缀防止同一毫秒重复
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return 1
end
return 0
再写过滤器 SlidingWindowRateLimitFilter.java:
java
package com.example.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 滑动窗口限流全局过滤器:按接口路径,每 60 秒最多 30 次。
* 注意这是 WebFlux 响应式写法,全程非阻塞。
*/
@Component
public class SlidingWindowRateLimitFilter implements GlobalFilter, Ordered {
private static final long WINDOW_MS = 60_000L;
private static final long LIMIT = 30L;
private final ReactiveStringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> slidingWindowScript;
public SlidingWindowRateLimitFilter(ReactiveStringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.slidingWindowScript = new DefaultRedisScript<>();
this.slidingWindowScript.setLocation(new ClassPathResource("sliding-window.lua"));
this.slidingWindowScript.setResultType(Long.class);
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String key = "sliding:" + exchange.getRequest().getURI().getPath();
return redisTemplate.execute(slidingWindowScript, List.of(key),
WINDOW_MS, LIMIT, System.currentTimeMillis())
.flatMap(result -> {
if (Long.valueOf(1L).equals(result)) {
// 未超限,继续走过滤器链,转发给下游
return chain.filter(exchange);
}
// 超限:返回 429,并写入一段 JSON 响应体方便客户端识别
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] body = "{\"code\":429,\"message\":\"请求太频繁,请稍后再试\"}"
.getBytes(StandardCharsets.UTF_8);
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(body)));
});
}
/**
* order 越小越先执行。-100 保证它在多数过滤器之前,
* 也就是请求一进网关就先过限流,避免下游被拖垮。
*/
@Override
public int getOrder() {
return -100;
}
}
这段代码的几个关键点:redisTemplate.execute 把 Lua 脚本和参数发给 Redis 执行,返回 Mono<Long>,1 表示放行、0 表示拒绝;bufferFactory().wrap() 把 JSON 字符串转成响应体字节;getOrder() 返回 -100 让这个过滤器排在最前面,保证限流拦截发生在转发之前。这套过滤器和官方 RequestRateLimiter 会叠加生效,正好演示多级限流:官方那层按 IP 令牌桶限速,这层按路径滑动窗口限频。
3.7 启动与验证
- 本地启动一个 Redis(默认端口 6379)。
mvn spring-boot:run启动网关。- 用 curl 连续快速请求,观察限流效果:
bash
# 连续快速发 40 个请求,统计返回码分布
for i in $(seq 1 40); do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/order/1
done
前 20 个请求(桶容量)能拿到 200 或 503(下游服务不存在时的网关错误码),超出后会出现 429。同时可以在 Redis 里查看限流 key:redis-cli keys 'rate-limit:*' 能看到按 IP 的令牌桶 key,keys 'sliding:*' 能看到按路径的滑动窗口 key。
四、踩坑经验和最佳实践
限流上线前,先把下面 8 个坑排掉,能省下半夜被叫起来的次数。
坑 1:忘了定义 KeyResolver,启动直接报错。 RequestRateLimiter 过滤器需要 key-resolver 指定用哪个 KeyResolver Bean,如果 Bean 不存在,启动时抛 NoSuchBeanDefinitionException。解决方案:要么配置里写 key-resolver: "#{@ipKeyResolver}",要么只定义一个 KeyResolver Bean 让 Spring 自动注入。
坑 2:burstCapacity 小于 replenishRate。 桶容量比每秒补充量还小,相当于水龙头比桶还大,配置校验直接失败。记住规则:burstCapacity >= replenishRate。经验值一般是补充速率的 1.5 到 2 倍,给突发流量留余量。
坑 3:在响应式网关里用了阻塞 Redis 客户端。 Gateway 是基于 WebFlux 的非阻塞架构,线程池很小。如果引入同步的 StringRedisTemplate(Lettuce 同步模式)在过滤器里调用,一个阻塞就把整个 event loop 线程卡住,高并发下网关直接雪崩。必须用 ReactiveStringRedisTemplate,就像本文示例里那样。
坑 4:限流 key 设计太粗,所有请求共用一个桶。 如果把 key 写死成 rate-limit:all,一个用户刷量,全站用户一起被限流,这是生产事故级别的配置错误。key 必须按业务维度区分:IP、userId、接口路径,或者组合。
坑 5:阈值拍脑袋定。 限流值定多少,不是感觉出来的。先用压测工具(如 JMeter、wrk)测出下游服务的真实 QPS 上限,再乘 0.7 的保险系数作为网关限流值。比如订单服务压测极限 200 QPS,网关就限 140,留出波动空间。
坑 6:网关集群部署,Redis 却是单点。 多个网关实例共享 Redis 令牌桶才能全局限流(这正是用 Redis 做限流存储的意义),但 Redis 自己挂了,所有请求都会卡在限流环节。生产环境给 Redis 配哨兵或集群,并且设置连接超时(timeout: 3s),Redis 故障时限流快速失败而不是无限等待。
坑 7:默认 429 响应没有业务可读的响应体。 客户端收到 429 只知道"被限了",不知道是哪个维度的限流、多久能恢复。要么在自定义过滤器中写 JSON 响应体(本文示例已演示),要么约定 429 + 响应头 Retry-After 告诉客户端等待秒数。
坑 8:Lua 脚本路径写错,启动时找不到脚本。 DefaultRedisScript 的 setLocation(new ClassPathResource("xxx.lua")) 路径必须和 resources 目录下的实际文件名完全一致,大小写敏感。脚本加载失败通常启动时就能看到异常,别拖到线上才暴露。
最佳实践一句话总结:限流阈值压测定、key 按业务维度分、全部用响应式客户端、Redis 要高可用、429 响应体要可读、上线前用脚本模拟刷量验证效果。
五、性能对比和技术选型
三种限流方案里,令牌桶最均衡,滑动窗口最精确,固定窗口最省钱但最不靠谱。 实测结论(本地 2C4G 环境,单机网关,Redis 同机部署):
| 方案 | 实现成本 | 单请求 Redis 命令数 | 精确度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口(INCR+EXPIRE) | 最低 | 1-2 | 有临界突刺 | 内部接口粗略限流 |
| 滑动窗口(ZSET) | 中 | 3-4 | 精确无突刺 | 秒杀、活动精确限频 |
| 令牌桶(RequestRateLimiter) | 低(内置) | 1(Lua 原子) | 平滑+容忍突发 | 网关入口默认首选 |
选型建议分三档:
- 网关统一限流:直接用 RequestRateLimiter。 官方组件、Lua 原子性、配置即用,覆盖 90% 的场景。本文主方案就是它。
- 需要精确窗口语义:用自定义 Lua 滑动窗口。 比如"每 60 秒最多 30 次"这类强语义需求,令牌桶表达不了"精确到秒的频次上限",滑动窗口更合适。
- 单机小应用:别杀鸡用牛刀。 单实例、低并发,用 Guava 或 Bucket4j 的内存限流即可,省一次 Redis 往返。一旦多实例部署,立刻切回 Redis 方案。
网关限流和 Nginx 限流怎么分工? Nginx 的 limit_req 在 L7 最前端,挡最粗暴的流量(比如单 IP 大流量攻击);Spring Cloud Gateway 限流在业务路由层,能拿到业务上下文(userId、路径、请求参数),做精细的业务维度限流。两者叠加不冲突,Nginx 挡粗的,网关挡细的。
六、总结
网关限流是微服务防刷的第一道防线,核心就三件事:选对算法、设计好 key、用好 Redis 的原子性。 展开来说:
第一,算法上,Spring Cloud Gateway 内置的 RequestRateLimiter 用令牌桶,平滑限速又能容忍突发,是网关入口的默认首选;需要精确的固定时间窗语义时,用 Lua 实现滑动窗口。第二,key 的设计决定了限流粒度,按 IP 防脚本、按 userId 防单个用户、按路径防单个接口,实际项目经常组合使用。第三,所有"检查+扣减"逻辑必须放在 Lua 脚本里原子执行,否则并发下限流形同虚设。
从架构视角看,限流放网关层而不是业务代码层,是因为流量在进入业务之前就该被拦住,一份配置管所有下游,这才是防刷的正确姿势。配合 Nginx 粗粒度限流、Redis 高可用部署、压测得出的阈值,这套组合能扛住绝大多数刷量场景。
最后提醒一句:限流是"保命"手段,不是"增长"手段。正常用户的合理流量被误伤,说明阈值或 key 设计有问题,上线后要持续观察限流命中率和误杀率,动态调整。技术方案对了,剩下的就是运维的细心活。