资讯动态

Spring Cloud Gateway网关限流:基于Redis令牌桶的完整实战

发布时间:2026/10/17 13:36:41 来源:尧图企业网站定制
网关层接口被突发流量打爆是微服务落地后迟早会遇到的事。我之前在一个模拟电商项目里就吃过教训订单服务高峰期响应从几十毫秒飙升到几秒数据库连接池直接被占满最后整个链路雪崩。后面我把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为10burstCapacity为20requestedTokens为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 groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 自定义KeyResolverIP、用户、组合维度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是2burstCapacity是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. 进阶玩法动态限流与自定义RateLimiter4.1 内置方案的最大限制内置RedisRateLimiter虽然开箱即用但有一个明显短板它把配置固定在application.yml里改参数必须重启网关。如果限流阈值需要根据线上流量动态调整比如流量高峰时段放宽、平时收紧重启会打断网关转发而且很难精细到某条路由或某个用户组。另外内置方案对“拒绝后怎么办”的定制能力有限。默认是直接返回429没有可扩展的降级逻辑。实际业务中用户被限流时往往希望返回一个友好提示或者把请求转发到排队页而不是看到一个光秃秃的状态码。要突破这些限制有两种做法一是自定义RateLimiter实现把规则读取改成动态获取二是直接写一个自定义GlobalFilter完全绕过内置过滤器自己控制限流与降级逻辑。下面分别讲。4.2 实现一个支持动态规则的RateLimiter先看自定义RateLimiter的做法。RateLimiter接口需要实现一个isAllowed方法返回是否放行以及剩余令牌数。我在某个项目里就这么做过规则存到配置中心网关通过监听配置变更实时更新限流阈值。实现思路可以用代码骨架来描述Component public class DynamicRedisRateLimiter implements RateLimiterRequestRateLimiterConfig { private final StringRedisTemplate redisTemplate; private final RuleProvider ruleProvider; public DynamicRedisRateLimiter(StringRedisTemplate redisTemplate, RuleProvider ruleProvider) { this.redisTemplate redisTemplate; this.ruleProvider ruleProvider; } Override public MonoResponse 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 RedisTemplateString, String redisTemplate; Override public MonoVoid 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观察第N1次请求改配置参数后不生效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、路径、命中规则输出到日志方便后续做分析。如果没有这些数据限流阈值调起来就是拍脑袋出了问题也复盘不了。动手之前先把最小可跑通的案例装起来再去调参数和做扩展实践中是最稳妥的路径。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑