资讯动态

Spring Cloud Gateway微服务网关:路由、鉴权、限流与灰度实战

发布时间:2026/10/27 22:30:20 来源:尧图企业网站定制
1. 先想明白网关该放在哪一层再谈怎么写代码微服务拆到一定规模之后总会冒出同一类问题每个服务都要各自处理登录校验、限流、跨域、日志埋点、灰度分流重复代码越堆越多改一处就要改八遍前端同学更难受手上拿着七八个域名和端口改一次服务拆分就得跟着改一次配置。这时候大家都会想到加一层微服务 API 网关把那些每个服务都要做、但又跟业务无关的事集中起来做一次。Spring Cloud Gateway 就是干这件事的一个主流选择它基于响应式编程模型构建能路由、能断言、能过滤、能限流适合已经跑在 Spring 生态里、并且希望把网关当成一个普通 Java 工程来维护的团队。这篇内容面向三类人刚接触微服务、想知道网关到底解决什么问题的新人手上正在把老的单体拆成多个服务、需要选一个网关落地的开发者以及网关已经上线、被 502 和超时折腾过、想搞清楚排查思路的运维和中间件同学。我会按我自己的实际落地顺序来讲从该不该用该放在哪一层到路由断言过滤器怎么配再到鉴权、限流、灰度、统一异常这些必须有的能力怎么实现最后把踩过的坑整理成速查表。代码和配置都是可以直接抄走改改就能跑的形态版本对齐的坑我也会单独拎出来说。先把一个常被跳过但最影响后续架构的问题讲清楚网关不是一个顺手加的功能它一旦上线就是所有流量的必经之路它的可用性直接等于整个系统的可用性。所以在你写下第一行spring-cloud-starter-gateway依赖之前得先想清楚它承担什么、不承担什么。我见过太多团队把网关做成了万能中间层鉴权、参数加工、业务聚合、数据脱敏全塞进去最后这个工程比任何一个业务服务都复杂谁都不敢改。下面先把这个边界划清楚。1.1 网关该承担什么不该承担什么网关最该干的事情用一句话概括就是与业务无关的横切关注点且必须在流量入口处做才有效的事。具体包括统一路由转发、统一鉴权与身份透传、统一限流与熔断、统一跨域处理、统一的访问日志与链路标识注入、统一的响应包装与异常兜底、灰度发布与流量染色。这些东西如果放到各个服务里做一是重复二是标准容易跑偏三是策略调整得逐个发版成本极高。反过来网关明确不该干的也有几件。第一是业务聚合那种调 A 服务拿用户信息、调 B 服务拿订单、拼成一个复合对象返回的活儿应该交给专门的聚合层或 BFF塞进网关会让网关跟着业务频繁发版失去了稳定基础设施的属性。第二是重计算比如图片裁剪、大文件加解密、复杂签名验签这些会占用网关的事件循环线程把整个入口拖慢。第三是数据持久化网关连业务库是大忌一旦连了库它的扩缩容就被数据库连接数锁死了。还有一个容易忽略的点网关不该成为唯一的鉴权点。有些团队觉得网关校验过 token 了内部服务就完全裸奔服务之间随便调。这在小规模时看着省事一旦内网被打穿或者某个服务被误暴露全线失守。比较稳的做法是网关做粗粒度拦截有没有 token、token 有没有过期、有没有访问这个路由的权限服务内部做细粒度校验这个用户能不能操作这条具体数据两层配合。提示判断一个逻辑该不该放网关我常用一个土办法——问自己这个逻辑变更时愿意为它单独发一次网关版本吗。如果答案是不愿意太频繁了那它就不该在网关里。1.2 网关、反向代理、服务内拦截器三者的分工很多同学会被 Nginx、网关、Spring 拦截器这三层搞晕觉得功能重叠。实际上它们的定位差得很远。Nginx 这类反向代理工作在更底层擅长的是 TLS 卸载、静态资源、超大并发连接维持、以及最外层的粗粒度访问控制它不理解你的业务身份也不方便做基于请求头和服务元数据的动态路由。网关工作在应用层能解析 JSON、能读注册中心的服务列表、能按权重分流、能做基于用户维度的限流代价是吞吐比 Nginx 低一个量级。服务内的拦截器则负责最贴近业务的那部分比如方法级权限、参数业务校验。所以经典的流量路径是客户端 → NginxTLS 卸载 静态资源 最外层防护→ 网关路由 鉴权 限流 日志→ 业务服务业务校验。这个分层不是形式主义它决定了故障时的定位顺序。有一次我们遇到接口大面积 504第一反应是网关挂了结果排查下来是 Nginx 到网关之间的 keepalive 连接数配置太小高并发下连接被反复重建加上后端响应稍慢就超时了。如果一开始就知道分层直接在 Nginx 那层看连接状态能省两个小时。我建议在架构图上把这三层明确画出来并且规定每一层只做自己那部分事情。比如跨域能在外层 Nginx 加响应头解决的就别在网关里再配一遍两边都配很容易出现重复响应头导致的浏览器报错。2. 技术选型为什么最后稳定在 Spring Cloud Gateway 上网关的选型其实没那么多选项。早期很多人用 Zuul 1.x那个是基于 Servlet 的阻塞模型一个请求占一个线程网关这种请求进来、转发出去、等响应回来的场景里绝大部分时间线程都在等 IO阻塞模型的线程利用率非常低。后来 Spring 官方推出了 Spring Cloud Gateway直接建在 Spring WebFlux 和 Reactor Netty 之上用少量事件循环线程撑住大量并发连接这是它最核心的竞争力。我在两个项目里做过对比测试同样配置的机器4 核 8G纯转发场景下阻塞模型在并发连接数上来之后延迟明显抬头因为线程池被占满后新请求只能排队响应式模型在几千并发连接下延迟曲线还是比较平的内存占用也更可控。当然这不是说阻塞模型不能用如果你的 QPS 只有几百、团队对 Reactor 完全不熟用阻塞模型反而更省心至少排查问题时线程栈能看懂。但当你的网关需要承接全站流量、并且预期会持续增长时响应式是更划算的长期投资。2.1 几种常见方案放在一张表里对比方案编程模型动态路由与 Spring 生态契合度二次开发成本适用场景Spring Cloud Gateway响应式Reactor支持配合注册中心/配置中心很高普通 Spring 工程中需要理解 Reactor已用 Spring 体系、需要深度定制Zuul 1.x阻塞Servlet支持高低老系统维护、并发要求不高Nginx Lua事件驱动需要自己写脚本维护低高脚本调试痛苦极高性能、逻辑相对固定各类现成网关产品各异支持视产品而定低但受限于插件不想自己维护代码这张表里最容易被低估的是二次开发成本这一列。Spring Cloud Gateway 的自定义能力很强写一个GlobalFilter就是实现一个接口的事但它要求你理解响应式编程里的流概念——你不能在过滤器里随便阻塞也不能想当然地以为代码是按行顺序执行的。我见过有同学在过滤器里调用了同步的 HTTP 客户端去查用户信息压测时 QPS 上不去最后发现是那一次阻塞调用把事件循环线程堵住了。这个坑在后面讲自定义过滤器时会详细说。至于那些开箱即用的网关产品优势是省事、有界面、有现成插件劣势是当你的需求稍微偏一点就得等厂商排期或者写插件而且出问题时你能看到的东西有限。我的判断标准是如果团队里有能读懂框架源码的人自己维护一个 Gateway 工程长期看更灵活如果没有选成熟产品更稳。2.2 响应式模型带来的收益以及你必须接受的代价收益前面说了高并发下资源利用率高、延迟稳定。代价也很实在我列几条你一定会遇到的。第一异常栈不友好。响应式链路里报错栈信息里大量是 Reactor 的内部帧真正的问题点被埋得很深得往下翻很久才能看到自己的类名。习惯了看 Servlet 栈的同学一开始会很抓狂。第二ThreadLocal不好用了。传统做法里我们把用户信息塞进ThreadLocal全链路随处可取。但在响应式模型下一个请求的处理可能在不同线程间切换ThreadLocal里的东西会丢。替代方案是往 Reactor 的Context里放或者干脆把需要的信息塞进请求头透传下去。这一点在实现鉴权过滤器时是必须处理的后面会给具体写法。第三任何阻塞调用都是定时炸弹。数据库查询、同步 HTTP 调用、文件 IO这些在过滤器里出现就要格外小心。真要做得用Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())切到弹性线程池去不能直接在事件循环线程上跑。第四调试手段变少。不能像以前那样随手打个断点看变量得靠日志和doOnNext这类钩子观察流。提示如果你的团队里没人愿意花时间理解 Reactor那选它就是给自己埋雷。技术选型永远要匹配团队能力而不是匹配 benchmark 数字。3. 核心概念拆解路由、断言、过滤器这三件套Spring Cloud Gateway 的世界观非常简单一个请求进来先找匹配的路由Route路由由**断言Predicate决定是否命中命中之后经过一串过滤器Filter**处理最后转发到目标地址。所有复杂功能都是围绕这三个概念展开的把它们吃透剩下的都是查文档的事。路由的定义包含几个要素唯一 id、目标 uri、断言列表、过滤器列表、以及可选的元数据 metadata。断言是与的关系配了多个断言就必须全部满足才命中。过滤器分两类GatewayFilter只作用于它所属的那条路由GlobalFilter作用于所有路由。元数据 metadata 是个很实用的东西可以挂一些自定义标记在过滤器里读出来做判断比如标记这条路由属于哪个业务域、是否需要鉴权。3.1 断言的匹配顺序与优先级陷阱断言命中是有顺序的框架会按路由的声明顺序或者显式配置的 order逐条尝试第一条完全匹配的路由胜出。这个特性非常关键因为它意味着顺序错了会出大问题。举个我实际踩过的例子我们有一条通用路由/api/**转发到默认集群后来新增了一条/api/special/**转发到新集群。因为通用路由写在前面所有/api/special/xxx的请求都被通用路由先吃掉了新路由压根没生效。排查时看日志发现路由命中的 id 不对才反应过来是顺序问题。解决办法有两种把更精确的路径写在前面或者给路由显式指定order值数值小的优先。断言本身也有坑。Path断言用的是 Ant 风格路径匹配/api/**能匹配/api/a/b/c但/api/*只能匹配一层。Host、Method、Header、Query、Cookie这些断言可以组合出很细的规则但组合越多越容易写出永远不命中的配置。我的习惯是配置完先用一条curl打进去看网关日志里打印的命中路由 id 对不对确认无误再往下加过滤器。3.2 过滤器的执行顺序与生命周期过滤器执行分两个阶段pre转发之前和post拿到响应之后。GlobalFilter配合Ordered接口或者Order注解来控制顺序数值越小越早执行 pre、越晚执行 post。这个洋葱模型很重要它意味着你在 pre 阶段放进请求头的东西post 阶段还能读到反过来在 pre 阶段消耗掉的请求体post 阶段就拿不到了。内置过滤器里StripPrefix用于去掉路径前缀比如外部是/api/user/info后端服务只认/user/info就配StripPrefix1PrefixPath加前缀RewritePath做正则改写AddRequestHeader/AddResponseHeader加头Retry做重试RequestRateLimiter做限流CircuitBreaker做熔断。这些够覆盖八成场景。需要特别提醒的是请求体只能读一次。HttpServletRequest 的 body 是个流读完了就没了。网关转发时如果某个过滤器把 body 读走了却没缓存后端会收到空请求体。框架提供了ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR这个属性名配合ModifyRequestBodyGatewayFilterFactory或者自己用ServerRequest.bodyToMono(String.class)读完之后把结果塞回 attribute才能既读到又能转发。这个细节在实现请求体加解密或请求体日志功能时是绕不过去的后面实操部分给完整代码。4. 从零把工程跑起来依赖、版本与最小配置讲完概念直接上手。这一节我会把工程搭建过程完整走一遍包括版本怎么对齐、依赖怎么引、配置文件用哪种组织方式。看起来是最基础的部分但恰恰是新人最容易卡住的地方——Spring Cloud 这套东西版本不对齐启动就报一堆找不到类的错非常劝退。4.1 依赖引入与版本对齐的两个硬性约束第一个硬性约束引入了spring-cloud-starter-gateway就不要再引spring-boot-starter-web。前者基于 WebFlux后者基于 Servlet两个 Web 容器同时存在会导致启动失败报Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway。这个错误信息其实写得很清楚但很多人不看日志直接去搜浪费时间。如果你的网关工程确实需要用到某个依赖了 web 的公共包处理方法是在那个依赖上排除spring-boot-starter-web。第二个硬性约束Spring Cloud 的版本要和 Spring Boot 版本严格对应。比较稳妥的做法是用父 pom 的方式通过spring-cloud-dependencies这个 BOM 统一管理版本不要自己给每个组件写版本号。我给一个典型的配置骨架parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.x/version /parent properties spring-cloud.version2023.0.x/spring-cloud.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency !-- 注册中心客户端按实际使用的选型引入 -- /dependencies如果你不确定版本对应关系最省事的办法是去 Spring Cloud 官方文档里的版本对照表查一眼别凭记忆写。我见过有人用 Spring Boot 3 配了 Spring Cloud 2021 的 BOM启动时报了一堆反射相关的NoSuchMethodError折腾半天才发现是版本错配。4.2 一份能直接启动的最小路由配置配置文件我用application.yml因为 YAML 层级清晰路由多了也比 properties 好看。最小可用配置大概长这样server: port: 8080 spring: application: name: edge-gateway cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: order-service-route uri: lb://order-service order: 10 predicates: - Path/api/order/** - MethodGET,POST filters: - StripPrefix1这里的lb://前缀是重点它表示走负载均衡后面的名字是注册中心里的服务名。lb这个 scheme 由ReactiveLoadBalancerClientFilter处理它会把服务名解析成实际实例地址。如果你暂时没接注册中心直接写http://127.0.0.1:9001也能跑方便本地调试。StripPrefix1的意思是去掉路径的第一段。上面这条配置下外部请求/api/user/info会被转成/user/info打到后端。这个数字要和你的路径层级对上写错会导致后端 404是新人最常犯的错之一。4.3 静态配置和动态配置什么时候该切换上面这种写在application.yml里的方式叫静态配置改一条路由就得重启。服务少、变更少的时候完全够用但一旦服务数量上来了、或者需要按环境动态调权重就必须换成动态路由。动态路由的核心思路是把路由定义从配置文件里挪到配置中心或者数据库里然后在网关里监听变更事件直接操作RouteDefinitionRepository或通过RouteDefinitionWriter刷新内存里的路由表。Spring Cloud Gateway 暴露了RouteDefinitionLocator这个扩展点你实现一个从任意数据源读取路由定义的实现类再配合一个刷新接口就能做到不重启更新路由。具体步骤我会在实操章节展开这里先记住结论路由数量少于 20 条且不常变静态配置足够超过这个量级或者有灰度需求早上动态方案。提示动态路由一定要做变更前校验比如新路由的 uri 格式不对、断言写错直接刷新进去会导致流量全部打偏。稳妥做法是先 dry-run 校验通过再提交或者在刷新前保留上一版快照出问题一键回滚。5. 动手实现业务里真正绕不开的几个能力路由能跑通只是开始真上线时缺一样都不行没有统一鉴权每个服务自己写没有限流一个爬虫就能把你打挂没有统一异常前端收到的错误格式五花八门没有灰度新版本上线只能全量赌一把。这一节我按代码量从小到大排逐个给实现思路和关键代码。5.1 统一鉴权一个 GlobalFilter 解决 90% 的场景鉴权过滤器的逻辑很直接从请求头拿 token校验有效性无效就直接返回 401有效就把用户标识塞进请求头传给下游。难点在于两点一是白名单怎么配二是前面提到的ThreadLocal问题。白名单我建议做成配置项支持 Ant 风格路径因为登录、注册、健康检查这些接口必须放行gateway: auth: whitelist: - /api/auth/login - /api/auth/register - /actuator/**过滤器本体Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); if (isWhiteList(path)) { return chain.filter(exchange); } String token exchange.getRequest().getHeaders().getFirst(Authorization); if (token null || token.isBlank()) { return unauthorized(exchange, token missing); } // 示例解析出用户标识真实场景换成你的校验逻辑 String userId; try { userId tokenResolver.resolve(token); } catch (Exception e) { return unauthorized(exchange, token invalid); } ServerHttpRequest mutated exchange.getRequest().mutate() .header(X-User-Id, userId) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } private MonoVoid unauthorized(ServerWebExchange exchange, String msg) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes ({\code\:401,\msg\:\ msg \}).getBytes(StandardCharsets.UTF_8); DataBuffer buffer response.bufferFactory().wrap(bytes); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { return -100; } }几个关键点说明一下。getOrder()返回-100是为了让它尽早执行比内置的很多过滤器都靠前避免无效请求继续往下走浪费资源。用exchange.mutate()改写请求头而不是直接操作原始对象这是框架推荐的不可变风格。返回错误时用response.writeWith而不是setComplete这样才能带上响应体。再说ThreadLocal的问题。上面这段代码通过请求头透传用户标识是响应式下最稳的做法不需要考虑线程切换。如果你确实需要在过滤器内部共享一些上下文比如链路 id用 Reactor 的 Contextreturn chain.filter(exchange) .contextWrite(ctx - ctx.put(traceId, traceId));然后在需要的地方通过Mono.deferContextual取出。这个写法一开始不习惯但用熟了就顺了。5.2 限流参数不是拍脑袋定的得算限流用RequestRateLimiter这个内置过滤器底层依赖 Redis 做令牌桶。核心配置是这样filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 50 redis-rate-limiter.burstCapacity: 100 redis-rate-limiter.requestedTokens: 1 key-resolver: #{ipKeyResolver}replenishRate是每秒补充的令牌数也就是稳态 QPS。burstCapacity是桶容量决定能容忍多大的瞬时突发。key-resolver决定限流维度按 IP、按用户、按接口都可以。KeyResolver 的写法Bean public KeyResolver ipKeyResolver() { return exchange - Mono.just( Objects.requireNonNull(exchange.getRequest().getRemoteAddress()) .getAddress().getHostAddress() ); }参数怎么定假设你的网关集群有 4 个实例业务侧给你的预算是某个接口全站 200 QPS。那么每个实例的replenishRate应该是 200 / 4 50。burstCapacity一般取replenishRate的 1.5 到 2 倍我这里取 100意思是允许瞬间来 100 个请求之后按每秒 50 个的速度消化。这个倍数的选择取决于业务能容忍多大的毛刺——批处理任务、定时任务集中触发的接口倍数要给大一些面向用户的查询接口倍数小一点更安全避免一次突发把下游压垮。有一个坑必须提前说replenishRate和burstCapacity设成一样的值会导致第一个请求就失败。这是历史遗留问题令牌桶在补充前如果容量等于消耗速度边界情况下会判定无令牌可用。所以永远让burstCapacity大于replenishRate。限流命中后默认返回 429但默认响应体是空的前端拿到一个没有内容的 429 会一脸懵。稳妥做法是自定义一个过滤器放在限流之后或者直接重写响应// 在 KeyResolver 之外通过自定义全局异常处理统一包装 429这个后面和统一异常一起讲。5.3 灰度发布用权重断言和自定义断言把流量切出来灰度是网关最有价值的能力之一。最简单的方式是用内置的Weight断言按比例把流量分到两个集群routes: - id: order-service-stable uri: lb://order-service predicates: - Path/api/order/** - Weightorder-group, 90 - id: order-service-canary uri: lb://order-service-canary predicates: - Path/api/order/** - Weightorder-group, 10同一个order-group的两个路由会按 90:10 分流。这个方案简单但粒度粗只能按比例切不能指定具体用户。更精细的做法是自定义断言按请求头里的用户标识哈希取模保证同一个用户始终落在同一个集群避免用户在两个版本间来回跳导致数据不一致Component public class GrayPredicateFactory extends AbstractRoutePredicateFactoryGrayPredicateFactory.Config { public GrayPredicateFactory() { super(Config.class); } Override public PredicateServerWebExchange apply(Config config) { return exchange - { String uid exchange.getRequest().getHeaders().getFirst(X-User-Id); if (uid null) { return false; } int bucket Math.abs(uid.hashCode()) % 100; return bucket config.getPercent(); }; } Validated public static class Config { private int percent; public int getPercent() { return percent; } public void setPercent(int percent) { this.percent percent; } } }配置里用- Gray20就能把 20% 的用户切到灰度集群。注意路由顺序灰度路由要写在稳定路由前面否则稳定路由的Path断言会先命中。我实际用下来还有一个小改造把percent存到配置中心配合一个管理接口动态调整。这样发版期间可以按 1%、5%、20%、50% 逐步放量出问题随时调回 0不用重启。5.4 统一异常与统一响应别让前端猜格式默认情况下网关返回的错误格式跟业务服务完全不一样。业务服务返回{code:0,data:{}}网关超时返回的是一个 Spring 的默认错误页。前端要写两套解析逻辑非常难受。解决办法是实现一个全局的异常处理把网关层面的错误统一包装。思路是继承DefaultErrorWebExceptionHandler或者用Order(-1)的ErrorWebExceptionHandler实现类重写响应内容。同时要注意业务服务返回的错误不应该被网关二次包装只有网关自身产生的错误才包装。Component Order(-1) public class GatewayErrorHandler implements ErrorWebExceptionHandler { Override public MonoVoid handle(ServerWebExchange exchange, Throwable ex) { ServerHttpResponse response exchange.getResponse(); HttpStatus status; String msg; if (ex instanceof ResponseStatusException rse) { status HttpStatus.resolve(rse.getStatusCode().value()); msg rse.getReason() null ? gateway error : rse.getReason(); } else if (ex instanceof ConnectTimeoutException) { status HttpStatus.GATEWAY_TIMEOUT; msg upstream timeout; } else { status HttpStatus.INTERNAL_SERVER_ERROR; msg internal error; } response.setStatusCode(status null ? HttpStatus.INTERNAL_SERVER_ERROR : status); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); String body String.format({\code\:%d,\msg\:\%s\,\data\:null}, response.getStatusCode().value(), msg); DataBuffer buffer response.bufferFactory() .wrap(body.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); } }这里要提醒一句ErrorWebExceptionHandler只在 WebFlux 环境下生效如果你混用了 Servlet 的组件这个处理器可能不会被注册表现为异常时还是返回默认错误页。排查方法是在启动日志里搜这个 bean 有没有被创建。5.5 日志与链路标识出问题时能捞到东西网关的访问日志我建议单独打一份包含请求 id、路由 id、上游地址、耗时、状态码。请求 id 在入口处生成通过请求头传给下游这样跨服务排查时能串起来Component public class TraceGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId exchange.getRequest().getHeaders() .getFirst(X-Trace-Id); if (traceId null) { traceId UUID.randomUUID().toString().replace(-, ); } long start System.currentTimeMillis(); String finalTraceId traceId; ServerHttpRequest req exchange.getRequest().mutate() .header(X-Trace-Id, traceId).build(); return chain.filter(exchange.mutate().request(req).build()) .doFinally(signal - { long cost System.currentTimeMillis() - start; String routeId (String) exchange.getAttribute( ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR null ? : org.springframework.cloud.gateway.support.ServerWebExchangeUtils.gatewayRoute); log.info(traceId{} path{} cost{}ms status{}, finalTraceId, exchange.getRequest().getURI().getPath(), cost, exchange.getResponse().getStatusCode()); }); } Override public int getOrder() { return -200; } }上面那段取路由 id 的写法我故意写得有点绕是为了提醒你不要硬编码字符串用框架提供的常量。ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR是框架常量直接用它获取Route对象再调getId()更稳。我在正式代码里都是这么写的上面为了让你看清结构才展开。日志量大的时候要注意磁盘别让网关的日志把机器塞满。我一般配置成按天滚动、保留 7 天并且对健康检查路径做过滤不然/actuator/health几分钟就能刷出几百兆。6. 常见问题与排查技巧实录这一节是我这些年攒下来的病历本。网关的问题有个共同特点现象都长得差不多502、503、504、超时、卡顿但根因可能完全不一样。下面我按现象分类给出排查路径和最终定位方法。6.1 5xx 家族502、503、504 分别意味着什么503 Service Unavailable在网关场景下最常见的原因是找不到可用实例。路由的lb://后面的服务名在注册中心里查不到健康的实例就会返回 503。排查步骤先看注册中心里这个服务有没有注册、实例状态是不是 UP再看网关有没有正常拉取服务列表有些注册中心的客户端有本地缓存服务重启后缓存没刷新会持续 503。还有一个隐蔽原因服务名大小写或者带了下划线注册中心和网关的解析规则不一致。504 Gateway Timeout是上游响应超时。网关默认的响应超时时间比较长你需要显式配置spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 10s注意这两个配置的单位不一样connect-timeout是毫秒response-timeout是Duration格式。我见过有人把response-timeout写成10000结果被解析成 10000 纳秒等于没有超时。这类问题只能靠仔细看文档避免。502 Bad Gateway通常是上游返回了非法响应或者连接被上游直接关闭。常见于上游服务进程崩溃重启的间隙或者上游返回的响应头过大超过了网关的限制。后者可以通过调大max-header-size解决。6.2 性能问题内存涨、线程堵、延迟抖网关的性能问题排查我总结了一个从外到内的顺序。第一步看事件循环线程有没有被阻塞。响应式模型下Netty 的工作线程数量是固定的默认是 CPU 核数乘 2任何一个线程被阻塞调用占住整个实例的处理能力就掉一截。排查方法是打开 Reactor 的阻塞检测BlockHound.install();这个工具会在有阻塞调用跑在事件循环线程上时直接抛异常定位非常准。我靠它抓到过一次在过滤器里同步调 Redis 的问题。第二步看内存。网关如果缓存了请求体或响应体很容易内存泄漏。尤其是用DataBufferUtils.join把请求体读成一个大 buffer 之后如果没有正确release堆外内存会持续增长最终 OOM。正确写法是读完立即处理、处理完释放或者用DataBufferUtils.join(...).flatMap(...)让框架管理生命周期。第三步看连接池。网关到上游的连接数如果不够高并发下会排队等待表现为延迟抖动但 CPU 不高。可以适当调整连接池参数但要注意别把上游压垮。我的经验是连接池上限设置为上游实例数乘以单实例可承受并发数宁可让网关侧排队也别让上游雪崩。6.3 一张速查表覆盖八成现场问题现象可能原因快速验证方式处理方向503 无可用实例注册中心拉不到实例 / 服务名不匹配查注册中心实例列表与网关本地缓存修服务名、强制刷新缓存504 超时上游慢 / 超时配置过短直连上游测响应时间调超时、优化上游502 错误响应上游崩溃 / 响应头过大看上游日志、抓包修上游、调大头部限制路由不生效断言顺序错 / 路径层级不匹配看网关日志里的命中路由 id调整顺序、加显式 order请求体丢失过滤器读了 body 未缓存下游打印 body 长度用缓存属性回填内存持续增长DataBuffer 未释放看堆外内存曲线检查 join 后的释放逻辑429 频繁限流参数过小 / 维度选错看 KeyResolver 的 key 值调参数、换维度跨域报错网关和外层重复加头看响应头是否重复只保留一层配置这张表我建议打印出来贴在工位上八成的值班问题照着走一遍就能定位。剩下两成通常是环境问题或者配置中心下发的配置和本地不一致排查时第一件事是确认网关实际加载的配置到底长什么样——这个可以通过 actuator 的/actuator/gateway/routes端点看非常有用。提示生产环境记得把这个端点保护起来别让外部能直接访问路由信息暴露出去有一定风险。6.4 几个只有踩过才知道的细节最后分享几条文档里基本不会写、但实际会踩的经验。配置中心的下发有延迟。你改了一条限流参数界面上显示已生效但网关实际生效可能要过几秒甚至几十秒。压测时如果改完参数立刻观察效果很容易得出错误结论。我的做法是改完等一个心跳周期再看或者直接查网关的内存值确认。注册中心的健康检查有心跳间隔。上游服务刚重启完注册中心可能还认为它是健康的网关把流量打过去就 502。要彻底避免得在服务端做优雅停机先把实例从注册中心摘掉、等在途请求处理完再退出。网关的线程数不是越多越好。有些同学一看延迟高就把 Netty 线程数调大结果上下文切换开销上来了反而更慢。响应式模型的设计初衷就是少量线程做大量事盲目调大是在跟模型对着干。压力测试要用真实流量特征。我见过用固定 QPS 打网关压测结果线上是突发的批量请求瞬间把限流桶打空导致大面积 429。压测时最好把峰值特征、请求大小分布都模拟进去否则测出来的数字没有参考价值。任何在过滤器里新增的远程调用都要先问一句能不能不调。网关是每次请求都要经过的地方这里加一个 10ms 的调用全站延迟就加 10ms。能在网关本地缓存的就本地缓存能异步的就异步实在要同步调用的务必用弹性线程池隔离。我个人在实际操作中的体会是网关这个组件真正的门槛不在怎么写一个过滤器而在知道什么该写、什么不该写。代码写起来都很快难的是在流量真正打上来之前把那些会拖垮入口的隐患提前想清楚。我现在每加一个新功能到网关都会先反问自己三个问题这个逻辑是不是非得在入口做、它会不会引入阻塞、它出问题时能不能快速回滚。三个问题都能答上来我才敢提交代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑