如果把 Spring Cloud Gateway 的请求链路比作一条流水线那么绝大多数全局过滤器都在做“加工”和“决策”真正到最后把请求送出去、把响应接回来的就是NettyRoutingFilter。它也是整个网关里最底层、最容易被忽略却又最关键的一环——印象中不少线上问题排查到最后都是在这个过滤器里翻了车。这篇就把它的源码和执行流程拆开看一遍包括它怎么构造转发请求、怎么处理只允许读一次的请求体、怎么把下游响应写回客户端以及我实际踩过的一些坑和排查思路。适合刚接触网关源码、或者已经被转发问题折磨过的同学参考。1. 它在整条过滤器链里到底排在哪1.1 全局过滤器中的定位NettyRoutingFilter实现了GlobalFilter和Ordered它的getOrder()返回值是Ordered.LOWEST_PRECEDENCE也就是整数最大值。这个细节非常值得琢磨在一个按 order 从小到大执行的过滤器链里order 越小越先执行LOWEST_PRECEDENCE意味着它几乎排在所有全局过滤器的最后面。这个顺序不是随意定的。网关里像LoadBalancerClientFilter、RouteToRequestUrlFilter这些过滤器都是在为“真正转发”做准备工作确定目标地址、改写协议、附加请求头。只有这些都执行完了最后才轮到NettyRoutingFilter把请求真正发出去。如果它排得太靠前下游地址还没准备好转发就无从谈起。另一个值得注意的点NettyRoutingFilter只处理http和https两种 scheme。对于ws、wss协议的 WebSocket 请求网关里有专门的WebsocketRoutingFilter来处理。所以在源码入口处你会看到一个非常直白的 scheme 判断不匹配就直接chain.filter(exchange)放行把控制权交还给过滤器链。1.2 和上游过滤器怎么衔接转发的目标地址存在哪里答案在ServerWebExchange的 attribute 里。RouteToRequestUrlFilter会把最终要请求的 URI 塞进ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR这个键里。后面所有路由过滤器都是从同一个 attribute 取值NettyRoutingFilter也不例外URI requestUrl exchange.getRequiredAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); String scheme requestUrl.getScheme();还有一层很微妙的保护机制isAlreadyRouted。这个标志位用来防止同一个请求被路由过滤器处理多次。为什么要防因为GlobalFilter链在某种配置下可能注册多个路由过滤器比如类路径下同时存在 Netty 和 WebClient 两套转发实现。如果这个开关没设置一个请求可能被先转发一次然后在某些异常或重试场景下再被转发一次造成下游重复消费。源码里进入真正转发逻辑前会先做这个判断如果没有被路由过就会调用setAlreadyRouted(exchange, true)打上标记。这个标记还承担了另一个职责如果前面的自定义过滤器已经完成了转发比如某些场景想绕过网关默认转发逻辑可以直接设置这个 attribute这样NettyRoutingFilter会直接放行不会再次转发。2. 构造器与初始化里藏着的设计2.1 HttpClient 从哪来NettyRoutingFilter的构造函数只依赖两样东西一个HttpClient一个ObjectProviderRouteDefinitionRouteLocator。HttpClient是 reactor-netty 的客户端抽象它并不是在NettyRoutingFilter内部 new 出来的而是由 Spring Cloud Gateway 的HttpClientFactory统一创建并注入。这解释了为什么网关的很多网络参数都集中在spring.cloud.gateway.httpclient配置下连接池大小、响应超时、SSL 配置、代理配置最后都会体现到这个HttpClient实例上。NettyRoutingFilter本身不关心连接池怎么管理、超时怎么计算它只负责“用这个客户端把请求发出去”。这套依赖注入的设计让网络层与应用层解耦得比较干净。ObjectProviderRouteDefinitionRouteLocator这个依赖则需要解释一下。ObjectProvider是一种懒加载的依赖获取方式允许在构造时不强制要求目标 bean 已经存在。网关启动时过滤器链的装配和路由定位器的装配可能有时序差异直接用构造器注入强依赖容易在启动早期报错。用ObjectProvider之后过滤器可以正常创建路由定位器则在需要时再获取。NettyRoutingFilter还实现了ApplicationListenerRouterFunctionMapping收到RouterFunctionMapping初始化事件后会再尝试获取一次RouteDefinitionRouteLocator相当于给路由定位器的获取加了一个“事件触发式”的补偿逻辑。这个细节在源码阅读时很容易被忽略但理解了它你就明白为什么某些 bean 在启动早期获取不到并不会影响网关整体启动。2.2 filter 方法入口判断filter方法的开头部分很朴素但每一步都有意义Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { URI requestUrl exchange.getRequiredAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); String scheme requestUrl.getScheme(); if (isAlreadyRouted(exchange) || (!http.equals(scheme) !https.equals(scheme))) { return chain.filter(exchange); } setAlreadyRouted(exchange, true); // ... 转发逻辑 }getRequiredAttribute如果取不到GATEWAY_REQUEST_URL_ATTR会直接抛异常。这意味着如果某些路由没有经过RouteToRequestUrlFilter的处理请求根本走不到这里。实际使用中如果你跳过标准路由配置、用自研方式直接创建 exchange这个异常是你第一个会遇到的问题。而isAlreadyRouted和 scheme 判断放在setAlreadyRouted之前是为了保证“已路由”或“非 http 协议”的请求不会被误打标记。比如一个lb开头的 URI如果LoadBalancerClientFilter因为某种原因没执行到这里 scheme 既不是http也不是https会直接进入后续过滤器链把地址解析问题暴露在更后面。3. 核心转发实现请求头、请求体与 Netty 发送3.1 请求头的“黑名单”处理构造转发请求时NettyRoutingFilter并不是把原始请求头原封不动地搬过去而是做了一次清理。常见的老版本逻辑是把原始HttpHeaders拷贝一份然后删除一组特定头HttpHeaders headers new HttpHeaders(); headers.putAll(request.getHeaders()); headers.remove(HttpHeaders.CONTENT_LENGTH); headers.remove(HttpHeaders.HOST);为什么删除Content-Length因为转发时请求体会经过FluxDataBuffer重新包装body 长度可能在处理过程中发生变化。如果硬带着原来的Content-Lengthbody 实际字节数和声明长度不一致下游服务解析时就会出问题——要么读不完整要么多读了字节导致下一个请求错位。让 reactor-netty 根据实际发送的 body 重新计算编码方式是更稳妥的做法。为什么删除HostHost头表示的是原始客户端访问的域名而不是下游服务的主机名。如果不删下游服务可能会基于这个值做虚拟主机路由或重定向结果转到错误的地方。当然有人确实需要透传原始Host网关也提供了spring.cloud.gateway.preserve-host-headertrue这个开关。开启后NettyRoutingFilter会把原始请求里的Host头原样设置到转发请求中。这个开关在调试一些依赖域名访问的下游系统时非常有用但生产环境默认关闭更安全。这里我个人在排查时发现一个容易踩的坑Spring Cloud Gateway不同小版本对请求头清理的细节有差异比如某些版本还会处理Transfer-Encoding。如果你发现下游收到的头和你预期不一致先检查当前版本的NettyRoutingFilter源码里到底删了哪些头而不是盲目怀疑是网络环境的问题。3.2 请求体只能读一次这个经典问题网关中请求体是一个FluxDataBuffer而Flux是冷流同一份数据流默认只能被订阅一次。第一次订阅消费完后续再订阅就拿不到数据了。这是一个在设计上非常合理的抽象——Web 请求的 body 往往体积不小不可能无限缓存在内存里。但对于网关这种中间层来说这个特性经常变成麻烦前面某个过滤器可能已经读了 body例如做签名校验、参数修改等NettyRoutingFilter再想读的时候body 已经空了。Gateway 社区对这个问题的主流解法是“缓存重放”。ReadBodyPredicateFactory、ModifyRequestBodyGatewayFilterFactory这类内置组件读取完请求体后会把 body 数据以FluxDataBuffer或字节数组的形式保存到 exchange 的 attribute 里键是ServerWebExchangeUtils.GATEWAY_REQUEST_BODY_ATTR。NettyRoutingFilter的转发逻辑中会优先检查这个 attributeFluxDataBuffer body exchange.getAttributeOrDefault(ServerWebExchangeUtils.GATEWAY_REQUEST_BODY_ATTR, Flux.empty()); if (body null || body Flux.empty()) { body exchange.getRequest().getBody(); }所以如果你想在自定义过滤器里读取请求体又不希望影响后续转发必须遵守一个约定读完 body 之后把读到的数据重新写回GATEWAY_REQUEST_BODY_ATTR。否则NettyRoutingFilter拿不到完整 body转发出去的请求就是一个空请求。最常见的表现就是下游接口收到Content-Length: 0或者干脆收不到 POST/PUT 参数。3.3 通过 HttpClient 把请求发出去请求头和请求体都准备好之后真正的转发由一行很 reactor 风格的链式调用完成。核心逻辑是通过HttpClient.request(...)拿到一个可以发送请求和接收响应的流式句柄然后send请求体再responseConnection拿到响应连接。HttpClient是 reactor-netty 提供的异步非阻塞客户端底层直接跑在 Netty 的 event loop 上。这也回答了很多人困惑的问题网关转发的过程中Tomcat 的 Servlet 线程会被释放掉吗答案是会。整个转发过程不占用 Servlet 容器线程Mono的回调会在 Netty 的 I/O 线程上执行。这也是 Spring Cloud Gateway 能支撑高并发的重要基础——如果转发是同步阻塞的网关的线程池早就被慢下游拖垮了。为什么默认用 Netty 而不是WebClient其实 Gateway 也提供了一个WebClientRoutingFilter默认情况下并不生效。选择 Netty 的直接原因在于 I/O 效率和背压控制。reactor-netty 的数据缓冲和 Netty 的 ByteBuf 能无缝配合减少请求体在复制过程中的内存开销。在大文件上传、流式响应场景下这个优势尤其明显。send环节还有一个隐藏细节对于 GET 请求FluxDataBuffer通常是空的这时HttpClient不会发送 body对于带 body 的请求HttpClient会根据 body 总长度决定走Content-Length还是Transfer-Encoding: chunked这也是为什么前面要删掉原始Content-Length——让 Netty 自己决定比我们手动算准确得多。4. 响应写回状态码、响应头与流式输出4.1 writeResponse 做了什么下游服务返回响应后NettyRoutingFilter通过flatMap把HttpClientResponse交给writeResponse方法这一步相当于把下游的响应“翻译”成网关自身的ServerHttpResponse。流程分三步第一步状态码转换。下游的 HTTP 状态码通过response.setStatusCode(...)设置到网关响应对象上。第二步响应头合并。这一步不是简单putAll而是要处理一些特殊情况。最常见的坑是Set-Cookie。HTTP 协议里Set-Cookie是一个需要通过多个同名头传递的字段但在某些 HTTP 客户端实现中多个Set-Cookie头会被合并成用逗号分隔的一个长字符串。如果网关直接把这个合并后的值透传客户端将无法正确解析多个 Cookie。NettyRoutingFilter在这块做了一些兼容处理把逗号分隔的Set-Cookie重新拆开逐个设置到响应头里。如果你在网关后面接了一个自研客户端遇到 Cookie 属性错乱的问题多半就是这一步的兼容逻辑没有生效或者被自定义代码覆盖了。第三步响应体写回。核心逻辑是把下游的FluxDataBuffer直接桥接到网关的response.writeWith(...)。注意这里也是流式的不会等下游响应全部读完再返回给客户端而是边读边写。这就是流式转发的表现客户端可以不间断地收到数据不需要等完整响应体累积。4.2 Content-Length、chunked 与 SSE 流式场景响应写回时Content-Length的处理同样有讲究。如果下游返回的响应头里带了Content-Length并且网关没有对 body 做任何改写理论上可以直接透传。但如果经过了压缩、过滤或修改原始长度已经不准确就必须去掉Content-Length让底层协议用 chunked 或连接关闭来界定边界。NettyRoutingFilter在writeResponse里对Content-Length的保留做了条件判断源码里最直接的表现就是if (clientResponse.responseHeaders().contains(HttpHeaders.CONTENT_LENGTH)) { response.getHeaders().set(HttpHeaders.CONTENT_LENGTH, clientResponse.responseHeaders().getFirst(HttpHeaders.CONTENT_LENGTH)); }这样的代码看起来没问题但在实际场景里一旦响应体被其他过滤器修改过下游传上来的Content-Length就是错误的。你会看到的现象是浏览器一直转圈不结束或者接口返回内容被截断。排查时不要只盯着NettyRoutingFilter更要检查在它之前有没有AddResponseHeader、ModifyResponseBody之类的过滤器改变了 body。SSEServer-Sent Events也是一个高频问题区。SSE 响应是持续不断的文本流不能用普通的Content-Length语义来处理通常依赖Transfer-Encoding: chunked或直接不设置长度。网关在处理这类响应时如果中间某个环节把流式语义弄丢了客户端就会一直拿不到第一个事件。检查目标服务的响应头是否包含Content-Type: text/event-stream再检查网关有没有开对响应缓冲的配置这两步基本能定位大多数 SSE 场景的问题。4.3 超时与异常映射网上很多人问网关转发下游超时了为什么网关返回的是 504这个异常映射逻辑就在NettyRoutingFilter的链式调用里。默认情况下HttpClient 的响应超时由spring.cloud.gateway.httpclient.response-timeout配置决定默认值在很多版本中是 30 秒。如果在这个时间内没有收到下游的响应reactor-netty 会抛出TimeoutExceptionNettyRoutingFilter通过onErrorMap把它包装成ResponseStatusExceptionHTTP 状态码设为 504 Gateway Timeout。这里有个实际经验要分享如果你发现网关总是在固定时间后报 504先确认是不是 response-timeout 太小再确认下游服务本身有没有在处理超时任务。很多时候是下游接了一个很慢的外部服务网关在等待时无法感知下游是死是活只能靠统一的超时兜底。除了响应超时还有一类异常容易被忽略连接池获取超时。HttpClientFactory默认给连接池设置了max-pending-acquisition和acquire-timeout当并发请求量超过连接池承载能力时新请求会排队等待可用连接等不到就抛异常。这类异常表现和响应超时很像但根源完全不同前者是连接数不够后者是下游响应慢。排查时一定要看异常堆栈里的具体类型而不是凭状态码猜原因。5. 实际踩坑与排查技巧实录5.1 目标地址是 “lb://” 但没有被解析我一直强调NettyRoutingFilter只认http和https。如果你看到日志里出现类似Only http and https schemes are supported的提示或者请求进入了过滤器链却被直接放行八成是GATEWAY_REQUEST_URL_ATTR里的地址还是lb://service-a/api这样的服务发现协议而不是真正的http://ip:port/api。正常情况下LoadBalancerClientFilter负责把lb://改写为具体的服务实例地址。什么时候会不生效路由配置里的lb协议拼写不对服务发现中心里没有这个实例或者自定义过滤器在它之前就把 URI 换掉了。排查思路很简单在NettyRoutingFilter入口处打断点或加日志看GATEWAY_REQUEST_URL_ATTR的值是什么。如果还是lb://问题一定出在更前面的过滤器而不是这个转发过滤器。5.2 自定义过滤器读了 body转发变成空请求这是我见过最多的一个坑。很多人为了做参数校验在GlobalFilter里调用了DataBufferUtils.join(exchange.getRequest().getBody())读完以后没有把数据缓存回 attribute结果NettyRoutingFilter转发时拿到的 body 是空的。下游收到请求后解析不到参数返回 400。要解决这个问题不是让你别读 body而是读完以后要“还回去”。一个简单的做法是把读取到的 body 字节包装成新的FluxDataBuffer放到ServerWebExchangeUtils.GATEWAY_REQUEST_BODY_ATTR里。如果一个请求要经过多个过滤器并且每个过滤器都要读 body那最好在第一个读 body 的过滤器里做缓存后续过滤器全部从 attribute 取而不是反复订阅原始请求体。另外一个小技巧如果只是临时调试可以用Logging相关的全局过滤器观察请求体避免自己写过滤器引入二次订阅问题。5.3 响应超时怎么合理调遇到网关下游超时先把超时配置分成两类来看。一类是spring.cloud.gateway.httpclient.response-timeout控制的是等待下游响应体开始返回的时间。另一类是全局的路由超时配置比如spring.cloud.gateway.metadata或RouteDefinition里的超时字段它们在不同版本实现位置不一样。调整response-timeout时要注意这个值影响的是同一HttpClient实例下所有请求。如果你只想给某个慢接口开更长的超时时间思路应该是单独配置路由的 metadata 再在自定义过滤器里覆盖而不是全局调大否则所有请求的失败感知都会变迟钝。超时时间设成-1在某些版本里表示不超时这个配置在研发环境调试长任务时很有用但我建议生产环境永远不要这么干。一旦下游假死连接会被长期占用连接池很快耗尽最后整个网关的转发能力都会瘫痪。5.4 SSL 与 HTTPS 转发不认证如果你的下游服务用的是自签名证书HttpClient默认会校验证书报错通常是SSLHandshakeException或证书路径相关的异常。解决办法不是去改NettyRoutingFilter源码而是通过spring.cloud.gateway.httpclient.ssl这一层的配置去调整信任策略。日常联调环境可以用“信任所有证书”的方式临时绕过生产环境必须配置正确的信任库。这块容易被人忽略的是HttpClient的 SSL 配置是全局生效的不是按路由生效的。如果你的网关同时代理了好几个下游有的用合法证书有的用自签名证书全局“信任所有”会让安全校验形同虚设。更好的办法是按下游服务拆分网关实例或者在路由层面通过自定义的HttpClient工厂做隔离。5.5 链接本地测试时暴露出的端口问题本地调试网关时很多人会遇到“网关能通但转发到 localhost 上的另一个服务就是不行”的怪问题。这通常不是NettyRoutingFilter的锅而是localhost在 IPv6/IPv4 解析、容器网络和本机回环地址上存在差异。reactor-netty 解析localhost可能优先拿到::1而目标服务只监听了127.0.0.1于是连接被拒。遇到这种问题先试着把下游地址从localhost:8081改成127.0.0.1:8081。如果是在 Docker 容器里跑网关还要特别注意容器网络模式。这类问题在网络层表现是 connect refused但在 Java 代码层面往往隐藏得很深。6. 最后分享一点我的体会NettyRoutingFilter源码看起来不长但它是整个网关数据通路的末端节点把前后所有机制都串在一起。读它的时候别只盯着复制粘贴代码要多想想每一步在真实网络环境中的意义为什么要删掉某些请求头、为什么要缓存请求体、为什么响应头要单独处理Set-Cookie。我自己的习惯是把这些坑整理成一张检查清单每接入一个新的下游服务就按清单过一遍。比如先确认 URI scheme 是否正确、再确认有没有过滤器在读 body、再确认下游响应头是否被正确透传。这套流程虽然朴素但比每次出了问题再翻文档高效得多。最后再补一个小技巧如果你在某个版本里发现转发行为和预期不一致优先去对比当前项目里spring-cloud-gateway的具体版本源码网上很多文章都是基于老版本写的字段名和逻辑细节都可能有变化。源码就在本地 maven 仓库里直接跳进去看比搜答案更快也更准确。