Spring Cloud Gateway限流熔断实战:外卖霸王餐突发流量下的网关优化
发布时间:2026/9/26 20:51:38来源:尧图网络
做过外卖霸王餐活动的后台同学应该都经历过那种感觉——活动页面上写着“每天10:00开抢”作为后端负责人你从9:58开始心里就打鼓。流量对网关来说从来不像压测报告里那样均匀增长而是到点的一瞬间像水闸打开一样灌进来网关Access Log里的QPS曲线直接起飞。Spring Cloud Gateway就是这个流量洪水的第一道坝而这道坝的泄洪策略写得好不好决定了下游发券系统是稳稳接住还是直接给你丢一屏的Read timed out。这篇文章就围绕“外卖霸王餐API网关层优化”这个真实场景聊聊我基于Java Spring Cloud Gateway做自定义过滤器、实现接口限流与熔断精细化配置的完整过程。里面包含方案选型的思考、核心代码的逐步拆解、压测数据和踩坑记录尤其适合那些正在为“突发流量场景”头疼的Java后端同学参考。不管你是有几年经验的开发还是正在啃java面试题准备跳槽的人这套思路都能帮你把网关层那点事讲出深度。1. 突发流量场景分析霸王餐活动给网关出的难题1.1 “定点放量”与“瞬间洪峰”到底有多恐怖外卖霸王餐的玩法大家都知道平台每天在固定时间点放出一定数量的免费餐券用户到点开抢。这类活动的流量模型和普通业务有本质区别不是普通的波峰波谷而是“零到峰值”的阶跃式突刺。我的监控面板上记得清清楚楚活动开始前5分钟接口流量大概只有200 QPS10:00:00那一刻直接跳到接近5000 QPS然后在一两秒内回落到1000左右接着进入一个持续抖动的高位平台期。这个流量模型带来的第一个麻烦是“时间窗口极短、压力极大”。普通Web业务通常是爬坡式增长给了网关和下游扩容的时间窗口。霸王餐活动没有它就是在某一秒内把压力拉满。第二个麻烦是“热点集中”所有用户都在抢同一个领取接口路径集中在/api/activities/lucky-meal/receive这一个接口上热点无法通过负载均衡打散。第三个麻烦是“用户行为不理性”失败的用户会疯狂重试客户端没有做退避策略时相当于在原来的洪峰上又叠了一层人为放大的流量。下游发券系统也不是我们自己的是第三方外卖平台提供的接口平时响应在200ms左右一旦我们这边网关把全部流量放过去第三方接口的响应时间会直接劣化到3秒以上超时率飙升。更麻烦的是第三方发券接口有它自己的频率限制我们这边越大方地转发对方越会直接拒绝服务等于自己把下游搞死。1.2 为什么在网关层做限流熔断而不是改业务代码很多人第一反应是“我在业务Service里加个分布式锁不就行了”。这个思路没有错但放在这个场景下有几个绕不开的问题。业务层限流的代码要侵入每个接口的实现逻辑如果活动接口不止一个、活动规则频繁变化每次改限流策略都要改代码、走发布流程等变更生效活动早就结束了。另一个问题是业务层限流发生时流量其实已经穿透网关、打到了应用服务器的线程池上线程池虽然不会立刻被打爆但大量排队请求会占满Tomcat的executor线程把同进程内其他不相关的接口也拖垮。我比较喜欢打的一个比方是网关限流是小区大门口的保安业务层限流是每家每户的防盗门。保安先在大门口拦一波让进小区的人保持“有序”防盗门再处理个别漏网之鱼这才是合理的防御纵深。尤其在Spring Cloud Gateway这种基于WebFlux的网关前面Netty的线程模型本身就很抗压改造成本低、生效范围大是所有限流手段里“性价比”最高的一个位置。另外一点是网关层的限流熔断策略可以做到“按接口、按用户、按时间维度”精细化配置而且可以把配置外置到Nacos等配置中心活动期间改阈值不用重新发布。这种灵活性是写在业务代码里的硬编码限流给不了的。2. 基于自定义过滤器的限流设计与实现2.1 选型分析自定义GlobalFilter vs 官方RequestRateLimiterSpring Cloud Gateway官方自带了RequestRateLimiterGatewayFilterFactory配合RedisRateLimiter可以快速实现基本的令牌桶限流。既然官方有现成的为什么我还要写自定义过滤器我在设计之初专门做过对比核心原因有四个。第一官方的过滤器是GatewayFilter它依赖Route配置才能生效配置写在application.yml里。如果限流规则分散在几十个route配置中活动期间要动态调整阈值就非常痛苦。第二官方默认基于令牌桶算法令牌桶对“突发流量”其实是允许一定突发的这跟霸王餐场景“来了就要拦”的诉求不完全匹配——我更想要一个严格的窗口计数超过就拒绝不存在“攒了令牌然后一口气放行”的说法。第三官方的配置维度主要是route级别想做到“同一路径下不同用户不同阈值”就比较别扭得拆route或者在KeyResolver里写额外逻辑。第四团队的Java水平比较平均与其让每个人去啃官方工厂类的源码不如把限流逻辑收敛到一个全局自定义过滤器中统一维护、统一埋点出问题也好排查。当然这里也说一句公道话如果你的场景就是普通API网关限流没有复杂的业务规则用官方RequestRateLimiter完全够别重复造轮子。我这边属于“规则要精细化、配置要动态化、日志要链路化”的三有需求自研更合适。另外项目里如果已经接了Sentinel也可以直接看spring cloud gateway sentinel这套方案它把限流熔断的规则管理、控制台可视化都做了封装能省下不少事。我当时没有引入Sentinel是因为网关服务本身很轻不想为了一个功能引入整套大依赖再加上Sentinel的网关适配版本和Spring Cloud版本之间偶尔有兼容性坑综合考虑下来决定手写一个精简版。2.2 Redis Lua原子限流脚本从计数到窗口的关键逻辑限流的核心算法我选择了“固定窗口计数器”用Redis的INCR命令实现。为什么不用更高级的滑动窗口固定窗口的优点是实现简单、Redis指令开销小缺点是两个窗口交界处可能出现“双倍流量”问题比如10:00:00到10:00:10允许1000次10:00:10到10:00:20又允许1000次如果请求刚好在9:59:59和10:00:01各打一次可能形成短时间内的局部超限。但在霸王餐场景中流量洪峰持续几分钟不是几秒钟的事固定窗口的边界误差可以接受。真正要命的是不原子实现会导致计数错误。如果我用两步操作先INCR再EXPIRE设置过期时间在并发场景下可能出现“多个请求都INCR成功了但EXPIRE还没执行”的问题这会让Redis里堆积大量永不超时的key最终把Redis内存打满。解决方法是把计数和设置过期时间放进一个Lua脚本里由Redis单线程保证原子性。下面是核心脚本-- 限流脚本 -- KEYS[1]: 限流的key比如 rate:limit:user:{userId}:{apiPath} -- ARGV[1]: 窗口内最大请求数 -- ARGV[2]: 窗口时间秒 local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end if current tonumber(ARGV[1]) then return 0 end return 1脚本的逻辑并不复杂对key执行自增如果是第一次自增结果为1就给它设置过期时间。这样即使这个key原本不存在也不会出现“只INCR不EXPIRE”的情况。脚本返回1表示允许放行返回0表示限流拦截。我单独强调一个实际测试时发现的细节current 1这个判断在并发极高的情况下其实存在一个理论上的竞态窗口即多个请求同时INCR可能都读到current 1而不再设置EXPIRE。实际中这个概率极低但严谨起见可以改成if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end配合current 1判断或者直接用SET NX结合INCR的方式。更保险的写法是用redis.call(SET, KEYS[1], 0, NX, EX, ARGV[2])先设置初始值再INCR。两种方案我都测试过对业务结果影响不大但后者多一条命令能进一步规避Redis版本的边界差异我最终上线版本用的是后者原因后面在排查小节里会细说。2.3 Key设计与阈值配置多维度精细化限流限流能不能做到“精细化”关键看Redis key怎么设计。我这边把限流维度拆成了四层每一层对应一个独立的key前缀互相隔离互不影响。第一层是用户维度key格式为rate:limit:user:{userId}:{apiPath}阈值是每个用户每10秒最多1次请求。这个设计直接对应用户抢券的核心诉求——每个人在一个时间窗口内只能成功抢一次多点几次也不该穿透到下游。用户ID从请求头的X-User-Id取取不到就退回IP维度防止匿名请求绕开限制。第二层是接口维度key格式为rate:limit:api:{apiPath}阈值是每秒800次。这是保护下游第三方发券接口的关键因为第三方虽然没明确告诉我们限流阈值但根据平时峰值压测推算每秒800次已经是它稳定响应的上限了。第三层是网关全局限流key格式为rate:limit:global阈值是每秒2000次。这层应对最极端的情况即使某个接口没被限流整个网关也不能因为总流量过高而拖垮Netty线程。所有请求在进入业务路由之前都要过这一关。第四层是IP维度key格式为rate:limit:ip:{clientIp}阈值是每分钟100次。这个主要针对脚本刷单。霸王餐活动免不了有羊毛党写脚本批量薅虽然用户维度能限制登录用户的频率但脚本可以批量注册账号IP限流是最后一道防线。配置像下面这样抽成一个配置类挂在Nacos上rate-limit: user: 1:10 # 每个用户10秒内1次 api: 800:1 # 每个接口1秒内800次 global: 2000:1 # 全局限流1秒2000次 ip: 100:60 # 每个IP 60秒内100次配置中心的key结构是阈值:时间窗口秒数代码里用冒号split解析。这样运营和开发同学在活动开始之前就可以根据预估流量调整不用改代码。2.4 过滤器代码实现与执行顺序控制整体方案定下来之后实现的核心是一个实现了GlobalFilter和Ordered接口的Bean。GlobalFilter保证所有请求都会经过这个过滤器无论路由怎么配置而Ordered则决定这个过滤器在整个过滤器链中的执行优先级。Component public class GatewayRateLimitFilter implements GlobalFilter, Ordered { private static final String LUA_SCRIPT local current redis.call(INCR, KEYS[1]); if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end; if current tonumber(ARGV[1]) then return 0 end; return 1; private static final long LIMIT_INTERCEPT_ORDER -100; Resource private StringRedisTemplate stringRedisTemplate; Resource private RateLimitProperties rateLimitProperties; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 先做全局限流最简单也最前置 if (!tryAcquire(rate:limit:global, rateLimitProperties.getGlobal().getThreshold(), rateLimitProperties.getGlobal().getWindowSeconds())) { return rejectRequest(exchange, system_busy, 当前访问人数过多请稍后再试); } ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); String userId resolveUserId(exchange); String clientIp resolveClientIp(exchange); // 依次做接口维度和用户维度限流 if (!tryAcquire(rate:limit:api: path, rateLimitProperties.getApi().getThreshold(), rateLimitProperties.getApi().getWindowSeconds())) { return rejectRequest(exchange, api_busy, 活动太火爆了稍后再试); } if (userId ! null !tryAcquire(rate:limit:user: userId : path, rateLimitProperties.getUser().getThreshold(), rateLimitProperties.getUser().getWindowSeconds())) { return rejectRequest(exchange, repeat_apply, 您已参与过活动请勿重复领取); } if (clientIp ! null !tryAcquire(rate:limit:ip: clientIp, rateLimitProperties.getIp().getThreshold(), rateLimitProperties.getIp().getWindowSeconds())) { return rejectRequest(exchange, ip_busy, 操作过于频繁请稍后再试); } return chain.filter(exchange); } private boolean tryAcquire(String key, long threshold, long windowSeconds) { DefaultRedisScriptLong script new DefaultRedisScript(LUA_SCRIPT, Long.class); Long result stringRedisTemplate.execute(script, Collections.singletonList(key), String.valueOf(threshold), String.valueOf(windowSeconds)); return result ! null result 1L; } private MonoVoid rejectRequest(ServerWebExchange exchange, String code, String message) { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); String body String.format({\code\:\%s\,\message\:\%s\}, code, message); DataBuffer buffer exchange.getResponse().bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } Override public int getOrder() { return LIMIT_INTERCEPT_ORDER; } }这段代码里有两个容易踩坑的点需要专门提一下。第一是Ordered的返回值。Gateway的过滤器链中order值越小的越先执行。我希望限流过滤器在请求刚进来、还没做路由转发的时候就开始拦截所以设成了-100。如果设计不到位把order写成了正数限流过滤器会排在一些前置过滤器后面在某些场景下可能已经消耗了部分资源才被拦下来效果会打折扣。第二是writeWith方法返回的MonoVoid。这里不能直接返回一个普通的响应体JSON字符串必须用DataBuffer包装如果你在这里习惯性地返回Mono.error请求会落入全局异常处理器反而多走一段没必要的逻辑。限流拦截就应该用正常的HTTP响应给客户端一个明确的429状态码。3. 熔断机制与降级策略的落地3.1 熔断状态机与触发条件限流解决的是“入口流量过大”的问题熔断解决的则是“出口已经出问题了”的问题。这两者必须配合使用。霸王餐场景里即使用户维度和接口维度的限流都生效了下游第三方发券接口还是有可能会因为网络抖动或者对方系统自身故障而出现大面积超时。如果网关不做熔断所有转发出去的请求都会挂在那等上游超时Netty的线程资源被越占越多整个网关的吞吐量跟着塌下来。熔断器我参考了Resilience4j的状态机思想设计了三个状态关闭CLOSED、打开OPEN、半开HALF_OPEN。关闭状态下请求正常转发同时统计一个时间窗口内的调用数据当失败率达到阈值后熔断器从关闭变为打开打开期间所有请求直接降级返回不再调用下游经过一个冷却时间后熔断器进入半开状态放一小部分探测请求过去看看下游恢复没有。如果探测请求成功熔断器回到关闭状态恢复正常转发如果探测请求仍然失败熔断器继续回到打开状态重新开始冷却。状态机的代码用Enum加并发安全的计数器实现public enum CircuitState { CLOSED, OPEN, HALF_OPEN }Component public class CircuitBreaker { private volatile CircuitState state CircuitState.CLOSED; private final AtomicInteger failureCount new AtomicInteger(0); private final AtomicInteger totalCount new AtomicInteger(0); private volatile long openedAt 0L; private static final int FAILURE_THRESHOLD 20; private static final double FAILURE_RATE_THRESHOLD 0.5; private static final long OPEN_TIMEOUT_MS 30_000L; private static final int HALF_OPEN_MAX_PROBES 10; public boolean isCallAllowed() { switch (state) { case CLOSED: return true; case OPEN: if (System.currentTimeMillis() - openedAt OPEN_TIMEOUT_MS) { state CircuitState.HALF_OPEN; totalCount.set(0); failureCount.set(0); return true; } return false; case HALF_OPEN: return totalCount.incrementAndGet() HALF_OPEN_MAX_PROBES; default: return false; } } public void recordSuccess() { if (state CircuitState.HALF_OPEN) { state CircuitState.CLOSED; totalCount.set(0); failureCount.set(0); } else if (state CircuitState.CLOSED) { totalCount.incrementAndGet(); } } public void recordFailure() { if (state CircuitState.HALF_OPEN) { state CircuitState.OPEN; openedAt System.currentTimeMillis(); return; } if (state CircuitState.CLOSED) { int currentTotal totalCount.incrementAndGet(); int currentFailures failureCount.incrementAndGet(); if (currentTotal FAILURE_THRESHOLD (double) currentFailures / currentTotal FAILURE_RATE_THRESHOLD) { state CircuitState.OPEN; openedAt System.currentTimeMillis(); } } } }这里是简化写法到了生产环境真正跑起来后你会发现用AtomicInteger做计数存在并发窗口isCallAllowed和recordFailure两个方法之间不是原子的可能会出现请求刚被判定允许调用后一秒熔断器就打开了的情况。这个在单机场景能忍多实例场景下每个节点各自统计各自的熔断状态也够用。如果要做得更严谨可以用AtomicReferenceState加CAS递归更新或者直接用现成的Resilience4j不必自己把状态机打磨到完美。3.2 失败率统计的滑动窗口实现上面代码里用的是一个累计计数器它有一个明显的问题只要整个生命周期内失败次数够多熔断器就会打开即使最近一分钟请求都是成功的。这在真实流量下不科学。我需要的是“最近一段时间”的失败率而不是全生命周期的失败率。滑动窗口有两种实现思路。一种是用时间片数组把窗口切成一秒一个槽位每秒一个计数比如窗口10秒就用10个槽位新请求落在当前秒的槽位上计算时把整个窗口内所有槽位的数据加起来。另一种是直接用Redis的ZSET用timestamp作为score每次调用成功或失败都往ZSET里记一条记录滑动窗口内统计时删除时间窗口之外的记录再计算失败比例。我在Gateway这套方案里选择的是JVM内存里的时间片数组原因还是“简单、快、不影响Redis”而且网关做个短路熔断不需要跨节点共享状态——每个网关实例独立判断下游是否健康就够了。如果一个网红活动地把所有网关实例都拉垮那说明已经不只是下游故障而是整个网关需要集群重启了。下面是时间片数组的核心逻辑片段private static final int WINDOW_SECONDS 10; private final SlidingWindowMetrics[] metrics new SlidingWindowMetrics[WINDOW_SECONDS]; private static class SlidingWindowMetrics { private final AtomicLong successCount new AtomicLong(0); private final AtomicLong failureCount new AtomicLong(0); }这组代码的运行机制是根据系统当前秒数对WINDOW_SECONDS取模得到槽位每次调用成功或失败就更新当前槽位的计数。计算失败率时遍历所有槽位汇总出窗口内的总成功数和总失败数失败数除以总数就是失败率。由于槽位是数组固定大小没有动态创建对象GC压力可以忽略。我实测在5000 QPS的流量下这个统计逻辑本身消耗的CPU几乎可以不计。使用时间片数组要注意一个细节槽位更新时可能会被多个线程同时写所以计数用了AtomicLong。如果对性能有极致要求可以用LongAdder替换AtomicLong在高并发下减少CAS竞争。3.3 降级响应设计返回什么才能让用户不流失熔断触发后网关需要给用户一个结果。这个结果如果处理不好用户看到的是“请求失败”体验直接崩塌。降级响应不能只返还一个冰冷的500 Internal Server Error而应该结合业务诉求设计一个用户可感知、可下一步操作的结果。我给霸王餐活动设计了三种降级响应。第一种是“排队中”语义当用户维度的限流拦截触发时返回HTTP 429body里包含code: queueing和message: 当前排队人数较多已为您保留参与资格。客户端拿到这个code后做3到5秒的定时轮询轮询的请求也走网关但会命中另一条“查询排队状态”的接口路径不触发存量的用户限流。这个设计把“失败重试”变成了“有序重试”避免了用户无脑刷新制造更多流量。第二种是“系统繁忙”语义当全局或接口维度限流触发时返回HTTP 429body里包含code: system_busy和message: 活动太火爆请稍后再试。这种响应客户端拿到后不应该立即重试而是做指数退避退避基准时间是2秒。我在客户端SDK里专门加了这段退避逻辑实测能把无效请求数降低一半以上。第三种是熔断触发时的降级返回HTTP 503body里包含code: service_unavailable和message: 发券系统繁忙已记录您的参与资格请稍后在活动页查看结果。这里是利用了活动本身的规则——霸王餐券是限量发放但并不是一个实时生成的刚需允许在用户参与后延后几分钟再通知结果。所以网关做熔断降级时可以额外写入一个“参与记录”到消息队列等下游恢复后再异步补发。这样即使全程熔断用户也不会觉得自己白抢了。有一点必须提醒降级响应的body要稳定不要每次动态生成一个随机文案。我之前测试时为了趣味性加了随机抽奖式文案结果客户端那边埋点数据解析直接乱套线上行为后台上全是未知code。降级文案应该是一份收敛的枚举字典服务端和客户端约好每个code的展示文本最多在兜底文案里带一个时间戳之类的动态变量。4. 参数调优、性能验证与监控4.1 限流阈值怎么定从容量评估到业务目标限流阈值拍脑袋定肯定不行要沿着一条估算链路往下推。我先明确一个业务约束霸王餐活动预计参与人数20万活动时间段10:00至10:15。如果假设绝大多数用户会集中在开始后5分钟内完成首次点击那平均每秒的用户到达率大约是200000除以300秒约667个用户每秒。再考虑每个用户会因为误触、页面刷新发出1.5次请求接口维度的起始阈值设为每秒1000次是合理的。为了给下游留下安全余量我实际设成了800次每秒。用户维度的阈值则完全由业务规则决定。霸王餐活动规则规定每天每个用户只能领取一次那用户维度的限流阈值就是10秒1次。这个10秒窗口的意思不是允许用户10秒后再次请求而是说“同一个用户连续两次请求的时间间隔不能小于10秒”超过10秒后如果用户再次请求且此时还有剩余名额可以重新进入发券流程。这样做既保证了规则不被绕过又不会误伤正常用户在不同时间段的操作。下游第三方接口的容量评估更关键。我找第三方接口负责人要了性能基线数据他们给出的建议是不超过500 TPS低于500时P99响应时间在300ms以内超过后响应时间迅速劣化。网关到第三方之间还有一层内部的Feign调用和消息队列最终我保守地把接口维度的限流阈值定在每秒800次实际压测效果是第三方接口P99稳定在350ms左右没有出现雪崩。这里要给一个决策原则限流阈值给出的不是“系统能承受多少”而是“系统能承受多少乘以一定安全系数之后还想让它承受多少”。我习惯的做法是把系统容量压测出来的最大值乘以0.6作为网关限流阈值再往后端再加一层0.8的系数作为后端自身限流阈值。这样即使流量突然抖动前端后端各留了40%和20%的余量不至于一次流刺就把整个链路打穿。4.2 压测过程中暴露的三个性能瓶颈代码写完成之后不能直接上线我在灰度环境压测时挖出了三个比较典型的性能瓶颈这里逐个还原一下现场。第一个瓶颈是Redis客户端连接池配置不足。第一次压测跑到1500 QPS时网关的CPU使用率还没过30%但接口耗时里有大量Redis异常日志里刷着JedisConnectionException: Connection refused。打开监控面板发现Redis客户端的活跃连接数一直顶在连接池上限8个每个请求都在等待获取连接。Spring Boot默认的2.x Redis连接池配置非常保守必须根据压测数据调大我最后把maxTotal调到了200maxIdle设成100minIdle设成50。这个调整直接让网关在5000 QPS下Redis操作仍然稳定。第二个瓶颈是所有限流操作串行执行的Latency累积。早期版本的过滤器逻辑是先查用户限流再查IP限流再查接口限流三段串行执行每次执行都要跟Redis做一次网络RTT三次一共增加了大概1.5ms延迟。高并发下1.5ms的耗时非常珍贵。后来我改成了使用Lua脚本一次性检查多个key把三次RTT合并成一次Redis单条Lua脚本内部可以顺序执行多个redis.call。这个优化把过滤器的平均耗时从1.8ms降到了0.6ms。第三个瓶颈是过滤器响应体序列化方式。最初我用exchange.getResponse().writeWith(Flux.just(buffer))感觉没什么问题压测时却发现写入大JSON响应的时候网关的响应吞吐量下降明显。后来检查发现是我在响应头里加了不合理的Content-Length配置导致响应体被错误分片。删掉这个头之后由网络栈自动处理问题消失了。这属于一个小的低级错误但排查花了我两个小时写出来希望大家少走弯路。4.3 监控指标与告警配置网关层做了限流和熔断如果没有监控你根本不知道这些机制有没有在正常工作。我在网关服务里额外注册了一套核心指标接入Prometheus后放到Grafana上看板。指标主要围绕三类。限流类指标gateway_rate_limited_total按type和path打标签记录每个维度被拦截的总数。活动期间我主要盯的是“用户维度拦截”有没有异常走高如果突然从每分钟几百变成每分钟几千大概率是羊毛党开始刷了就要把IP维度阈值收紧一点。熔断类指标gateway_circuit_breaker_state记录当前熔断器状态0表示关闭、1表示打开、2表示半开。状态变化会产生告警在活动期间只要看到状态切换到OPEN就说明第三方接口那边出了问题需要及时联系对方或者切换降级预案。基础类指标gateway_http_requests_total、gateway_request_duration_seconds这些是常规的请求量、耗时指标主要用来核对限流是不是按预期改变了流量模型。监控还有一个短板需要补上日志和指标之间的关联性。我在过滤器被拦截的地方都打了一条WARN级别日志带上requestId、userId、path、limitKey、threshold这些信息。这样一来线上如果用户投诉说“明明没到限流次数怎么就被拦截了”我可以根据requestId从日志系统里把这条被拦截的记录捞出来对照Redis里实际计数器的值快速定位问题。没有这种可关联的日志光看图表的拦截总数是回答不了“某个具体用户为什么被拦”这种问题的。5. 常见问题与实战避坑记录5.1 过滤器执行顺序混乱导致限流失效自定义过滤器和网关内置过滤器之间是有执行顺序问题的。Spring Cloud Gateway的过滤器链是一个有序列表order值越小越靠前执行。我记得第一次把限流过滤器加进去时没太在意order结果压测发现很多请求已经走到了路由转发阶段才被限流器拦下下游已经被打了大量无效请求。排查后发现问题出在order设置上。如果把order设成一个正数比如10那它排在很多内置过滤器的后面因为内置过滤器部分order是负数。请求顺序是先经过一些前置处理过滤器再到我的限流过滤器意味着在做完路由匹配、甚至已经尝试处理的时候限流才介入。正确的做法是把order值设为一个比较小的负数确保它排在所有业务路由过滤器之前。我这边定的是-100你也可以设成-1000效果类似只要保证它不是正数就行。还有另一个坑是多个自定义过滤器之间的顺序。限流和熔断过滤器如果order相同加载顺序会依赖Spring的Bean初始化顺序这是不可控的。强烈建议不同的全局过滤器之间不要设置相同的order值用不同的负数区间留出清晰的前后层级。5.2 Lua脚本误用引发的事故以前写限流脚本的时候我踩过一个非常隐蔽的坑。早期版本我把EXPIRE的过期时间直接写死在脚本里比如固定10秒。后来优化成通过ARGV传值后上线前测试时没注意脚本KEY的拼接方式导致所有用户共用了一个全局KEY。结果是活动一开第一个用户进入后计数器一直累加第1001个请求直接触发全局限流所有用户全部被拦住。这个事故的原因很简单我在代码里用String userId null默认值当请求头里没有用户信息时拼接出来的key是rate:limit:user:null:{apiPath}所有匿名用户都共用同一个key。修复方案是在解析不到用户ID时改用IP维度限流绝不使用固定字符串占位。还有就是建议在脚本执行后打一条DEBUG日志记录拼接后的完整key上线初期用日志验证key是不是符合预期。Lua脚本还有一个常见问题是脚本缓存。stringRedisTemplate.execute每次传入脚本内容Redis会缓存脚本内容但每次执行的EVALSHA版本如果对不上会导致NOSCRIPT错误。Spring Data Redis内部对脚本做了缓存处理一般不会出问题但在某些自定义客户端封装里需要显式使用DefaultRedisScript并开启缓存。我在代码里统一用的DefaultRedisScript不再手动拼接脚本字符串。5.3 配置热更新的正确姿势限流阈值刚上线时是写在application.yml里的活动期间发现某个接口阈值偏紧需要临时放开只能改代码重新发布。后来我把阈值全部迁移到了Nacos配置中心用ConfigurationProperties绑定一个RateLimitProperties对象。Nacos配置中心支持配置变更后自动刷新Bean属性但这里有个前提限流过滤器中读取的是属性对象的实时值不能把配置值缓存在一个static变量里。之前我看到过同事写过这样的代码在PostConstruct方法里把配置值读到一个静态Map中后续所有的判断都从Map中取。这种写法在配置中心下发新值之后静态Map不会自动刷新等于配置热更新完全失效。正确做法是直接使用Spring管理的Bean属性或者监听RefreshScope事件后手动更新本地缓存。我在Gateway的配置类上加了RefreshScope注解保证配置中心推送新值后限流过滤器中拿到的属性是最新的。活动期间我和运营的协作方式是他们预估活动开始后前10分钟的参与率我在后台根据监控数据每5分钟调整一次阈值。Nacos上修改值的生效时间在百毫秒级实测足够应对流量突变。但要注意Nacos推送本身也有网络延迟如果遇到极端情况推送不畅可以再给配置中心配一套直连重试机制或者干脆做一个简单的本地配置降级文件作为兜底。5.4 多实例网关的时钟漂移问题当网关从单实例扩展成多实例部署后限流逻辑本身依赖Redis所以计数是全局准确的这点设计对了。但熔断器状态保存在每个实例的JVM内存中不同实例之间是独立的这带来一个问题如果网关有6个实例其中一个实例触发了熔断另外5个实例还在继续往下游转发请求熔断并没有发挥出全局限流的效果。这是一个可以被接受的折中但也需要明确它的影响范围。单实例熔断触发后Nginx负载均衡会把后续请求均匀地分发到其他实例上这些实例如果连续收到失败响应也会在各自的计数窗口内触发熔断最终实现“慢扩散式熔断”。这个过程通常需要几秒钟对下游造成的瞬时压力是可控的。如果你追求更严格的全局熔断要么使用Redis存储熔断状态要么直接上车Sentinel控制台由控制台统一做规则管理和推送。时钟漂移这个点更隐蔽。时间片数组滑动窗口依赖本机System.currentTimeMillis()每个实例的系统时间如果漂移超过几百毫秒各个实例对“当前时间窗口”的判定就会出现错位进而导致统计的数据不准确。解决办法是在部署网关实例的主机上配置NTP时间同步并在代码里统一使用一个时间服务注入避免依赖本地时间的细微偏差。我所在团队碰过一次因为虚拟机时间漂移导致熔断提前触发的问题后来排查了很久才定位到是时钟问题这里提醒大家尽量在基础设施层面就做好时间同步。写在最后的一些体会这个网关层优化方案从设计到上线前前后后经历了三轮迭代每次都是被真实流量教育之后才做出的调整。现在回头看最值得骄傲的不是那几段限流脚本而是把“流量入口-网关策略-下游容量”这三层的关系理顺了网关限流保护的不是网关自己而是整个下游链路。把它当成一道需要精细调节的水闸工程来对待比单纯写一个if (count limit) return false;要有意义得多。如果再让我重新做一次这个项目我会一开始就把Sentinel的网关适配方案纳入评估范围毕竟手写熔断状态机虽然能学到很多但生产系统的稳定性更多来自久经考验的成熟框架。最后再分享一个经验上线前无论如何都要做一次完整的全链路压测把网关、下游第三方、数据库和缓存都圈进来不要只压网关单独一段。压测时如果发现第三方接口不能像内部服务一样随意加压那就用Mock服务模拟它的响应延迟和超时这样才能真正逼出网关层限流熔断策略的极限在哪里。
网站建设高端定制企业官网