☰
Spring Cloud Gateway实战:路由、过滤、限流熔断与生产避坑
2026/10/16 8:50:12 网站建设 项目流程

1. 网关选型复盘:为什么是Spring Cloud Gateway而不是其他方案

1.1 为什么我最终放弃了Servlet模型的网关

很多团队接触微服务网关,第一反应是"搞个Nginx反代不就行了"。对一个内部只有两三个服务的项目来说,确实如此。但服务数量到了十几个,路由规则开始跟注册中心联动、需要按请求头做灰度、按AppId做限流、按服务名做熔断时,纯Nginx配置会变得难以维护。我接手过一个订单链路,光nginx.conf就有两千多行,每次发版要运维手工改配置,出一次错就要回滚。现在回过头看,当时的根本问题不是Nginx不行,而是网关层的"业务逻辑"越来越重,需要一个能写代码的网关。

Spring Cloud Gateway是Spring官方在Finchley版本后主推的响应式网关,底层跑了WebFlux和Netty,从设计上就是为了替代Zuul 1.0。Zuul 1是Servlet模型,每个请求独占一个线程,数据库慢查询一拖,线程池瞬间被打满。而Gateway的Netty线程模型是事件循环驱动的,少量线程可以处理大量并发连接,压测时同样配2核4G的容器,Zuul扛不住的流量Gateway能扛,这就是我选它最朴素的原因。

1.2 响应式不是免费的糖果

Gateway把Servlet API换成了WebFlux的ServerWebExchange,这带来一个重要约束:你原来的HttpServletRequest、HttpServletResponse那套心智模型不能用了。很多第一次写网关过滤器的人会下意识去调exchange.getRequest().getSession(),然后发现根本没有Session这个概念。网关层是无状态的,它只负责转发和前置逻辑,别再往过滤器里塞业务状态。这种模型对老Java开发者初期会有点别扭,但一旦理解请求处理过程是由一串Mono 回调连接起来的,你就掌握了它的精髓:整个链路是一个回调式责任链,走到chain.filter(exchange)才往下游流动。

迁移成本其实远大于选型时大家的预期。我见过一个项目把网关从Zuul迁到Gateway,代码改了半个月,最后发现有些第三方SDK是基于Servlet的,放不进Gateway的过滤器链,只能单独部署一个辅助服务。所以我的建议是:如果团队对响应式编程完全没经验,或者业务里全是那种必须直接操作Servlet API的老接口,强上Gateway未必是优化,它是重构。

1.3 网关架构位置:Gateway不是用来取代Nginx和Kong的

很多人还有个误区,觉得用了Spring Cloud Gateway,Nginx和Kong就该退役了。实际上合理拓扑是:最外层Nginx或负载均衡器负责TLS终止、DDoS缓解和静态资源分发,内层的Gateway负责路由编排、灰度、限流、鉴权、链路追踪。Gateway再往下才到服务实例。这样分层的好处是,Nginx把最脏最暴力的流量挡在外面,Gateway专注于业务属性的转发策略,不需要跟网络层面的问题纠缠。我见过直接把TLS证书放在Gateway上、Nginx做纯四层转发的做法,也不是不行,但证书更新、双向认证、SNI分流这些职能全部落到Gateway后,运维面会变宽,出了问题排查链路更长。

Kong这类基于OpenResty的网关也能做动态路由和插件化,如果团队Lua能力强,它确实是不错的方案。但对我来说,Kong的插件生态虽然丰富,真要写一个跟业务代码深度联动的Filter(比如读取Redis里的Token再做路由决策),最终还是得在网关服务里写Java类更顺手。所以Gateway这类代码型网关联调业务系统的能力是无价的,而Kong更适合做纯通用能力的聚合网关。两种流派各有所长,选型要看你团队的语言栈和需求形态。

2. 路由与断言:把RouteDefinition吃透

2.1 一条路由的五个组成部分

Spring Cloud Gateway里,Route的核心概念是:id、uri、predicates、filters、order。每个属性都有明确边界。id是路由标识,uri是目标地址,predicates决定"这条路由何时命中",filters负责"命中之后做什么修改",order决定多个路由之间的优先顺序。理解它们的关系可以用一个生活类比:网关是小区大门,每条路由是一栋楼的指引牌,断言是识别特征(比如"你穿着快递员制服"),过滤器是进楼前的门禁检查(查快递单号、称重、登记),最终uri就是楼栋地址。

YAML里最常见的写法是:

spring: cloud: gateway: routes: - id: order-consumer uri: lb://order-service predicates: - Path=/order/** - Method=GET,POST filters: - StripPrefix=1 - AddRequestHeader=X-Source, gateway order: 1

这里Predicate之间是AND关系:必须Path命中且Method命中,路由才成立。Filter配置的顺序就是执行顺序,StripPrefix去掉一层路径前缀,AddRequestHeader在转发前加请求头。这些是基本操作,但很多人忽略的是:predicates的匹配结果不只是在路由选择阶段用,它还会把匹配到的参数放进ServerWebExchange的attributes里,后面过滤器可以读取。比如Path断言匹配到的路径模板变量,可以通过exchange.getAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE)拿到。你要做路径参数透传,这就很关键。

2.2 多路由匹配的先后顺序与404陷阱

网关匹配路由时会按order递增的顺序逐条尝试,命中条件一旦满足就用当前路由执行过滤器链,不再往后匹配。所以当你有两三条路由都写了Path=/order/**,一定要把更具体的路由排前面,或者通过order显式控制。我曾经把一条"兜底转发"/**的路由放在最前面,结果所有请求全部被它吞掉,后面精心设计的灰度路由、限流路由完全没生效。排查了半天,最后发现就是order默认都是0,而YAML的配置顺序本身就是定义顺序,我写兜底写太靠前了。

还有个小细节:没有命中任何路由时,Gateway返回的是404,不是让你自己处理的转发失败。在压测或者联调时,看到404第一个反应应该是"断言没配上",而不是"目标服务挂了"。这一类问题我给它起了个外号叫"404陷阱",很多人把时间浪费在查下游服务上,其实网关压根没有把请求路由出去。

2.3 动态路由:用RouteDefinitionRepository打破重启魔咒

静态路由适合稳定链路,一旦要做灰度发布、多环境切换、配置中心联动,手动改YAML重启的方式就不可接受了。Spring Cloud Gateway提供了RouteDefinitionRepository接口,你可以自己实现它的getRouteDefinitions()、save()、delete(),把路由定义搬到数据库或配置中心。核心思路是:当配置中心推送新路由时,调用:

applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));

这个事件会触发路由刷新,网关重新从RouteDefinitionRepository拉取最新的路由定义。我在订单系统里就做过一个管理端,页面里改一条路由,存到数据库,发事件,网关立即可用,完全不用重启。这里有个容易忽略的点:RouteDefinition对象里的Predicate定义是字符串格式的,比如"Path=/demo/**",网关内部靠对应的PredicateFactory去解析。你从数据库读出来的字符串必须严格符合每种PredicateFactory的参数格式,否则刷新时会直接解析失败,整个路由表变成空。所以我们当时在管理端保存前做了一次本地校验,把Predicate定义构建成RouteDefinition后立刻尝试解析,保证写进去的都是能用的。

动态路由结合注册中心还能更野:直接从服务列表里生成Route。Spring Cloud Gateway原生支持lb://服务名,但默认路由是静态的,想实现"所有服务自动暴露网关入口"就需要自定义RouteLocator:

@Bean public RouteLocator customRouteLocator(DiscoveryClient discoveryClient) { return new RouteLocator() { @Override public Flux<Route> getRoutes() { // 遍历discoveryClient.getServices(),为每个服务生成Route } }; }

这样新增一个微服务,网关入口自动出现。但这种"自动暴露"是有风险的,暴露了不该暴露的接口就麻烦了。我建议自动生成的路由只开放给内部网络,对外入口还是走显式配置的受控路由。

3. 过滤器体系:网关里最灵活的扩展点

3.1 GlobalFilter、GatewayFilter与FilterFactory的分工

Spring Cloud Gateway有三层过滤器,很多人会搞混:

类型作用范围典型场景
GlobalFilter作用于所有请求鉴权、TraceId、限流、日志
GatewayFilter作用在某条路由上路径重写、加请求头、重试
FilterFactoryGatewayFilter的可配置化工厂通过YAML参数定义单个GatewayFilter

链条执行时,GlobalFilter会与当前路由的GatewayFilter合并成一个有序列表,顺序由Order值决定。你实现GlobalFilter通常还会实现Ordered接口。全局过滤器和路由级过滤器是混在一起排序的,并不是先说GlobalFilter再执行GatewayFilter。所以调整Order时要全局看,不能只盯着自己写的那一个类。

这里的底层流程是:请求进来先走DispatcherHandler,由RoutePredicateHandlerMapping找到匹配路由并构造GatewayFilterChain,然后链式执行,直到最后一个Filter调用下游客户端把请求发到目标服务。理解这个链路,你才能精准判断"我这个过滤器应该放在路由选择之前还是之后"。比如鉴权逻辑必须在路由命中之后、转发之前执行,你就能直接把它挂到过滤器链的头部,让不合法的请求提前返回401,不必再往下游转发。

3.2 自定义全局过滤器:一个可复用的鉴权模板

我提供一个实际用过的全局鉴权过滤器精简版,思路是先检查Token,再决定放行还是返回401:

@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // 白名单路由直接放行 String path = request.getURI().getPath(); if (path.startsWith("/public/")) { return chain.filter(exchange); } String token = request.getHeaders().getFirst("Authorization"); if (token == null || token.isBlank()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 假设这里调用内部认证服务校验token,并解析出用户信息 ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", "10086") .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } @Override public int getOrder() { return -100; } }

这段代码值得拆开讲三点。第一,返回响应要用exchange.getResponse().setComplete(),它跟Spring MVC里的return ResponseEntity完全不是一个套路,很多人习惯性写try-catch然后抛异常,结果发现异常根本不会被统一处理,因为WebFlux下异常要交给ErrorWebExceptionHandler或自己捕获。第二,往下游传用户信息不是死板的setAttribute,修改ServerHttpRequest的Header是标准做法,下游服务不用感知网关存在,读Header就行。第三,getOrder()用负数确保它在大部分过滤器前面执行,因为Order越小的Filter越先执行,这是网关里最容易被忽略的规则。

3.3 Order排序里的三个坑

Order排序的坑,几乎每个人都会踩一次。第一个坑是正数不够用:你希望"在最后一步打印日志",但下游转发过滤器Order很大,如果你随手写了Order=100,可能仍然排在下游转发之前。我后来给关键Filter定了规范:全局日志-200、鉴权-100、限流0、动态路由100、转发兜底200,再往后留出业务扩展空间。

第二个坑是GlobalFilter和路由Filter混排。路由配置里显式排列GatewayFilter的顺序,最后再和GlobalFilter合并。如果两者Order值重叠,合并结果会不稳定,同一条链路每次执行顺序都可能因为路由表更新而变化。解决办法就是为全局过滤器预留独立的Order区间,比如都取负数或都取正数大数字,避免重叠。

第三个坑是Filter里读Body时会破坏后续Filter的Body可见性。我在做验签时踩过:读了一次请求体,后面的签名校验过滤器再读就是空。因为ServerHttpRequest的Body是流式的,读过一次就没了,必须把Body缓存下来,用自定义装饰器包装Request。网关层的Body缓存很耗内存,大请求会导致GC压力飙升,只建议在需要对Payload做校验的特定路由上启用,不要全局加。

4. 限流与熔断:网关层稳定性治理的取舍

4.1 RequestRateLimiter与Redis令牌桶

网关限流跟业务限流不一样,网关不关心每个接口的业务特性,它只关心"我这个入口每秒能处理多少流量"。Spring Cloud Gateway内置了RequestRateLimiter过滤器工厂,配Redis实现令牌桶算法。所谓令牌桶,就是系统以固定速率往桶里放令牌,请求来了要拿令牌才能过,桶满时令牌溢出,桶空时请求直接拒绝。它跟漏桶的区别是,令牌桶允许一定程度的突发流量,就像收费站可以预支通行券,只要桶容积没满,瞬时并发大了也能放行。

一个典型的配置长这样:

filters: - name: RequestRateLimiter args: key-resolver: "#{@userKeyResolver}" redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 redis-rate-limiter.requestedTokens: 1

replenishRate是每秒钟往桶里放多少令牌,burstCapacity是桶的最大容量,requestedTokens是每个请求消耗几个令牌。如果burstCapacity填的是replenishRate的两倍,就意味着最多允许突发两秒的流量,再多就直接429。这里最常见的一个错是burstCapacity填成1000而replenishRate只有10,桶是1000但每秒只补充10个,第一秒请求全过了,之后很久都在贫血,限流完全失效。

4.2 KeyResolver:限流粒度怎么定

限流的桶是分布式共享的,但KeyResolver决定每个请求归到哪个桶。默认的PrincipalNameKeyResolver取的是登录用户名,如果你根本没做登录,这个方案完全不生效。自己实现一个按用户ID或按IP限流的Resolver很简单:

@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); if (userId == null) { userId = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); } return Mono.just(userId); }; }

同一用户ID能精确限流,匿名请求按IP兜底。但要注意,如果整条链路的请求都经过网关,所有下游服务会共享同一套全局限流,某个大流量业务的突发会拖累其他业务。所以更合理的做法是给不同路由配不同的RateLimiter参数,或者把KeyResolver加上路由ID前缀,让桶按"路由+用户"维度隔离,比如:

return Mono.just(routeId + ":" + userId);

这里routeId从exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR)取。这种组合Key在微服务多业务混跑时非常实用,我实际配过之后,限流误伤的概率低了很多。

4.3 熔断降级:什么时候适合在网关做,什么时候不该做

Spring Cloud Gateway集成熔断,新一点的玩法是配合Resilience4j的Reactive CircuitBreaker,配置一个SpringCloudCircuitBreakerFilterFactory:

filters: - name: SpringCloudCircuitBreaker args: name: orderServiceCB fallbackUri: forward:/fallback/order

下游调用失败率超过阈值后,请求不会继续压向下游,而是转到fallbackUri对应的一个内部处理器返回降级内容。这里我要说一句:网关层熔断适合的是"快速失败、保护下游"这种粗粒度场景,比如下游依赖的数据库挂了,你再怎么重试都是给雪上加霜;但如果是业务逻辑里的偶发超时,更适合在服务调用层做重试或降级,而不是让网关一刀切。为什么?因为网关不知道每个业务接口的容忍度,有些接口慢一点只是体验问题,直接被网关熔断反而会造成更大范围失败。

我在做支付链路时深有体会,用户查订单详情慢了两秒,你直接熔断给个空白页,用户只会以为系统崩了。网关熔断的正确姿势是:设一个保守的阈值,比如5秒超时、错误率50%以上再熔断,同时熔断窗口不要太长,否则恢复期间用户一直被拒。Resilience4j的时间窗口、最小调用次数、滑动窗口大小这些参数都得压测调,不能拍脑袋。

另外别忘了超时配置。Spring Cloud Gateway自己的HttpClient有connect-timeout和response-timeout,默认响应超时通常不够用也没关系,但一定要区分全局和路由级别。全局超时配5秒,大文件下载接口就得单独放开,否则正常下载也会被网关掐断。Route级别超时新版支持在metadata里配response-timeout,也可以用自定义GlobalFilter覆盖,操作起来并不复杂,关键是要有意识地去梳理各业务的时延水位。

5. 没有文档的角落:长连接、大文件与证书的实战坑

5.1 WebSocket转发:Netty隐藏的细节

Spring Cloud Gateway对WebSocket的支持常常被忽略,因为文档里只有一句"以ws/wss开头即可"。实际用起来,你只要把route的uri设成ws://下游服务地址,Gateway的WebSocketRoutingFilter会自动接管,它会把HTTP Upgrade请求转换成WebSocket连接。这个Filter的执行有个大前提:它必须在过滤器链中足够靠前,并且执行后不再走后面的HTTP转发逻辑。如果你自定义了GlobalFilter给了很大的正Order值,可能反而把WebSocket走向搞乱。

WebSocket是长连接,网关内部的连接池、空闲超时都会影响连接生命周期。我踩过的一个坑是:前端页面看着WebSocket连接建立成功,但过了一两分钟就掉线。排查到最后是网关心跳没处理,后端服务自己有心跳,但注册中心给的实例IP变了,网关把请求导向了已经下线的实例。长连接场景下,服务实例迁移、滚动发布都会导致存量连接断裂,这是网关层很难完全规避的问题。我现在会让下游服务把WebSocket的心跳周期调短,客户端自动重连,网关不做额外的连接保活代理逻辑——因为让它保活带来的维护成本远高于直接让客户端重连。

5.2 大文件上传与413:冲垮内存前必须知道的事

大文件上传走网关,经常出现413或者内存暴涨。原因在于Gateway基于WebFlux的Netty,默认会对请求体做内存缓冲处理。你在Spring MVC里可以用MultipartFile愉快地处理几百MB文件,但Gateway里如果配置不当,大文件会在网关层先把整个Body读进内存,再往下游转发,内存一下子就被吃满。

解决办法有几个方向。最简单粗暴的是在网关前面直接限制文件大小,超过阈值直接403,让大文件走对象存储旁路,不经过网关。我实际做过一个图像上传服务,1MB以下走网关,超过1MB直接302到对象存储的预签名URL,这样网关几乎不吃上传流量。如果确实必须经网关,把spring.codec.max-in-memory-size调大只是治标,还会加剧内存风险;更好的是用Gateway的流式转发能力,但要注意很多中间Filter(比如验签、记录Body)会强制触发身体读取,把流式变成了全量缓冲。所以大文件链路尽量走"纯净通道",只保留最少量的Filter。

响应侧也有类似的坑。Gateway转发响应时,默认是按chunked流式返回的,但有些老服务不会设置Content-Length,前端就等不到响应结束。我在对接一个遗留HTTP接口时就卡了很久,最后发现是下游返回的Content-Length跟实际Body长度不一致,被Netty直接掐断。遇到这种问题,优先排查响应头,别动网关。

5.3 TLS终止:证书放网关还是放Nginx

关于证书,我的建议是能放外层负载均衡就放外层。Spring Cloud Gateway作为Java服务,做HTTPS卸载需要配置server.ssl,还要维护证书信任库,每次证书更新都要重启网关进程,对于追求高可用的服务来说很尴尬。如果非要让网关终结TLS,一定要用标准证书管理工具维护PKCS12格式,不要把私钥直接写在YAML里。证书泄露不是开玩笑的,网关层的私钥泄露意味着整个域名的HTTPS防护失效。

双向TLS是Gateway做服务间安全传递的一个亮点:网关验证客户端证书,然后把解析出的客户端身份通过Header传给下游。这个方案在内部服务间交互很实用,但双向TLS会增加不少运维复杂度:证书轮换、吊销列表、错误处理都要做,小团队慎用。如果只是内部服务互信,更简单有效的方案是网关统一鉴权后,在Header里增加可信调用来源标记,下游只信任网关这个来源。

6. 可观测性与落地清单:把网关做成真正的入口

6.1 TraceId传递:网关是链路追踪的起点

微服务链路追踪通常从网关开始。一个请求进来,网关如果没有TraceId就生成一个,然后通过Header传给所有下游服务,这样整个链路上的日志才能串起来。实现很简单,一个GlobalFilter搞定:判断Header里有没有traceId,没有就生成UUID,放进请求头,再塞进响应头返给前端。这里有个容易忽略的细节:WebFlux是响应式模型,日志框架的MDC不能像Servlet里那样直接跨线程传递,因为同一个请求会被多个事件循环线程处理。解决办法是把TraceId放进Reactor的Context,或是在过滤器中手动打印关键日志,不依赖MDC。我用过一种取巧的方式:只在上游转发和下游响应时用响应式上下文取TraceId,不搞全局MDC,日志照样能追踪。

网关还要负责把统一的Header规范传给下游,比如X-Request-Id、X-User-Id、X-Route-Id。如果你发现下游日志没法串起来,先看看是不是网关没有透传Header。很多问题的排查表第一项就是这个。

6.2 Metrics与健康检查:网关不能是黑盒

网关作为核心入口,必须暴露Metrics。Spring Boot Actuator配合Prometheus能让网关吞吐量、延迟分位数、4xx/5xx比例、每个路由的调用量全部可视化。我通常会在网关层额外记录每个route的响应耗时,通过Metrics打点,然后在Grafana里看TopN耗时的路由。没有这些数据,做网关调优就是凭感觉。

健康检查同样重要。网关的/actuator/health要设置为只检查自身和关键依赖(比如注册中心、Redis),不要每次健康检查都去探测所有下游服务,否则下游服务一抖动,负载均衡器会把网关实例也摘掉,造成连锁故障。我经历过一次,因为健康检查里包含了下游数据库探活,一小时里网关被摘了三次,整个服务不可用,排查半天才发现是健康检查配置的问题。

6.3 我整理的生产环境网关配置清单

最后给一份我多次压测、线上验证后沉淀的清单,不一定覆盖所有场景,但至少能避免常见事故:

  • 路由数量控制在可控范围内,每条路由的Filter尽量精简,Filter越多延迟越高,压测时差别很明显。
  • 全局超时、路由超时分开配,下载、长轮询接口单独放宽。
  • 限流必须有,哪怕刚开始配得松,也要有兜底,防止某一个业务方异常流量打垮入口。
  • 鉴权Filter放在最前面,但白名单路径要单独配置,不要把链路追踪的Filter放在鉴权后面,否则未认证请求没有日志。
  • 网关日志要关注转发失败和超时,正常请求日志采样输出,全量打日志会先把网关IO打爆。
  • 网关实例要水平扩容至少2个,前面挂负载均衡,网关本身要做到无状态,路由配置全部动态化。
  • 每次发布前用压测脚本验证关键路径,网关升级比普通服务升级风险高得多,务必灰度。

写完这个清单,我又想到一个更根本的原则:网关不做业务,网关只做路由和横切。你往网关里塞的业务逻辑越多,它就越像一个单体应用,故障半径就越大。过滤器、限流、熔断都是手段,别把它们当成业务容器。这个原则,是我折腾几年网关后最想分享的一句话。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询