ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Cloud Gateway实战:路由转发、统一认证与限流熔断

Spring Cloud Gateway实战:路由转发、统一认证与限流熔断 做微服务拆分的项目多了之后你会发现一个特别尴尬的事服务拆得越细前端调接口越乱。十几个服务各自维护一套地址鉴权逻辑每个服务都要写一遍遇到突发流量还只能干瞪眼。这些问题在单体时代根本不存在一旦拆成微服务就全冒出来了。而答案基本都落到同一个组件上——API网关。这篇实战笔记以Spring Cloud Gateway为核心把路由转发、统一认证、限流熔断这三件事完整地落地一遍从依赖选型到生产配置都给出可以直接改改就用的方案。适合正在做微服务改造、或者想给现有微服务集群补一层正经入口的同学参考也适合刚入手Spring Cloud Gateway、只知道概念但没写过完整链路的新手。1. 为什么这个项目值得做网关在微服务里的位置1.1 微服务拆完之后的三个现实问题服务拆分之后第一个直观的感受就是“调用关系失控”。以前单体应用只有一个地址前端、第三方对接都指向同一个服务拆成十几个微服务后每个服务都有独立端口前端如果要直连就得维护一份越来越长的服务地址清单A服务调B服务也不行B服务调C服务也不行互相之间的地址散落在各个配置文件里稍微换个环境就全乱套。第二个问题是鉴权逻辑重复到爆炸。用户登录之后生成的token用户服务要校验订单服务要校验商品服务也要校验每个服务都贴一段解析JWT的代码不说一旦token生成规则调整所有服务都要跟着改一遍。这种重复代码在架构上属于典型的坏味道。我见过一个项目登录态的校验逻辑在六个服务里各有一份三个已经过期没人维护还有一个因为依赖版本不同连解析方式都对不上。第三个问题是流量完全没有“入口”的概念。接口被刷了不知道在哪一层限流下游服务挂了上游请求还在傻等超时线上出了安全问题想临时封掉某个接口还得跑到对应服务器上改配置重启服务。这些问题汇总起来就是微服务在入口层面缺了一个统一治理的组件。而这个组件就是API网关。1.2 技术选型我为什么锁定了Spring Cloud Gateway很多团队在网关选型上纠结过Nginx、Kong、Shenyu、Spring Cloud Gateway到底选哪个我个人的判断标准很简单团队的技术栈是什么就优先选什么。如果团队主力是Java那Spring Cloud Gateway是学习成本和维护成本最低的选择因为里面写的Filter、路由配置都是Java开发日常熟悉的东西出了问题能直接断点调试而不是去啃Lua脚本或者OpenResty那一套。Nginx当然也能做转发和限流但它本质上是一个高性能的Web服务器做动态路由、细粒度鉴权、与注册中心联动这些能力都比较弱。Kong这类独立网关功能更完整但对Java团队来说自定义插件的开发语言和生态跟现有团队技能栈对不上后期想加功能往往要专门找人。Spring Cloud Gateway天然跟Spring Boot、Nacos、Sentinel这些组件联动而且它基于WebFlux和Reactor模型底层是Netty性能上并不吃亏。这里有个关键认知必须先说清楚Spring Cloud Gateway是基于WebFlux的反应式编程模型它不能跟Spring MVC共存在同一个应用里也就是说网关模块里千万不要引入 spring-boot-starter-web 依赖。很多人第一次启动就直接报“Spring MVC found on classpath”十有八九就是这里出了问题。看清这个前提后面的路就好走多了。2. 项目搭建与基础配置2.1 依赖引入与版本号匹配Spring Cloud Gateway这玩意最折磨人的不是编码而是版本兼容性。它的版本跟着Spring Cloud走Spring Boot版本不对启动的时候会出现各种莫名其妙的报错比如某个配置类找不到、某个方法签名对不上。我自己在稳定生产环境一直用下面这组组合跑了很多个项目都没出过兼容问题Spring Boot 2.7.18Spring Cloud 2021.0.8Spring Cloud Alibaba 2021.0.5.0如果是纯新的企业项目也可以用Spring Boot 3.x加Spring Cloud 2022.x以上的组合但要注意javax命名空间全部换成了jakarta老代码迁移起来一堆编译错误除非有强需求否则没必要追新。先看pom配置parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties spring-cloud.version2021.0.8/spring-cloud.version spring-cloud-alibaba.version2021.0.5.0/spring-cloud-alibaba.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version${spring-cloud-alibaba.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-loadbalancer/artifactId /dependency /dependencies注意这里我没引入 spring-boot-starter-web网关应用使用的是WebFlux环境。如果项目里有其他公共模块间接带了web依赖要用exclusion把它排除掉这是网关启动前必须解决的第一个坑。2.2 网关服务启动验证依赖配好之后先来一版最简的配置把网关跑起来确认环境没问题再逐步加功能。application.yml里只需要指定端口和应用名就行server: port: 8000 spring: application: name: api-gateway启动之后看日志如果出现类似Netty started on port(s): 8000的提示说明网关模块已经作为独立服务正常运行了。这里有个小细节可以确认你用的确实是WebFlux正常Spring MVC项目启动日志里会出现Tomcat初始化而网关里看到的是Netty这是判断环境是否正确的一个最直观的信号。我自己习惯在基础配置阶段就把日志级别打开尤其是后面排查路由问题时会非常方便logging: level: org.springframework.cloud.gateway: debug org.springframework.http.server.reactive: debug开启之后网关每次收到请求都会打印出路由匹配的过程哪个Predicate匹配了、走的是哪个Route、最终转发到了哪个URI一目了然。这个日志在排障时的价值比你在代码里打一百个断点都高。3. 路由转发把请求送到正确的服务3.1 Path路由与StripPrefix的配合网关最核心的职责就是路由转发。说白了就是一张映射表请求满足某个条件网关就把请求送到对应的下游服务。Spring Cloud Gateway把这类条件叫做Predicate谓词最常用的就是Path谓词。我以一个用户服务为例所有以 /api/user 开头的请求都要转发到用户服务。配置是这样的spring: cloud: gateway: routes: - id: user-service uri: http://127.0.0.1:8081 predicates: - Path/api/user/** filters: - StripPrefix1id路由的唯一标识随便起名但最好跟下游服务对应uri转发目标地址predicates路由匹配条件Path/api/user/** 表示匹配以 /api/user/ 开头的请求filters过滤器链这里用了 StripPrefix1StripPrefix1是网关配置里最容易理解错的一个东西。它的意思是转发之前把请求路径的前1段去掉。这里的“段”是按斜杠分隔的。比如前端请求 /api/user/list加了这个过滤器之后网关实际转发给下游的路径是 /user/list前面的 /api 被剥掉了。那为什么需要这个操作因为网关这一层通常要统一给所有服务加个业务前缀比如 /api便于nginx转发规则和网关路由规则统一。但下游服务自己定义的Controller路径一般是不带 /api 的如果网关原样转发过去下游就会报404因为人家根本没有 /api/user/list 这个映射。所以StripPrefix就是用来抹平网关前缀和下游路径之间差异的。反过来说如果下游服务的Controller本身也带 /api 前缀那就不需要配这个过滤器。3.2 通过Nacos做服务发现与负载均衡真实生产环境不会用 http://127.0.0.1:8081 这种写死的地址服务会有多实例会扩容缩容地址会变。所以网关必须跟注册中心联动才能动态拿到下游服务的实例列表。我这边用的是Nacos配置改成这样spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: discovery: locator: enabled: false routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1改动核心在uri这一行从 http://127.0.0.1:8081 变成了 lb://user-service。lb是loadbalancer的缩写意思是让网关从注册中心拿 user-service 这个服务的所有实例然后通过负载均衡策略选一个来转发。Spring Boot 2.4以后Spring Cloud网关默认的负载均衡器是Spring Cloud LoadBalancer需要额外引入spring-cloud-starter-loadbalancer依赖前面pom里已经加了。如果不加启动不会报错但一请求就会提示找不到负载均衡器这个坑我踩过后来排查了半天才想起来是少引依赖。还有一行配置注意一下discovery.locator.enabled我设置成了false。这个开关如果打开网关会自动扫描注册中心里所有服务并生成一个以服务名为路径前缀的路由比如 /user-service/** 直接转发到 user-service。听起来很方便但实际上会让网关联动出很多不受控的入口尤其是服务多了以后很容易有人绕过你精心配置的Prefix直接通过服务名访问鉴权和限流规则全部失效。所以我建议默认关掉所有路由都显式配置出来。3.3 路由配置里的几个暗坑路由转发看起来简单实际用起来坑不少。我第一次做网关时就因为StripPrefix配错了排查了将近一小时。后来总结下来路由配置里最容易踩的坑有这么几个第一个坑是StripPrefix加多或加少。前端请求 /api/user/list下游服务Controller定义是 /user/list那就需要StripPrefix1。如果下游Controller定义是 /list那就需要StripPrefix2。这个没有标准答案完全看你自己的路径规划。判断方法也简单在网关debug日志里看最终转发过去的那行URL不对就调。第二个坑是多个路由的匹配顺序。Spring Cloud Gateway匹配路由是按配置顺序从上往下找的一旦某个路由的Path匹配成功后续路由就不会再看了。如果你的 /api/user/** 和 /api/** 同时存在就必须把更具体的路由放在前面否则所有 /api/user 请求都会被 /api/** 截胡转发到错误的下游。第三个坑是转发后的响应头丢失。跨域场景下如果下游服务设置了CORS响应头网关在转发时可能会被吞掉。解决办法是在网关层统一配置CORS或者复制下游的CORS头到响应里。这个后面做联调的时候很容易被忽略前端报跨域后端说我没问题最后都查到网关头上。4. 统一认证网关层的JWT校验4.1 认证设计思路与过滤器流程网关统一认证的核心思路说白了就一句话把原本散落在每个微服务里的登录校验逻辑收拢到网关这一层由网关统一负责token的校验和用户身份的识别。这样做的好处非常明显。新增一个微服务时不需要再写任何鉴权代码只要接入Nacos并在网关配好路由就自动拥有了登录校验能力。而且后续如果要从JWT换成OAuth2或者引入其他认证协议只需要改网关这地方所有下游服务完全无感知。实现方式是基于Gateway的全局过滤器GlobalFilter。我设计的一套标准流程是这样的客户端带token请求网关网关全局过滤器先判断当前路径是否在白名单里登录、注册、验证码这类开放接口在白名单就直接放行不在白名单就检查Authorization请求头解析token解析成功从token里取出用户信息塞进请求头转发给下游服务解析失败或token缺失直接返回401请求到此为止这套流程的关键在于下游服务不再自己解析token而是直接读网关塞进去的 X-User-Id、X-Username 这些请求头。不但省去了重复代码还保证了全链路用户身份信息的一致。4.2 白名单与全局过滤器实现直接上一段可运行的全局过滤器代码这是整个网关认证模块的核心Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final ListString WHITE_LIST Arrays.asList( /api/user/login, /api/user/register, /api/captcha ); Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } String token request.getHeaders().getFirst(Authorization); if (token null || token.isEmpty()) { return unauthorized(exchange, 未登录); } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); ServerHttpRequest mutatedRequest request.mutate() .header(X-User-Id, String.valueOf(claims.get(userId))) .header(X-Username, String.valueOf(claims.get(username))) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { return unauthorized(exchange, token无效或已过期); } } Override public int getOrder() { return -100; } private MonoVoid unauthorized(ServerWebExchange exchange, String msg) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); DataBuffer buffer exchange.getResponse().bufferFactory() .wrap(({\code\:401,\msg\:\ msg \}).getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } }这里有几个点必须解释清楚。第一实现GlobalFilter和Ordered接口是Gateway全局过滤器的标准写法getOrder返回的数字越小过滤器执行顺序越靠前。我设的是-100目的是保证这个认证过滤器优先于其他所有路由过滤器执行。第二token解析部分我用了一个 JwtUtil 工具类网上有很多开源版本。要注意的是jjwt这个库0.9.x和0.11.x版本的API完全不一样0.9.x用setSigningKey0.11.x用parserBuilder和SigningKey先确定你项目里引的是哪个版本再写代码。第三这里有个实践心得白名单精确匹配有时候不够灵活。比如路径末尾带不带斜杠/api/user/login 和 /api/user/login/ 就是两个不同的path如果客户端习惯不一样就容易被误拦。我自己更倾向于维护一组白名单前缀用 startsWith 判断或者先把路径做一次统一格式化把末尾斜杠去掉再匹配。4.3 token怎么传给下游服务token在网关层解析之后要不要继续往下游传这个问题我们在设计评审时讨论了好几轮。最终我推荐的做法是网关解析token后不把原token透传给下游而是把解析出的关键用户信息塞到请求头里让下游直接消费。之所以不推荐把token原样传给下游有两点原因。第一token一旦落到下游服务的日志里等于把用户的登录凭证永久留存在不该存在的地方安全风险放大第二如果每个下游服务都能拿到原始token难保某天有开发为了省事直接自己解析结果又出现鉴权逻辑分散的问题架构设计就白做了。采用方案A时下游拿用户的写法就变得非常统一String userId request.getHeader(X-User-Id);所有服务都用同一套代码拿当前用户出问题也好查。这里还要注意网关下传的 X-User-Id、X-Username 这些请求头理论上下游是信任的如果有人绕过网关直接请求下游服务就可以伪造用户身份。所以生产环境一定要把下游服务放在内网只允许通过网关访问不能直接把业务端口暴露到公网。这是网关统一认证方案能成立的前提。5. 限流熔断防止服务被冲垮5.1 RequestRateLimiter限流原理与配置限流是网关另一个必须承担的职责。Spring Cloud Gateway自带一个RequestRateLimiter过滤器底层基于Redis和令牌桶算法。令牌桶的原理很好理解桶里装着令牌请求来了先拿令牌有令牌就放行没令牌就拒绝而令牌会以固定速率持续补充。为什么要用令牌桶而不是简单的计数器因为令牌桶能容忍一定程度的突发流量。只要桶里还有积攒的令牌短时间内打过来比平均值多几倍的请求也能被放行而不是一刀切限死。这种特性对业务很重要。比如某个活动瞬间涌入大量用户只要总量还在桶的容量范围内服务就能扛住但如果持续超量令牌消耗完了后续请求就会被限住。配置限流前必须先把Reactive版本的Redis依赖引进来dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency然后给某个路由挂上RequestRateLimiter过滤器filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: #{ipKeyResolver}三个参数含义分别是replenishRate每秒补充的令牌数也就是平均每秒允许通过的请求数burstCapacity令牌桶容量表示允许瞬时打进来的最大请求数key-resolver一个Spring Bean的名称决定按什么维度来区分限流对象我习惯用一个简单例子给新同事解释这三个参数的关系假设桶容量是20每秒补充10个令牌。请求刚打过来时桶是满的所以前20个请求可以直接通过随后每秒钟只能有10个请求能拿到新令牌其余的全部返回429。如果你的下游服务最多只能扛每秒10个请求那burstCapacity设20、replenishRate设10就是一个还算合理的起步值。5.2 自定义限流Key和参数调整RequestRateLimiter默认的key-resolver是PrincipalNameKeyResolver取的是当前登录用户的PrincipalName。但这个默认实现通常不满足业务需求。企业里最常见的限流维度是IP因为不需要用户体系也能生效。以下是按IP限流的实现Bean public KeyResolver ipKeyResolver() { return exchange - { String ip exchange.getRequest().getRemoteAddress() null ? unknown : exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); return Mono.just(ip); }; }注意如果网关前面还挂了Nginx或者云负载均衡getRemoteAddress拿到的是代理服务器的IP所有用户都会被当成同一个IP来限流那就等于全局限流了。要拿真实客户端IP就得从 X-Forwarded-For 这个请求头里取取第一个逗号前面的IP即可。这段逻辑在真实生产环境几乎是必写的。限流参数的调优没有绝对正确的值完全靠压测和业务预期来定。我一般建议普通查询类接口限流可以相对宽松只要别让下游CPU和数据库扛不住就行写操作和下单类接口限流要严格一些因为写操作对数据库和事务资源的消耗远大于读秒杀这类场景限流的目的不是让所有人通过而是保护核心服务不被打挂宁可拒绝大部分请求也要保证服务可用参数调完不是一劳永逸的。上了生产之后要盯监控看被限流的请求占比看下游服务的RT和错误率。如果下游一直很健康可以适当上调如果下游频繁报警赶紧往回收。5.3 熔断降级与兜底响应限流管的是入口流量熔断管的是下游故障。网关转发到下游服务时下游如果响应超时或者直接挂了网关不能在这里死等。等一个两个请求还好等多了网关线程全被占住整个入口就瘫痪了。Spring Cloud Gateway里做熔断我用的是内置的CircuitBreaker过滤器底层集成的是Resilience4j。给路由加上这个过滤器再配置一个fallbackUri就能实现熔断后的快速失败filters: - name: CircuitBreaker args: name: userServiceCB fallbackUri: forward:/fallback/userfallbackUri配置的是一个网关内部的转发地址。当熔断器打开时请求会被转发到网关自己的 /fallback/user 这个接口而不是继续等待下游超时。这个兜底接口在网关应用里写一个Controller即可RestController public class FallbackController { GetMapping(/fallback/user) public MapString, Object userFallback() { MapString, Object result new HashMap(); result.put(code, 503); result.put(msg, 用户服务暂时不可用请稍后重试); return result; } }当然其他下游服务都要各自配一个fallback地址不能共用一个。一开始图省事我让所有服务都fallback到同一个接口后来发现线上排障时根本看不出来是哪个服务挂了。每个服务独立配一个fallback路径返回信息里带上服务名或者业务提示对监控和应急沟通都有好处。Resilience4j的熔断参数可以在配置中心单独调整比如失败率阈值的触发条件、熔断打开状态的等待时间等。但企业落地的第一步我更建议先用默认参数加fallbackUri跑起来把链路走通再根据压测结果精细化调整。别一开始就把十几个参数全部配置上出了问题反而不知道怎么排查。6. 常见问题排查与调优实录6.1 典型坑点速查表网关这类入口组件的坑往往不是代码难写而是当你遇到问题时根本不知道从哪里查起。我把做网关这半年多遇到的典型问题整理成一张速查表排查的时候直接对照表现原因解决办法启动直接报Spring MVC found on classpath网关里误引入了spring-boot-starter-web排除web依赖或用spring-boot-starter-webflux路由配了但请求返回404Path谓词没匹配上或StripPrefix配错开启网关debug日志看实际匹配的路由和转发路径所有请求都返回503uri里的服务名在注册中心找不到检查Nacos服务名是否一致Nacos地址是否通登录接口能过但业务接口401白名单路径没匹配到统一去掉路径末尾斜杠或用白名单前缀startsWith判断限流不生效或启动报错没引入redis-reactive或Redis连不上引入依赖检查Redis地址和密码配置请求能通但响应头异常跨域头或自定义头在转发时丢失在网关层统一配置CORS用DedupeResponseHeader去重这张表里我想特别强调第一行那个Spring MVC冲突。我接手过一个项目开发环境跑得好好的打包部署上去就启动失败最后发现是某个公共依赖里面带了spring-boot-starter-web网关编译期不报错启动时才暴露出来。所以网关模块的依赖树一定要检查干净用mvn dependency:tree排查一下有没有传递进来的web依赖。第二个常见问题是服务名找不到。Nacos里注册的服务名是 user-service路由uri配的却是 user-service-provider这种粗心错误其实很常见。还有一种是网关和下游服务注册到了不同的Nacos命名空间或者不同的group看起来地址一样但网关就是发现不了服务。遇到503先去Nacos控制台看一眼服务列表比在代码里瞎猜高效得多。6.2 实用调优建议网关上生产之前有几个调优项是值得花时间做的。第一个是超时配置。网关转发到下游如果下游服务处理很慢网关的默认超时时间可能会让请求等待很久线程一直挂着。建议在配置里显式设置连接超时和响应超时spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 5sconnect-timeout是建立连接的超时时间response-timeout是等待响应的超时时间。配置成3秒和5秒意味着下游再慢也不能拖住网关超过5秒超过就快速失败走熔断兜底。第二个是网关节点要独立部署并水平扩容。一台网关扛不住所有流量生产环境至少要部署两个节点前面挂负载均衡。网关是典型的无状态组件不像业务服务那样有本地缓存和会话粘连问题扩缩容非常方便。流量起来的时候多拉两个网关实例就能扛住。第三个是网关配置要交给配置中心管理。我后来把网关的路由配置全部迁移到了Nacos Config这样改路由规则不需要重启网关。网关多了之后如果每次变更都要一台台登录服务器改配置文件再重启运维成本会高得离谱。配置中心虽然初期要花点时间接入但长期来看绝对值得。还有一点经验之谈网关代码里尽量别写太重的业务逻辑。网关是个入口不是业务容器。有些同学喜欢在网关里做数据聚合、调用好几个下游服务拼装返回结果这是典型的过度设计。这种活应该交给BFF层或者专门的聚合服务网关只做转发、认证、限流、熔断这几件事保持轻量才能保证性能和稳定性。我个人在实际项目里体会最深的一点是网关不是越复杂越好。路由、认证、限流、熔断这四件事做扎实就已经能解决微服务集群入口层面八成以上的问题。它更像一个小区的门禁——正常住户刷卡放行访客登记核对遇到突发情况能快速拉起闸机防止挤踏。后面如果要扩展灰度发布、接口版本迁移这些能力基于这套网关再叠加自定义过滤器就行整个架构不用推翻重来。先让网关把基础职能履行好再往上面长能力这条路走下来是最稳的。
返回列表