Spring Cloud Gateway RequestRateLimiter 实战:Redis 令牌桶限流从配置到本地压测验证
网关限流最容易踩的坑,不是"有没有加限流组件",而是配置看起来生效了,压测时却不知道到底按什么维度限、什么时候返回 429、Redis 里令牌桶参数该怎么解释。
本文用 Spring Cloud Gateway 的 RequestRateLimiter 讲一条最小实战链路:
客户端请求
-> Spring Cloud Gateway 路由匹配
-> RequestRateLimiter 过滤器
-> KeyResolver 解析限流 key
-> RedisRateLimiter 按令牌桶算法判断是否放行
-> 放行转发到后端服务 / 拒绝返回 429
目标读者是 Java 后端、微服务网关维护者和正在做接口限流治理的架构师。读完你应该能拿到三件东西:一份 Gateway + Redis 限流配置、一段 KeyResolver 示例、以及一套本地压测验证方法。
验证说明:本文核心参数和行为来自 Spring Cloud Gateway 官方文档 RequestRateLimiter GatewayFilter Factory。当前写作机器未安装 JDK/Maven,无法在本机完整启动 Java demo;文中的 Spring Boot 工程代码是工程骨架示例,压测命令和预期现象按官方行为给出,落地时请在你的项目版本下编译运行确认。
1. RequestRateLimiter 解决的是什么问题
在微服务里,限流一般不建议散落在每个业务服务里,否则会出现几个问题:
| 问题 | 放在业务服务里 | 放在 Gateway 层 |
|---|---|---|
| 统一治理 | 每个服务各配一套 | 路由维度统一配置 |
| 防护位置 | 请求已经打到业务服务 | 请求进入业务前先拦截 |
| 调整成本 | 多个服务逐个改 | 网关配置集中调整 |
| 观测口径 | 分散在业务日志 | 网关侧统一看 429、RT、命中率 |
Spring Cloud Gateway 的 RequestRateLimiter 是一个 GatewayFilter Factory。它会在请求进入某条路由后,调用一个 RateLimiter 判断请求是否允许通过。使用 Redis 时,常见实现是 RedisRateLimiter,底层思路是令牌桶算法。
如果请求不允许通过,默认返回:
HTTP/1.1 429 Too Many Requests
这点很重要:压测时不要只看后端服务 QPS,还要看 Gateway 是否稳定返回 429。
2. 环境版本和依赖
一个最小 demo 可以按下面环境准备:
| 组件 | 建议版本 / 说明 |
|---|---|
| JDK | 17+ |
| Spring Boot | 3.x |
| Spring Cloud Gateway | 与 Spring Boot 版本匹配的 Spring Cloud 版本线 |
| Redis | 6.x / 7.x 均可 |
| 压测工具 | curl、seq、xargs,或 hey / wrk |
Maven 依赖至少需要 Gateway 和 reactive Redis:
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
说明:不同 Spring Cloud 版本的 artifact 命名可能会调整。老项目里也常见
spring-cloud-starter-gateway。新项目请以当前 Spring Cloud Gateway 官方文档和你所用 Spring Cloud BOM 为准。
Redis 可以用 Docker 快速启动:
docker run --name gateway-redis -p 6379:6379 -d redis:7-alpine
确认 Redis 端口:
docker ps --filter name=gateway-redis
3. 最小路由配置:按路由加限流
假设后端服务地址是 http://localhost:8081,Gateway 监听 8080。下面是一份最小 application.yml:
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
cloud:
gateway:
server:
webflux:
routes:
- id: order-api
uri: http://localhost:8081
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
key-resolver: '#{@ipKeyResolver}'
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 4
redis-rate-limiter.requestedTokens: 1
几个参数先翻译成人话:
| 参数 | 含义 | 示例值说明 |
|---|---|---|
key-resolver |
按什么维度限流 | 示例按客户端 IP 限流 |
replenishRate |
每秒补充多少令牌 | 每秒恢复 2 个请求额度 |
burstCapacity |
桶最多能装多少令牌 | 瞬时最多允许 4 个请求突发 |
requestedTokens |
每个请求消耗几个令牌 | 默认 1,一次请求扣 1 个 |
如果 replenishRate=2 且 burstCapacity=4,可以粗略理解为:稳定每秒允许 2 个请求,短时间最多可以攒到 4 个请求的突发能力。连续猛打超过令牌恢复速度后,就会开始返回 429。
4. KeyResolver:限流 key 一定要想清楚
RequestRateLimiter 不是天然知道"按谁限流"。它需要一个 KeyResolver。
4.1 按 IP 限流
本地 demo 最容易按 IP 限流:
java
`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 ipKeyResolver() {
return exchange -> {
var remoteAddress = exchange.getRequest().getRemoteAddress();
if (remoteAddress == null || remoteAddress.getAddress() == null) {
return Mono.just("unknown");
}
return Mono.just(remoteAddress.getAddress().getHostAddress());
};
}
}
`
按 IP 的优点是简单,缺点也明显:如果前面还有 Nginx、SLB、Ingress,remoteAddress 可能拿到的是代理 IP,而不是用户真实 IP。生产环境通常要结合可信代理头处理,例如 X-Forwarded-For,并且只信任来自内网代理层写入的头。
4.2 按用户 ID 限流
如果你的接口已经有登录态,用户维度更适合做业务限流:
java
`@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = exchange.getRequest()
.getHeaders()
.getFirst("X-User-Id");
return Mono.justOrEmpty(userId);
};
}
`
对应配置改成:
filters:
- name: RequestRateLimiter
args:
key-resolver: '#{@userKeyResolver}'
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
redis-rate-limiter.requestedTokens: 1
官方文档里提到:如果 KeyResolver 没有解析出 key,默认会拒绝请求。可以通过下面两个配置调整空 key 行为:
spring:
cloud:
gateway:
filter:
request-rate-limiter:
deny-empty-key: true
empty-key-status-code: 403
生产上我更建议默认拒绝空 key。否则一旦用户 ID 获取失败,就可能绕过限流。
5. 本地后端服务:准备一个被转发接口
为了验证 Gateway 是否真的拦截,可以准备一个最小后端服务,监听 8081:
java
`@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public Map<String, Object> getOrder(@PathVariable String id) {
return Map.of(
"id", id,
"status", "CREATED",
"timestamp", System.currentTimeMillis()
);
}
}
`
启动后验证直连后端:
curl -i http://localhost:8081/api/orders/1001
再验证经过 Gateway:
curl -i http://localhost:8080/api/orders/1001
正常情况下,经过 Gateway 的请求应该被转发到后端,返回业务 JSON。
6. 压测验证:重点看 200 和 429 的比例
现在开始打请求。先用一个简单循环:
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:8080/api/orders/1001
done
如果配置为:
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 4
redis-rate-limiter.requestedTokens: 1
你会看到前几个请求更可能是 200,后面连续请求开始出现 429。示例输出形态如下:
200
200
200
200
429
429
429
429
429
429
注意:具体 200 数量会受请求间隔、机器速度和令牌恢复影响,不要把示例输出当成固定断言。真正要验证的是:
- 限流前请求能转发到后端;
- 连续请求超过令牌桶能力后返回 429;
- 等待一段时间后令牌恢复,请求又能通过。
可以加一个等待验证令牌恢复:
sleep 2
curl -i http://localhost:8080/api/orders/1001
如果恢复正常,说明令牌桶在按 replenishRate 补充令牌。
并发压测命令
不用额外安装工具,也可以用 xargs 模拟并发:
seq 1 30 | xargs -n1 -P10 -I{} \
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:8080/api/orders/1001 | sort | uniq -c
可能看到类似:
4 200
26 429
这和 burstCapacity=4 的直觉是一致的:同一时刻突发打进来时,最多先吃掉桶里已有令牌,后续请求被拒绝。
7. Redis 里发生了什么
RedisRateLimiter 会把限流状态放到 Redis。你不需要手写 Redis key,但排查时可以看 key 是否生成:
docker exec -it gateway-redis redis-cli keys '*request_rate_limiter*'
常见排查重点:
| 现象 | 可能原因 | 检查方式 |
|---|---|---|
| 所有请求都是 200 | 路由没匹配 / filter 没生效 / key 每次都不同 | 看 Gateway 路由日志、打印 key |
| 所有请求都是 429 | burstCapacity=0 / key 为空且被拒绝 / Redis 时间或脚本异常 |
检查配置和 Gateway 日志 |
| 压测结果不稳定 | 请求间隔导致令牌恢复 | 增大并发、降低 replenishRate |
| 多个用户互相影响 | KeyResolver 维度太粗 | 改成 userId / tenantId |
| 线上限流失效 | 代理 IP 被当成用户 IP | 检查 X-Forwarded-For 处理链路 |
如果要更直观看到限流 key,可以在 KeyResolver 里临时打印:
java
`@Bean
public KeyResolver ipKeyResolver() {
return exchange -> {
String key = exchange.getRequest().getRemoteAddress()
.getAddress()
.getHostAddress();
log.info("gateway rate limit key={}", key);
return Mono.just(key);
};
}
`
生产环境不建议长期打印每个请求的 key,容易造成日志量过大。
8. 参数怎么取舍:replenishRate 和 burstCapacity 不要乱填
很多配置问题都出在这两个参数上。
8.1 稳定限流
如果你希望非常稳定,例如每秒最多 10 个请求,不允许明显突发:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 10
这种配置简单,适合后台接口、内部接口或不希望突发流量压垮下游的场景。
8.2 允许短突发
如果业务允许短时间突发,例如按钮点击、页面初始化、移动端重试:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 30
含义是长期看每秒 10 个请求,但桶最多能攒到 30 个令牌。用户短时间内可以打出一波请求,但连续突发仍然会被限制。
8.3 降低单请求成本以支持低速率
官方文档还提到 requestedTokens。默认一个请求消耗 1 个 token。如果你要表达"每分钟 1 个请求"这种低速率,不能只把每秒速率写成小数。常见做法是把 requestedTokens 和补充速率组合起来设计。
例如需要更细粒度的时间窗口时,可以先用普通秒级参数满足大多数接口;真正复杂的分钟级、租户级、套餐级限流,建议封装独立限流策略,不要只靠一条 YAML 配置硬凑。
9. 生产落地建议
9.1 限流 key 要和业务风险一致
不同接口的 key 不一样:
| 接口类型 | 推荐 key | 原因 |
|---|---|---|
| 未登录公开接口 | IP / 设备指纹 | 防爬、防刷 |
| 登录用户接口 | userId | 防单用户刷接口 |
| 多租户 API | tenantId + appId | 防某个租户拖垮系统 |
| 开放平台接口 | accessKey / clientId | 和调用方额度绑定 |
| 后台管理接口 | userId + path | 防误操作和脚本重复提交 |
不要所有路由都用一个 IP key。公司内网、代理层、NAT 出口会让很多请求看起来来自同一个 IP,容易误伤。
9.2 限流只是第一道防线
Gateway 限流适合挡住入口流量,但它不是完整稳定性方案。生产上通常还要配合:
- 下游服务线程池隔离;
- 熔断和超时;
- Redis 异常时的降级策略;
- 429 指标监控和告警;
- 按路由、按租户的限流配置中心化管理;
- 对重要接口做灰度和压测基线。
9.3 429 要进入可观测性
上线前至少看三类指标:
gateway_requests_total{routeId="order-api", outcome="SUCCESS"}
gateway_requests_total{routeId="order-api", status="TOO_MANY_REQUESTS"}
redis_command_latency / Redis 连接池指标
如果只看业务服务 QPS,你可能会误判"流量下降了";实际上可能是 Gateway 已经大量返回 429。
10. 常见问题
Q1:为什么我配置了限流但没有 429?
优先按这个顺序查:
- 请求路径是否真的匹配该 route;
- filter 名称是否写成
RequestRateLimiter; key-resolver是否引用到了正确 bean;- Redis 是否连接成功;
KeyResolver每次返回的 key 是否一致;- 压测强度是否足够,令牌可能已经恢复了。
Q2:为什么一启动就全部 429?
常见原因:
burstCapacity写成 0;KeyResolver返回 empty,而默认空 key 会被拒绝;- Redis 连接异常或 Lua 脚本执行失败;
- 配置层级写错,导致参数没有按预期生效。
可以先把 replenishRate 和 burstCapacity 调大,确认主链路正常,再逐步收紧。
Q3:按 IP 限流上线后为什么误伤严重?
大概率是代理链路问题。很多生产环境里,Gateway 前面还有 SLB、Nginx、Ingress 或 API 网关。如果没有正确处理转发头,Gateway 看到的可能是代理层 IP。结果就是大量真实用户共享一个限流 key。
生产上要明确:
- 哪一层写入
X-Forwarded-For; - Gateway 是否信任这个头;
- 是否只信任来自内网代理的头;
- 是否需要按 userId / appId 替代 IP 限流。
Q4:Redis 挂了,限流应该放行还是拒绝?
这不是一个纯技术默认值问题,而是业务取舍:
| 策略 | 优点 | 风险 |
|---|---|---|
| fail-open | Redis 故障时尽量不影响用户 | 下游可能被流量打穿 |
| fail-closed | 优先保护下游 | 用户请求可能被误拒绝 |
核心交易、支付、库存类接口通常更偏保护下游;内容浏览、低风险读接口可能更偏可用性。不要上线时才讨论这个问题。
11. 小结
Spring Cloud Gateway 的 RequestRateLimiter 不难配置,真正难的是把它验证清楚:
- 路由是否匹配;
- key 是否稳定;
- Redis 令牌桶参数是否符合预期;
- 超限后是否返回 429;
- 等待后是否能恢复;
- 线上是否会被代理 IP、空 key、Redis 故障和指标缺失坑到。
如果只是复制一段 YAML,上线后很容易不知道限流到底有没有生效。我的建议是:每条关键路由都保留一组本地压测命令和预期 429 现象,先把限流行为跑清楚,再谈生产参数怎么调。
如果你关注 Java 后端、Spring Cloud、网关治理和线上稳定性建设,可以关注我的 CSDN 专栏。