Spring Cloud Gateway核心原理与生产实践:路由、谓词、过滤器全解析
网关这东西我在微服务拆分后踩了不少坑才摸清。服务从两三个涨到十几个的时候调用关系开始变得一团乱麻前端不知道该打哪个地址每个服务都在重复做鉴权和限流日志里找到一个请求要翻四五个服务。后来把 Spring Cloud Gateway 引入进来这些问题才逐渐理顺。这篇文章不是简单的组件讲解我会把 Gateway 的三板斧——路由、谓词、过滤器——拆开揉碎用实际配置和踩坑经历来说明白同时把线上才会遇到的那些问题超时、跨域、集群部署也一并交代清楚。适合正在入门 Spring Cloud Gateway、或者已经在用但想系统搞懂核心原理的同学。1. 为什么网关成了微服务架构里绕不开的一层不少刚接触微服务的同学都有个疑问服务间直接相互调用不就行了吗为什么要额外架一层网关多一次网络跳转不更慢吗这个问题的答案得从你服务数量变多之后的管理成本说起。1.1 没有网关时的混乱局面假设你现在有订单、用户、库存、支付、物流五个服务。没有网关时前端要调订单接口就得知道订单服务的 IP 和端口要调用户接口又得记另一套地址。如果某个服务做了多实例部署前端还得自己实现负载均衡。更麻烦的是每个服务的鉴权逻辑都得自己写——A 服务验证一遍 TokenB 服务再验证一遍代码大量重复改一处鉴权逻辑要发布五六个服务。这还不算最头疼的。你想在入口处加一个全局限流或者打印统一的访问日志没有网关就只能改每个服务工作量大且容易漏。我见过一个团队因为漏配安全校验某个内部接口直接裸奔在公网上被扫描工具扫到之后才慌慌张张补上线。这个问题的根源不是开发不细心而是架构层面缺少一个统一收口的地方。1.2 Gateway 与 Zuul 的选型差异你可能听说过两个名字Spring Cloud Gateway 和 Zuul。现在新项目基本不再推荐 Zuul核心原因在于底层模型的差异。Zuul 1.x 基于 Servlet 的阻塞 IO 模型一个请求一个线程线程池满了之后请求就排队等待而 Gateway 基于 Spring WebFlux 和 Netty走的是非阻塞响应式编程少量线程就能支撑大量并发连接。打个比方Zuul 的模型像银行柜台一个窗口同时只能服务一个客户人多了就排队Gateway 的模型像餐厅的点餐系统一个服务员可以同时接好几桌的单记录完了就交给后厨慢慢做不需要人一直等着。在高并发场景下Netty 的吞吐量优势非常明显而且 Gateway 的配置更灵活支持动态路由和更细粒度的过滤器链。我实测过同样的并发压力下Gateway 的线程占用比 Zuul 低很多响应时间也更稳定。1.3 网关承载的核心职责清单把 Gateway 放进架构后我通常让它承担这几件事统一路由转发所有外部请求先到网关由网关根据路径、请求头等条件分发到具体服务统一鉴权认证在过滤器里做 Token 校验不合法的请求直接拦截业务服务不再关心这个请求是谁的问题跨域处理前后端分离场景下统一配置 CORS避免每个服务各配一套限流熔断配合 RequestRateLimiter 做接口限流配合 CircuitBreaker 过滤器和下游服务的熔断日志与监控在过滤器里统一记录请求耗时、响应状态配合链路追踪工具串联整个调用链这些职责集中到网关之后业务服务的代码可以瘦身不少。开发同学只需要关注自己的业务逻辑通用横切逻辑全在网关层完成。这也是微服务架构里前端请求唯一入口的意义所在。2. 三个核心组件如何协作Route、Predicate、Filter 的运转机制Spring Cloud Gateway 最核心的概念就是三件套Route 路由、Predicate 谓词、Filter 过滤器。很多教程把它们分开讲但如果你不理解三者如何配合配置起来还是会一头雾水。我用一句话先概括Route 定义请求从哪来到哪去Predicate 定义什么条件的请求匹配这条路由Filter 定义请求在路由前后要做哪些额外处理。2.1 一次请求在网关里的完整旅程一个 HTTP 请求进来后Gateway 的处理过程大致是这样请求先被接入层的 Netty 接收经过 WebFlux 的 DispatcherHandler 分发网关拿到请求后把 RoutePredicateHandlerMapping 拿出来逐个匹配路由的 Predicate——只要有一个谓词条件不满足就换下一条路由继续试一旦某条路由的所有谓词全部命中网关就确定要使用该路由并把这条路由关联的所有 GatewayFilter 和全局 GlobalFilter 组成一个过滤器链过滤器链按顺序处理请求先经过 pre 阶段的过滤器比如添加请求头、鉴权校验然后通过 Netty 的 HttpClient 把请求转发到下游服务下游服务返回响应后再经过 post 阶段的过滤器比如添加响应头、记录日志最终回到调用方这四个步骤里路由匹配是关键分水岭匹配成功之前只走谓词判断匹配成功之后才进入过滤器链。理解了这个顺序你就知道为什么谓词和过滤器职责完全不同也理解了为什么一个请求命中的是某条路由而不是所有路由。2.2 Route 路由的数据结构路由的具体配置在代码里对应 RouteDefinition 类核心字段有这几个字段作用示例id路由唯一标识order-serviceuri转发目标地址http://localhost:9001 或 lb://order-servicepredicates匹配条件数组Path/api/order/**filters路由级过滤器数组StripPrefix2, AddRequestHeaderX-Request-Id, 123metadata附加元数据可用于携带灰度版本号等信息order路由优先级数字越小越先匹配0这里需要注意 uri 的两种写法。直接写 http 地址适合固定目标的场景比如对接外部系统如果写lb://order-service就表示走服务发现网关会从注册中心拿到服务实例列表做负载均衡这也是微服务架构下最常用的方式。order 字段一般用不到但当你配置了多条 Path 前缀相近的路由时它决定先匹配谁。2.3 Predicate 谓词的匹配逻辑谓词是 Router 的准入条件官网术语叫 Predicate 工厂。内置的谓词都是xxx()和xxxvalue这种写法配置在 predicates 数组里所有谓词是AND 关系——必须全部满足路由才匹配。常用内置谓词如下Path按路径匹配支持Path/api/order/** **表示多级路径Method按 HTTP 方法匹配如MethodGET,POSTHeader按请求头匹配如HeaderX-Request-Id, \d支持正则Query按查询参数匹配如Querypage表示必须包含 page 参数Cookie按 Cookie 匹配如Cookiename, valueHost按 Host 头匹配支持通配符RemoteAddr按客户端 IP 匹配Weight按权重分流常用于灰度发布同一组路由分别配不同权重After / Before / Between按时间窗口匹配我第一次用 Weight 时犯了个错在两个路由里都配了Path/api/test/**但权重不同结果请求全部到了第一个路由。后来看了源码才明白Weight 谓词必须给同一组路由设置相同的 group 名并且服务端要开启对应支持否则分组不生效。源码里就是按 group 来归集路由再根据权重做随机分流。2.4 Filter 过滤器的两类角色过滤器分两种一种是路由级别的 GatewayFilter配置在路由的 filters 数组里只作用于这条路由另一种是全局过滤器 GlobalFilter对所有路由生效通常用 Java 代码编写。内置 GatewayFilter 我常用这几个StripPrefix转发前去掉路径的前缀段。比如StripPrefix2请求/api/order/list转发时就变成/listRewritePath通过正则改写路径常用于敏感路径隐藏比如把/api/user/**重写为/user/**AddRequestHeader / AddResponseHeader往请求或响应里加头RequestRateLimiter限流配合 Redis 使用Retry转发失败时重试配置最大重试次数和状态码CircuitBreaker集成 Resilience4j 做熔断降级GlobalFilter 则是自由度最大的扩展点鉴权、日志、灰度标记、全局限流这些横切逻辑全部在这里实现。两种过滤器会在网关启动时合并成一个过滤器链GlobalFilter 实现 Ordered 接口控制执行顺序。3. 路由配置实战从最小 yml 到服务发现动态路由前面把原理过了一遍现在上手实操。这一节我会从最小配置开始一步步把它扩展成带服务发现、动态路由的完整配置同时解释每一步为什么这样写。3.1 最小可运行的 yml 配置我先给出一个最精简的网关配置端口固定在 8080所有/api/order/**的请求统一转发到本地的 9001 端口服务。server: port: 8080 spring: application: name: gateway-service cloud: gateway: routes: - id: order-service uri: http://localhost:9001 predicates: - Path/api/order/** filters: - StripPrefix2这里有两个动作第一所有/api/order/**的请求匹配到order-service这条路由第二转发前用 StripPrefix 去掉前两段路径所以/api/order/list到了下游服务变成/list。下游服务只需要提供/list接口不需要关心外部接口长什么样这个设计很常用——对外暴露的路径与对内服务路径解耦。验证方式很简单启动网关和下游服务后直接访问http://localhost:8080/api/order/list看响应是不是来自下游服务。如果出现 404先检查下游服务是否确实存在/list这个 mapping再检查 StripPrefix 是否把路径多去或者少去了一段。3.2 谓词组合匹配的配置示例单条 Path 谓词只够处理最简单的场景。实际项目里我经常要按请求方法和请求头做更精细的区分。例如要求只允许 POST 请求、必须携带指定请求头、并且来源路径是/api/pay/**spring: cloud: gateway: routes: - id: pay-service uri: lb://pay-service predicates: - Path/api/pay/** - MethodPOST - HeaderX-Pay-Version, v1 filters: - StripPrefix2 - AddRequestHeaderX-Gateway-Source, gateway注意谓词之间的隐式关系是 AND路径对了但方法不是 POST这条路由就不会命中或者请求头里没有X-Pay-Version: v1也不会命中。这是不少新手踩坑的地方——以为谓词之间是 OR 关系配了好几个条件结果请求一直 404。3.3 集成注册中心实现 lb 动态路由服务实例地址经常变化扩容、发布、宕机不可能写死。把网关接入 Nacos 或 Eureka 后uri 换成lb://前缀即可。spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: enabled: true lower-case-service-id: true routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/**discovery.locator.enabledtrue表示开启自动路由注册中心里每一个服务都会自动生成一条路由访问路径是服务名小写。不过自动路由的路径规则比较死板默认/{serviceId}/**所以我更习惯还是手写路由规则把 uri 配成lb://order-service这样既保留了对路径的完全控制又能利用负载均衡能力。负载均衡本质是 Gateway 内部的 LoadBalancerClient 从注册中心拉取实例列表再通过轮询等策略选出一个目标地址。3.4 用 Java DSL 配置路由的适用场景有些项目喜欢用代码配置路由不写 yml。这种方式的优点是灵活可以根据条件动态生成路由缺点是配置不直观改个路径还要重新编译发布。我更推荐 yml 为主、代码为辅常规路由写 yml需要动态增删路由的场景比如后台管理系统里在线新建路由再用代码。代码初始化一个路由的写法如下Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route(order-service, r - r .path(/api/order/**) .filters(f - f.stripPrefix(2)) .uri(lb://order-service)) .build(); }这段代码和前面 yml 的效果完全一样核心是校验规则的表达方式不同而已。你要是刚上手建议先别在代码里折腾把时间花在理解谓词和过滤器上更值。4. 用 GlobalFilter 写一个生产级鉴权过滤器路由配置解决的是请求往哪走的问题接下来看哪些请求能进来。鉴权是网关里最典型的 GlobalFilter 场景我直接分享一下实践中沉淀出来的写法包含核心代码、执行顺序控制、以及白名单的处理思路。4.1 为什么鉴权必须放在网关层你可能会想下游服务自己校验 Token 不就行了可以但网关层做鉴权有一个巨大的优势——前端只需要和后端约定一套 Token 方案所有服务的鉴权都在收口处完成。业务服务收到的请求已经是验证过的用户安全逻辑简化非常多。就算你只对一个服务做鉴权把这段代码挪到网关也比挪到各个业务服务里省事。4.2 核心代码校验 Token 并透传用户信息下面是一个精简的但生产可用的鉴权过滤器Component public class AuthFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); // 1. 白名单直接放行 if (path.startsWith(/api/auth/login) || path.startsWith(/api/auth/register) || path.startsWith(/actuator)) { return chain.filter(exchange); } // 2. 从 Header 取出 Token你也可以换成 Cookie、Query 参数 String token request.getHeaders().getFirst(Authorization); if (!StringUtils.hasText(token) || !token.startsWith(Bearer )) { return unauthorized(exchange); } token token.substring(7); // 3. 校验 Token这里用 Redis 查用户信息避免每个服务解析 JWT // 真实项目往往是调用一个 auth-service 的远程接口注意加缓存 UserInfo userInfo userSessionService.getUserInfo(token); if (userInfo null) { return unauthorized(exchange); } // 4. 把用户信息放到请求头里透传给下游下游就不用再查一次 ServerHttpRequest mutatedRequest request.mutate() .header(X-User-Id, String.valueOf(userInfo.getUserId())) .header(X-User-Name, userInfo.getUserName()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } private MonoVoid unauthorized(ServerWebExchange exchange) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } Override public int getOrder() { return -100; } }这段代码有几个细节值得展开。先说 getOrder 返回 -100 的含义GlobalFilter 的执行顺序是按升序排的数字越小优先级越高负值是为了让鉴权过滤器排在所有内置过滤器前面——如果内置过滤器先把请求体读走了你再做任何修改都晚了。然后看请求头透传下游服务从X-User-Id直接拿用户标识省去重复解析 JWT 的过程这是我在实践里非常推荐的优化能显著减少下游的重复计算。白名单的写法我给的是path.startsWith实际项目建议放在配置中心里这样改白名单不用重新发版。千万别把白名单写死在代码里别问我为什么知道有一次上线临时加了登录页回调地址忘了同步配置生产上直接一片 401排查了快二十分钟才发现是白名单缺失。4.3 修改请求体的场景与易错点如果你需要在过滤器里校验或者修改请求体比如验签后改写 JSON 字段事情就麻烦很多。核心难点是ServerWebExchange 的请求体默认只能被读取一次第一次读过之后后续过滤器再读就是空的了。正确做法是缓存请求体的字节然后用装饰器包装请求让下游永远能读到完整数据。public class CacheBodyFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return DataBufferUtils.join(exchange.getRequest().getBody()) .map(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); return bytes; }) .flatMap(bytes - { // 这里 bytes 就是要做校验/修改的完整请求体 // 修改后重新构造 wrappedRequest再传给 chain.filter ServerHttpRequest wrappedRequest new ServerHttpRequestDecorator(exchange.getRequest()) { Override public FluxDataBuffer getBody() { return Flux.just(exchange.getResponse().bufferFactory().wrap(bytes)); } }; return chain.filter(exchange.mutate().request(wrappedRequest).build()); }); } Override public int getOrder() { return -200; } }这个方案有一个性能代价请求体会被完整缓冲到内存里如果接口的 body 很大、并发又高内存压力会直线上升。所以实际项目里要评估是不是每个接口都有必要读 body可以用 Path 谓词只对特定接口做 body 缓存其他接口直接放行。我自己的习惯是只对需要验签的几个关键接口开启 Body 缓存其余接口一概不碰 body避免内存浪费。5. 生产环境踩坑记录跨域、超时、并发与网关集群配置全跑通之后不代表万事大吉线上环境的坑往往藏在细节里。这一节我把这些年实际遇到的问题和解决思路整理出来每条都是真实教训。5.1 跨域配置重复响应头的经典问题前后端分离项目里 CORS 是必配项。Gateway 里配置跨域有两种方式yml 的spring.cloud.gateway.globalcors或用 WebFilter 的 CorsWebFilter。坑在什么地方如果你在网关层配了 CORS又在某个下游服务里配了一次 CORS浏览器会收到两个Access-Control-Allow-Origin响应头——浏览器直接报错服务看起来是通的前端就是调不通。我当时排查这个问题的思路是先用 curl 直接请求后端服务看响应头里有几个 CORS 头再请求网关对比两次结果。发现下游服务也加了 CORS 配置后把下游的 CORS 全部移除只在网关统一配置问题立刻消失。这也是网关统一收口的意义之一——跨域这种横切逻辑只能在一层做重复做就会冲突。yml 配置示例spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowedOrigins: https://example.com allowedMethods: - GET - POST - PUT - DELETE allowedHeaders: * allowCredentials: true5.2 转发时请求头丢失与 Host 设置网关转发时默认会保留大部分请求头但有两个头容易被忽略Host和X-Forwarded-*。有的下游服务用 Host 头做虚拟主机路由或者生成跳转链接结果经过网关后 Host 变成网关的地址导致下游生成的链接不对。解决办法是手动改写 Hostspring: cloud: gateway: routes: - id: backend uri: http://backend-service:8081 predicates: - Path/api/** filters: - AddRequestHeaderHost, backend-service:8081如果你是lb://方式转发还可以配置spring.cloud.gateway.httpclient.headers来控制要透传的头。另外需要注意X-Forwarded-Prefix如果 StripPrefix 把路径前缀去掉了下游如果还想知道原始完整路径就要读 X-Forwarded-Prefix 或 X-Original-URL。建议在生产里打开转发原始信息的开关排查问题的时候少走很多弯路。5.3 503、超时与 Gateway 内置 HttpClient 的关系网关转发用的是 Netty 的 HttpClient默认连接池大小、超时时间都比较保守。高并发下很容易遇到两个现象间歇性 503或者某个慢接口响应时间超过默认的 30 秒直接报错。我遇到过不止一次下游服务其实没挂但网关连接池被打满新的转发请求拿不到连接直接返回 503。这类问题的排查思路分两步第一步看下游服务日志确认是否真的收到过请求如果下游日志完全没有请求说明请求卡在网关连接层。第二步看网关监控指标特别是连接池的 pending 数和 active 数。优化配置如下spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 10s pool: max-connections: 500 max-pending-connection: 200 max-idle-time: 10s再配合下游服务的实际吞吐量调节数值。连接池不是越大越好——如果下游每秒只能处理 200 个请求你把网关连接池调到 2000只会把下游打崩。建议先压测定下游的承载上限再反推连接池的合理值。响应超时也要看业务实际情况长任务接口的响应时间超过 10 秒很常见硬调超时会导致用户明明在正常使用却被网关断掉。5.4 网关集群部署与无状态化改造网关作为流量入口不能单点部署。网关本身是无状态的只要你把路由配置放在配置中心、鉴权信息放在 Redis 或数据库就可以水平扩展多个实例。部署结构通常是Nginx 或云负载均衡器在最前面后面挂两到四个网关实例网关实例再注册到 Nacos。集群部署之后有一个细节很容易被忽略每个网关实例的 Spring Boot 端口、实例 ID 最好不要相同这样 Nacos 的健康检查才能准确感知实例状态。我踩过一次坑两台网关实例都是 8080 端口注册到 Nacos 时实例 ID 冲突导致负载均衡策略异常部分请求一直打到同一个实例上。解决办法是给每个实例设置不同的 server.port或者基于 IP 动态生成实例 ID。限流在集群模式下还要注意一个问题如果用本地内存做限流计数器每个实例的计数是独立的整体限流阈值会被放大 N 倍。要么使用 RequestRateLimiter 默认的 Redis 模式要么自己实现分布式限流否则N 台网关N 倍限流上限这个隐患会一直存在。5.5 网关参数调优心得Netty 相关的几个参数我认为值得多花点时间调一是spring.cloud.gateway.httpclient.connect-timeout决定建立连接的超时时间默认值偏大建议调到 3 秒以内连接都建立不起来说明网络或者下游有问题没必要等太久二是 Tomcat 容器参数虽然 Gateway 是 WebFlux 应用但内置的 Netty 服务端也有线程和连接参数大多数情况下默认值就够用自行调大反而可能增加内存压力。JVM 参数上Gateway 这种 IO 密集型应用建议把堆内存设成固定值比如-Xms512m -Xmx512m避免动态扩容时出现抖动。还有一个容易被忽略的地方是日志Gateway 的请求日志默认没有访问日志排查线上问题很痛苦建议通过 NettyAccessLogFeature 或者过滤器记录 GET/POST 的方法、路径、耗时、状态码每天按天滚动保存关键时刻能救命。一些收尾的实用建议最后分享几个我自己的使用习惯希望能让你少走弯路。第一个建议是先画清楚请求流转图再写配置很多路由匹配问题本质上是对流程理解不清造成的比如 StripPrefix 要减几段、谓词之间是不是 AND 关系画图之后一目了然。第二个建议是网关的过滤器尽量保持薄不要在过滤器里写重业务逻辑遇到需要调用外部服务的场景务必加上超时和缓存否则网关本身会成为新的瓶颈。第三个建议是监控必须在一开始就做好网关是流量的咽喉这个位置的监控比任何业务服务都重要请求量、错误率、响应时间、连接池水位这四项至少要有可视化的面板。把这些基础打磨好Spring Cloud Gateway 才能真正成为你架构里的稳定入口。