做微服务做到一定规模网关就不是一道选做题而是必答题了。我最早接手一个电商类项目时接口调用关系混乱每个服务都要单独写鉴权、限流和日志逻辑代码重复得让人头疼。后来我们花了两周时间把网关层独立出来选了 Spring Cloud Gateway 做统一入口才算是把这块理顺了。这篇博文我按实际落地的顺序写从技术选型、最小路由配置、核心工作原理到鉴权限流和生产排错把我踩过的坑和验证过的方案一次性说清楚适合刚接触微服务网关的读者也合适正在从 Zuul 迁移到 Gateway 的团队参考。1. 选型理由Gateway 的非阻塞模型到底赢在哪里1.1 Zuul 1.x 的阻塞线程模型为什么扛不住高并发先说一个很容易被忽略的背景。Zuul 1.x 是基于 Servlet 构建的采用的是“一个请求占用一个线程”的阻塞模型。请求到达后Tomcat 从线程池里分配一个线程一直占着直到下游服务的响应完整返回线程才被释放。听起来没什么问题但一旦某个下游服务响应慢这个线程就一直挂在那里干等。我用一个生活化的例子解释餐厅里每个服务员只能服务一桌客人客人点完菜后服务员就站在旁边等着厨房出菜期间不能去招呼别的桌。就餐高峰期客人一多服务员全被占住新进来的客人只能排队。Zuul 1.x 面对高并发时就是这个状态线程池一旦耗尽新请求就开始排队阻塞整体吞吐量直线下降。Spring 官方后续停止维护 Zuul 1.x社区把注意力转向了 Spring Cloud Gateway很大一部分原因就在这。不是说 Zuul 不能用而是在 IO 密集的网关场景下阻塞模型把线程资源浪费在了等待上。网关作为所有请求的入口如果它先成为瓶颈下游服务再怎么扩容也白搭。1.2 WebFlux 响应式模型和 Netty 事件循环的直观理解Spring Cloud Gateway 基于 Spring WebFlux底层跑在 Netty 上。Netty 使用的是事件循环模型一个或者少数几个线程就能处理成千上万个并发连接。当请求发过来事件循环线程把 IO 操作注册好然后立刻去处理下一个事件真正读取数据或者写回响应的动作是由底层 IO 多路复用机制完成的。还是用餐厅类比这次服务员不再傻等上菜而是点完单之后让客人先坐着自己继续招呼下一桌。菜做好的时候厨房通过对讲机喊一声有空闲的服务员再去把菜端上去。同样的服务员数量同时能服务的客人数量完全不在一个量级。我画了个简单的对比表方便大家直观理解对比维度Zuul 1.xSpring Cloud Gateway底层模型Servlet 阻塞式WebFlux Netty 非阻塞线程模型一请求一线程事件循环少量线程高并发表现线程池耗尽后明显排队高并发下吞吐量更平稳长耗时依赖占用线程等待不占线程IO 完成回调内存占用线程栈内存开销大相对更轻量当然响应式模型也带来了一个副作用线程模型和传统 Spring MVC 完全不同很多习惯写法要改过来。比如在过滤器里直接调用阻塞式 Redis、阻塞式数据库查询都会拖垮事件循环线程这块后面讲限流和鉴权的时候我会再强调。2. 十分钟搭起第一个可运行的路由环境2.1 版本搭配是入门第一道坑Spring Cloud Gateway 对 Spring Boot 版本要求很严格版本不匹配启动阶段就会报各种莫名其妙的错。我本地用的组合是 JDK 8 Spring Boot 2.7 Spring Cloud 2021.0.9对应的 Gateway 版本是 3.1.x这套组合很稳定也是目前生产环境里最常见的配置。如果你的项目已经升级到 Spring Boot 3那就不能继续沿用 Gateway 3.x需要切换到支持 Spring Boot 3 的版本线依赖坐标要换成新的 starterJDK 也建议 17 起步。版本选型给一个直接可用的建议parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version2021.0.9/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency一个常见翻车点Gateway 项目中如果引入了 spring-boot-starter-web启动会报Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway。因为 Gateway 依赖 WebFlux而 Spring MVC 和 WebFlux 不能共存于同一个应用里。网关工程里别加 web starter也别加依赖于 web starter 的组件这个坑我在 5.1 节里详细说。2.2 最小路由配置逐行拆解把网关跑起来只需一个启动类和一个 yml 配置。路由配置是整个网关的核心先给一个最简单但能直接用的例子同时模拟一个 user-service 运行在 8081 端口server: port: 8080 spring: cloud: gateway: routes: - id: user-service-route uri: http://localhost:8081 predicates: - Path/api/user/** filters: - StripPrefix1这段配置总共只有五行核心内容每一行的作用都不一样id路由的唯一标识任意字符串但建议起一个能看懂的名字比如 user-service-route。日志、监控、问题排查时都要用 id 定位。uri请求转发到哪个地址。这里写的是 http://localhost:8081表示把所有匹配到的请求转发到这个服务。实际生产环境通常写lb://user-service让网关从注册中心拿服务实例做负载均衡这个在 3.3 节专门讲。predicates断言相当于路由的触发条件。Path/api/user/**的意思是当请求路径以 /api/user/ 开头时这条路由生效。filters过滤器对请求做额外处理。StripPrefix1表示转发前把原始路径的第一段前缀剥掉。比如请求进来是/api/user/list转发给下游时变成/user/list。配置完成后启动网关直接访问http://localhost:8080/api/user/list网关就会把请求转发到http://localhost:8081/user/list。我们用 curl 验证一下curl -i http://localhost:8080/api/user/list如果下游 user-service 有/user/list这个接口就返回 200。要是返回 404第一件事先看是不是 StripPrefix 层级配错了这是由 Gateway 后遇到频率最高的初级问题。2.3 路径规划直接影响路由匹配的难易度路由匹配的效果好坏有一半取决于 API 路径规划。我见过不少项目下游服务既有/user/list又有/user/detail网关层配置Path/api/user/**StripPrefix1 之后变成/user/list、/user/detail看着很整齐。但如果下游服务把控制器挂在/api/user上那 StripPrefix 就不该用或者改用 RewritePath 把规则写得更细。路径匹配遵循 Ant 风格的通配符规则三个符号要记一下?匹配一个字符。*匹配零个或多个字符但不跨目录层级。Path/api/*能匹配 /api/user配不上 /api/user/list。**匹配零个或多个目录层级。Path/api/**能匹配 /api/user也能配 /api/user/list。这几个概念说清楚了路由配置就成功了一半。刚开始接触 Gateway 的读者最容易犯的毛病是把*当**用结果请求路径带多级目录时就匹配不上。3. 网关内部到底怎么处理一个请求3.1 路由四件套ID、URI、断言、过滤器缺一不可在 Gateway 的世界里一条完整路由由四部分组成。ID 前面已经提到是路由的唯一标识符。URI 决定转发目标位置。断言决定“什么时候这个路由生效”它是布尔表达式请求只要满足断言条件就走这条路由。过滤器决定“请求在转发前后做什么”可以在转发前修改请求头、加参数也可以在拿到下游响应后改写响应内容。我经常把这套机制想象成快递中转站。快递包裹请求到了中转站先看面单上的条件断言比如哪些地区的件走哪条传送带确认传送带之后包裹可能要贴个新标签请求头过滤也可能要做安检鉴权过滤最后送往对应的出港口URI 对应的下游服务。中转站如果只有传送带没有分拣规则包裹就会到处乱跑只有分拣规则没传送带快递也送不出去。四件套结合路由才能正常工作。3.2 十几个断言工厂常用的是这几个Gateway 内置了十多种断言工厂全部遵循“名字参数”的写法。常用的我和使用场景一起整理出来断言工厂配置写法典型场景PathPath/api/**按路径分流最常用MethodMethodGET,POST限制接口方法HeaderHeaderX-Request-Id, \d校验请求头是否满足正则QueryQuerypage, \d要求带指定参数且匹配规则CookieCookieuserId, \d根据 Cookie 分流HostHost**.example.com按域名分流RemoteAddrRemoteAddr192.168.1.100/24限制来源 IPWeightWeightgroup1, 80灰度发布按权重分流After/Before/BetweenAfter2024-01-01T00:00:0008:00[Asia/Shanghai]限制时间窗时间类断言不少人会忽略它有个实际用法系统升级期间用 Before 断言把所有流量切到维护页升级结束后把配置改掉就行。它的时间格式支持时区配置时注意[Asia/Shanghai]这种写法。之前有人问我为什么他的多个断言都写了却一直不生效。原因多半是配置了多个 Path 断言而 Gateway 对同一个路由的多个断言是“并且”关系。必须所有断言同时满足路由才会生效这和“或”关系是两个概念。3.3 从 uri 到 lb接入注册中心后的负载均衡原理网关如果只转发到固定地址那还是半成品。生产环境的服务实例数量会动态变化今天三个实例明天缩容到两个写死 localhost:8081 显然不行。接入注册中心之后路由配置里写lb://user-servicespring: cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1这个lb://前缀是很关键的信号。Gateway 看到它就会启用 LoadBalancerClientFilter根据服务名去注册中心拉取实例列表再按照负载均衡策略选择一个实例发起转发。我在项目里接的是 Nacos 作为注册中心需要额外引入对应依赖并配置注册中心地址。这里有个非常常见的误区uri 里写了lb://user-service但没引入注册中心依赖或者注册中心没配好启动时不报错请求一到网关就报 503。排查顺序很简单先看注册中心服务列表里有没有这个服务名再看网关本地能不能解析到实例列表最后再检查负载均衡策略。当初我们为了查这个 503日志翻了一个多小时最后发现是 Nacos 连接超时配置有问题服务列表是空的LoadBalancerClientFilter 自然找不到可用实例。4. 入门的尽头是动手三种必会过滤器4.1 全局过滤器实现统一鉴权别再让业务服务做这件事网上很多教程会把鉴权逻辑写在业务服务的拦截器里结果每个服务都维护一套拦截规则加服务就要重新复制一遍改逻辑就要一个服务一个服务地改。网关接入后鉴权最适合放在网关全局过滤器里。Gateway 的过滤器分为两种GatewayFilter 跟着单个路由走GlobalFilter 全局生效所有请求都会经过。自定义全局过滤器只需要实现 GlobalFilter 接口和 Ordered 接口。我提供一个简化版 JWT 鉴权示例Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token exchange.getRequest().getHeaders().getFirst(Authorization); if (token null || !token.startsWith(Bearer )) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } String tokenValue token.substring(7); // 实际项目中这里调用统一鉴权服务解析 JWT boolean valid validateToken(tokenValue); if (!valid) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 把解析出来的用户信息放到请求头传给下游 ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, getUserId(tokenValue)) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } Override public int getOrder() { return -100; } }这里有个设计细节要重点强调getOrder 返回的数值越小过滤器越先执行。我把鉴权过滤器的 order 设置为 -100就是确保它排在所有转发动作之前。如果 order 比较大请求可能已经被其他过滤器修改甚至已经往下游发起了再来做鉴权就没意义了还会造成无效流量。还有一个经验在过滤器链中往请求头添加用户信息可以避免下游服务再解析一次 JWT这是网关层减少重复校验的典型做法。我们当时做完这个改动下游每个服务都删了一堆认证代码体感非常好。4.2 限流与熔断给网关装上刹车和安全带网关是流量的总闸门不加限制热点请求会把下游直接打挂。Gateway 内置了 RequestRateLimiter 过滤器结合 Redis 做分布式限流。核心配置三块依赖、过滤器参数、KeyResolver 定义。引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency路由过滤器配置filters: - StripPrefix1 - name: RequestRateLimiter args: key-resolver: #{userKeyResolver} redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20参数含义解释一下。replenishRate 是每秒补充的令牌数burstCapacity 是令牌桶的容量。用生活化的例子来说replenishRate 是每秒往桶里倒多少枚代币burstCapacity 是桶最多能装多少枚。请求来了就从桶里拿一枚桶空了请求就被拒绝。burstCapacity 设为 20允许瞬时处理 20 个请求之后每秒补 10 枚令牌。定义 KeyResolver决定按什么维度限流Bean public KeyResolver userKeyResolver() { return exchange - Mono.just( exchange.getRequest().getRemoteAddress() null ? unknown : exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() ); }这个 KeyResolver 按客户端 IP 限流每个 IP 每秒最多 10 个请求突发可到 20。如果业务需要按用户维度限流就从请求头里取用户 ID。熔断和限流是两个维度。限流提前把流量挡在外面熔断则是在下游快撑不住时快速失败。Gateway 集成 Resilience4j CircuitBreaker 需要引入独立依赖然后加过滤器配置- name: CircuitBreaker args: name: orderServiceBreaker fallbackUri: forward:/fallbackfallbackUri 是熔断后转发到一个本地降级接口也就是要在网关里也提供一个/fallback控制器返回兜底响应。这个兜底逻辑就是当订单服务出现故障时用户至少能拿到一个明确的“服务繁忙”提示而不是无限等待超时。4.3 跨域问题网关层统一解决 CORS 配置前端项目同时对接多个微服务时最容易遇到跨域问题。如果每个业务服务都各自配 CORS那么每加一个服务就要做一遍相同的配置很容易漏配或者配不一致。网关层统一配置 CORS 是更合理的方式spring: cloud: gateway: globalcors: cors-configurations: [/**]: allowedOrigins: https://example.com allowedMethods: - GET - POST - PUT - DELETE allowedHeaders: * allowCredentials: true maxAge: 3600这里有几个细节容易踩雷。allowCredentials 设置为 true 之后allowedOrigins 不能配置成*必须明确写域名。浏览器要求凭证模式和通配符源不能同时使用。另外如果网关层配置了自定义过滤器修改了 Host 头跨域预检请求OPTIONS可能在过滤器链里被异常处理掉响应里看不到 CORS 头前端就一直报跨域错误。遇到这种情况先检查鉴权过滤器是不是把不带 Authorization 的预检请求直接拦掉了。5. 实战排错我遇到过的四类典型故障5.1 启动即失败Spring MVC 与 WebFlux 冲突加入 Gateway 依赖后启动报错信息很长但关键是这一句Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway。我在一次给老项目接网关时就撞上了说到底就是网关工程里引入了 spring-boot-starter-web。为什么会有这个依赖很多时候不是你直接加的而是引入了其他 starter 间接带进来的比如某些监控组件或者工具包。处理方法很简单dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId /exclusion /exclusions /dependency或者更干脆把不需要的依赖整个移除。排查方法是在 IDE 里打开依赖分析搜 spring-webmvc看看是从哪条链路引进来的。除了 web 冲突还有一类启动失败是端口被占用。网关通常独占一个对外端口如果和别的服务共用机器记得检查端口冲突。5.2 路由全部白屏 404断言与 StripPrefix 的坑这是排在第二位的故障网关起来了配置也没报错但访问任意接口都返回 404。排查时先按下述顺序看第一步看断言是否匹配。请求路径和断言路径差一点都匹配不上。比如请求http://localhost:8080/user/list而断言是Path/api/user/**那肯定匹配不到任何路由Gateway 就直接返回 404。第二步看 StripPrefix 和转发路径的关系。如果下游真正能识别的路径就是/api/user/list而你在过滤器里写了 StripPrefix1转发时就变成了/user/list下游没有这个接口同样 404。要精确修改路径时用 RewritePath 比 StripPrefix 更灵活filters: - RewritePath/api/user/(?segment.*), /$\{segment}这段配置把/api/user/list重写成/list再转发。有个细节yml 里写这段时$符号要写成$\{segment}如果直接写${segment}Spring 会把{segment}当成占位符去取值可能解析成空字符串。这个转义坑我印象很深当时查了很久才发现是 yml 解析规则的问题。5.3 下游响应慢导致网关一直等超时参数调优网关默认的响应超时时间是 30 秒。看起来不短但下游服务如果出现慢 SQL、长时间 GC或依赖的外部接口响应超时30 秒也能把网关线程拖得很累。给网关配一个合理的超时时间很重要spring: cloud: gateway: httpclient: connect-timeout: 5000 response-timeout: 10sconnect-timeout 单位是毫秒response-timeout 单位是秒。connect-timeout: 5000表示 5 秒内建立连接建不上就失败response-timeout: 10s表示等待响应最多 10 秒。如果你在网关里做了重试配置也要留意超时和重试的叠加效果。比如单次超时 10 秒重试 3 次那最坏情况要等 30 秒以上。生产环境我建议网关默认不开启重试或者只在幂等 GET 请求上开启避免把压力翻倍地甩给下游。5.4 故障速查表这部分我把常见的故障整理成一张表方便读者直接对照故障现象可能原因解决办法启动报 Spring MVC 不兼容引入了 spring-boot-starter-web排除 spring-webmvc 或移除依赖启动正常但所有接口 404断言路径没匹配上请求检查 Path 断言是否包含完整前缀请求转发后下游 404StripPrefix 层级配错调整 StripPrefix 或改用 RewritePath转发目标报 503服务名在注册中心找不到检查注册中心连接和实例状态服务间响应慢、网关等待过长没有合理配置超时设置 connect-timeout 和 response-timeout跨域预检请求失败鉴权过滤器拦截了 OPTIONS放行 OPTIONS 或允许无 Token 预检限流不生效KeyResolver 未定义或 Redis 未连接检查 Bean 定义与 Redis 连通性5.5 日志排查经验把 Gateway 的 Debug 日志打开遇到难以定位的转发问题别急着猜先把日志级别调低。在 application.yml 里加一行logging: level: org.springframework.cloud.gateway: DEBUG打开之后能看到每个请求匹配了哪条路由、经过了哪些过滤器、转发目标是什么。这个日志在排障时价值非常高比反复猜配置慢效率得多。我们上线初期一直开着这个日志观察流量稳定后才调回 INFO。也有一个小技巧在自定义过滤器里打关键日志记录请求路径、目标 uri、过滤器链处理耗时。生产环境排查性能问题时这些日志往往能直接定位到瓶颈是鉴权还是转发还是下游慢。我自己做了几年微服务关于 Gateway 最深的体会是如果不理解“请求是怎么在过滤器链里流转的”出了问题就会非常被动。配置文件和代码永远只是表象真正值得花时间去抠的是谁在什么时候动了请求、改了头、转了方向。入门阶段建议拿一个简单服务手动搭一遍最小路由再试着写一个全局过滤器然后逐步折腾限流、熔断和跨域——这个过程走下来对网关的理解会比单纯看文档快得多。