尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Spring Cloud Gateway限流:Redis令牌桶防刷实战

Spring Cloud Gateway限流:Redis令牌桶防刷实战 电商大促当天运营发现订单接口被脚本狂刷服务器 CPU 飙到 95%正常用户下单全被拖垮。限流放网关层做还是放业务代码里做本文用 Spring Cloud Gateway 4.0.7 Redis 令牌桶从原理到可运行代码讲透网关限流怎么做、怎么防刷、怎么避坑。摘要接口被刷、系统被打垮最该先做的是网关层限流。本文以 Spring Cloud GatewaySpring 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 脚本保证取令牌是原子操作不会出现并发下两个请求同时拿到最后一个令牌的问题。RequestRateLimiterGateway 的过滤器把 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.java3.2 完整 pom.xml?xml version1.0 encodingUTF-8?projectxmlnshttp://maven.apache.org/POM/4.0.0xmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsdmodelVersion4.0.0/modelVersionparentgroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-parent/artifactIdversion4.0.7/versionrelativePath//parentgroupIdcom.example/groupIdartifactIdgateway-rate-limit/artifactIdversion1.0.0/versionnamegateway-rate-limit/namedescriptionSpring Cloud Gateway 限流实战/descriptionpropertiesjava.version21/java.versionspring-cloud.version2025.1.2/spring-cloud.version/propertiesdependencies!-- 网关核心Gateway 5.0.2由 Spring Cloud BOM 统一管理版本 --dependencygroupIdorg.springframework.cloud/groupIdartifactIdspring-cloud-starter-gateway/artifactId/dependency!-- 响应式 Redis 客户端RequestRateLimiter 和自定义 Lua 限流都依赖它 --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis-reactive/artifactId/dependency/dependenciesdependencyManagementdependenciesdependencygroupIdorg.springframework.cloud/groupIdartifactIdspring-cloud-dependencies/artifactIdversion${spring-cloud.version}/versiontypepom/typescopeimport/scope/dependency/dependencies/dependencyManagementbuildpluginsplugingroupIdorg.springframework.boot/groupIdartifactIdspring-boot-maven-plugin/artifactId/plugin/plugins/build/project这里要特别说明两点。第一spring-cloud-starter-gateway和spring-cloud-dependencies都没有写版本号因为版本由spring-cloud-dependenciesBOM 统一管理2025.1.2 这个版本对应 Gateway 5.0.2。第二Spring Cloud 2025.1.2 只兼容 Spring Boot 4.0.7parent 版本写成 4.0.7不要自己升级到 4.1.x否则依赖解析会报错。3.3 完整 application.ymlserver:port:8080spring:application:name:gateway-servicedata:redis:host:localhostport:6379timeout:3scloud:gateway:routes:# 订单服务路由先过 RequestRateLimiter 令牌桶限流再转发-id:order-serviceuri:lb://order-servicepredicates:-Path/api/order/**filters:-name:RequestRateLimiterargs:# 每秒补充 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 完整启动类packagecom.example.gateway;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;SpringBootApplicationpublicclassGatewayApplication{publicstaticvoidmain(String[]args){SpringApplication.run(GatewayApplication.class,args);}}3.5 完整 KeyResolver 配置类packagecom.example.gateway.config;importorg.springframework.cloud.gateway.filter.ratelimit.KeyResolver;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.web.server.ServerWebExchange;importreactor.core.publisher.Mono;/** * 限流 Key 解析器决定令牌桶算在谁头上。 * KeyResolver 接口只有一个方法给定一次请求返回这个请求的限流 key。 */ConfigurationpublicclassRateLimitConfig{/** * 按客户端 IP 限流。 * 同一个 IP 的所有请求共享一个令牌桶可以防单个 IP 的脚本刷量。 */BeanpublicKeyResolveripKeyResolver(){returnnewKeyResolver(){OverridepublicMonoStringresolve(ServerWebExchangeexchange){Stringipexchange.getRequest().getRemoteAddress()null?unknown:exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();returnMono.just(rate-limit:ip:ip);}};}/** * 按用户 ID 限流。 * userId 从请求参数里取适合已登录用户维度的限流。 */BeanpublicKeyResolveruserKeyResolver(){returnexchange-{StringuserIdexchange.getRequest().getQueryParams().getFirst(userId);returnMono.just(rate-limit:user:(userIdnull?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/目录下-- 滑动窗口限流脚本-- KEYS[1] 限流 key这里按接口路径-- ARGV[1] 窗口大小毫秒-- ARGV[2] 窗口内最大请求数-- ARGV[3] 当前时间戳毫秒localkeyKEYS[1]localwindowtonumber(ARGV[1])locallimittonumber(ARGV[2])localnowtonumber(ARGV[3])-- 1. 把窗口外太旧的请求记录删掉redis.call(ZREMRANGEBYSCORE,key,0,now-window)-- 2. 数一下窗口内还剩多少条记录localcountredis.call(ZCARD,key)-- 3. 没超限记下本次请求放行超限拒绝ifcountlimitthen-- score 存时间戳member 加随机后缀防止同一毫秒重复redis.call(ZADD,key,now,now..-..math.random(1000000))redis.call(PEXPIRE,key,window)return1endreturn0再写过滤器SlidingWindowRateLimitFilter.javapackagecom.example.gateway.filter;importorg.springframework.cloud.gateway.filter.GatewayFilterChain;importorg.springframework.cloud.gateway.filter.GlobalFilter;importorg.springframework.core.Ordered;importorg.springframework.core.io.ClassPathResource;importorg.springframework.data.redis.core.ReactiveStringRedisTemplate;importorg.springframework.data.redis.core.script.DefaultRedisScript;importorg.springframework.http.HttpStatus;importorg.springframework.http.MediaType;importorg.springframework.stereotype.Component;importorg.springframework.web.server.ServerWebExchange;importreactor.core.publisher.Mono;importjava.nio.charset.StandardCharsets;importjava.util.List;/** * 滑动窗口限流全局过滤器按接口路径每 60 秒最多 30 次。 * 注意这是 WebFlux 响应式写法全程非阻塞。 */ComponentpublicclassSlidingWindowRateLimitFilterimplementsGlobalFilter,Ordered{privatestaticfinallongWINDOW_MS60_000L;privatestaticfinallongLIMIT30L;privatefinalReactiveStringRedisTemplateredisTemplate;privatefinalDefaultRedisScriptLongslidingWindowScript;publicSlidingWindowRateLimitFilter(ReactiveStringRedisTemplateredisTemplate){this.redisTemplateredisTemplate;this.slidingWindowScriptnewDefaultRedisScript();this.slidingWindowScript.setLocation(newClassPathResource(sliding-window.lua));this.slidingWindowScript.setResultType(Long.class);}OverridepublicMonoVoidfilter(ServerWebExchangeexchange,GatewayFilterChainchain){Stringkeysliding:exchange.getRequest().getURI().getPath();returnredisTemplate.execute(slidingWindowScript,List.of(key),WINDOW_MS,LIMIT,System.currentTimeMillis()).flatMap(result-{if(Long.valueOf(1L).equals(result)){// 未超限继续走过滤器链转发给下游returnchain.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);returnexchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body)));});}/** * order 越小越先执行。-100 保证它在多数过滤器之前 * 也就是请求一进网关就先过限流避免下游被拖垮。 */OverridepublicintgetOrder(){return-100;}}这段代码的几个关键点redisTemplate.execute把 Lua 脚本和参数发给 Redis 执行返回MonoLong1 表示放行、0 表示拒绝bufferFactory().wrap()把 JSON 字符串转成响应体字节getOrder()返回 -100 让这个过滤器排在最前面保证限流拦截发生在转发之前。这套过滤器和官方 RequestRateLimiter 会叠加生效正好演示多级限流官方那层按 IP 令牌桶限速这层按路径滑动窗口限频。3.7 启动与验证本地启动一个 Redis默认端口 6379。mvn spring-boot:run启动网关。用 curl 连续快速请求观察限流效果# 连续快速发 40 个请求统计返回码分布foriin$(seq140);docurl-s-o/dev/null-w%{http_code}\nhttp://localhost:8080/api/order/1done前 20 个请求桶容量能拿到 200 或 503下游服务不存在时的网关错误码超出后会出现 429。同时可以在 Redis 里查看限流 keyredis-cli keys rate-limit:*能看到按 IP 的令牌桶 keykeys sliding:*能看到按路径的滑动窗口 key。四、踩坑经验和最佳实践限流上线前先把下面 8 个坑排掉能省下半夜被叫起来的次数。坑 1忘了定义 KeyResolver启动直接报错。RequestRateLimiter 过滤器需要key-resolver指定用哪个 KeyResolver Bean如果 Bean 不存在启动时抛NoSuchBeanDefinitionException。解决方案要么配置里写key-resolver: #{ipKeyResolver}要么只定义一个 KeyResolver Bean 让 Spring 自动注入。坑 2burstCapacity 小于 replenishRate。桶容量比每秒补充量还小相当于水龙头比桶还大配置校验直接失败。记住规则burstCapacity replenishRate。经验值一般是补充速率的 1.5 到 2 倍给突发流量留余量。坑 3在响应式网关里用了阻塞 Redis 客户端。Gateway 是基于 WebFlux 的非阻塞架构线程池很小。如果引入同步的StringRedisTemplateLettuce 同步模式在过滤器里调用一个阻塞就把整个 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: 3sRedis 故障时限流快速失败而不是无限等待。坑 7默认 429 响应没有业务可读的响应体。客户端收到 429 只知道被限了不知道是哪个维度的限流、多久能恢复。要么在自定义过滤器中写 JSON 响应体本文示例已演示要么约定 429 响应头Retry-After告诉客户端等待秒数。坑 8Lua 脚本路径写错启动时找不到脚本。DefaultRedisScript的setLocation(new ClassPathResource(xxx.lua))路径必须和 resources 目录下的实际文件名完全一致大小写敏感。脚本加载失败通常启动时就能看到异常别拖到线上才暴露。最佳实践一句话总结限流阈值压测定、key 按业务维度分、全部用响应式客户端、Redis 要高可用、429 响应体要可读、上线前用脚本模拟刷量验证效果。五、性能对比和技术选型三种限流方案里令牌桶最均衡滑动窗口最精确固定窗口最省钱但最不靠谱。实测结论本地 2C4G 环境单机网关Redis 同机部署方案实现成本单请求 Redis 命令数精确度典型场景固定窗口INCREXPIRE最低1-2有临界突刺内部接口粗略限流滑动窗口ZSET中3-4精确无突刺秒杀、活动精确限频令牌桶RequestRateLimiter低内置1Lua 原子平滑容忍突发网关入口默认首选选型建议分三档网关统一限流直接用 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 设计有问题上线后要持续观察限流命中率和误杀率动态调整。技术方案对了剩下的就是运维的细心活。
返回列表