搞网关的人应该都有过这种经历:前端页面连的是wss://,网关路由看着没问题,但连接要么直接握手失败,要么刚建立就断开。排查到最后,往往都是卡在 Spring Cloud Gateway 内置的 WebSocketRoutingFilter 上。这是 Gateway 处理所有ws/wss升级请求的核心过滤器,不管你的业务系统在后端怎么实现 WebSocket,只要流量经过网关,就一定绕不过它。
这篇文章我会从源码入手,把这个过滤器的处理链路拆开讲清楚:它如何识别 WebSocket 请求、如何向后端发起握手、握手成功后怎么透传数据、以及每个关键节点出问题时该怎么排查。内容不只贴代码,还会带上不同版本之间的实现差异和我实际运维中踩过的坑,对正在做网关定制或者排查连接异常的同学会很有帮助。
1. 整体设计:为什么网关要单独内置一个 WebSocket 过滤器
1.1 WebSocket 升级和普通 HTTP 转发不是一回事
先明确一个基础概念:WebSocket 连接不是一条普通的 HTTP 请求。正常情况下,客户端会先发一个普通的 GET 请求,请求头里带上Upgrade: websocket和Connection: Upgrade,服务端如果同意切换协议,会返回101 Switching Protocols。这个“先 HTTP 后升级”的过程,业界一般叫握手(Handshake)。
握手一旦完成,这条 TCP 连接就从请求-响应模式切换成双向长连接模式,后面传输的不再是 HTTP 报文,而是 WebSocket Frame。
正因为有这种特殊性,网关不能拿处理普通 HTTP 的逻辑去处理 WebSocket。普通的 HTTP 转发过滤器(比如老版本的 NettyRoutingFilter 或新版基于 WebClient 的转发过滤器)关注的是“发一个请求,拿回一个响应”,请求发完就结束了。而 WebSocket 需要先完成协议升级,然后长期维持这条连接并双向搬运数据。这两种交付方式本质上就不一样,用生活里的话类比:一个是点外卖,拿了就走;另一个是包月私厨,每天固定上门做菜,中途不能换人也不能断档。
所以 Spring Cloud Gateway 单独实现了 WebSocketRoutingFilter,专门负责 WebSocket 的场景,避免和普通 HTTP 转发逻辑混在一起。
1.2 它在过滤器链里的执行位置和执行顺序
Spring Cloud Gateway 的过滤器分为两类:GlobalFilter(全局过滤器)和 GatewayFilter(路由过滤器)。WebSocketRoutingFilter 实现的是GlobalFilter和Ordered接口,而且它的 order 是Ordered.HIGHEST_PRECEDENCE,也就是所有过滤器里优先级最高的那一档,排在整个过滤器链的最前面。
这个顺序不是随便定的,而是有意为之。因为 WebSocket 请求一旦握手成功后,连接就从 HTTP 变成了长连接,后续不应该再被当作普通 HTTP 请求去转发。把它的优先级放到最高,可以让它在其他 HTTP 转发过滤器执行之前就把请求“接走”。
从源码逻辑上看,filter()方法里第一步就是判断当前请求是否属于 WebSocket 升级:
@Override public Mono<Void> filter(ServerWebExchange exchange) { if (!WebSocketService.isWebSocketUpgrade(exchange.getRequest())) { return Mono.empty(); } // ... 后续处理 WebSocket 升级 }判断不通过的请求直接返回Mono.empty(),什么都不做,继续走后面的过滤器链。只有命中升级条件的请求才会进入这个过滤器自己的处理逻辑。理解了这个入口,再解读后面的源码就不容易迷路。
2. 源码拆解:从请求进入 filter() 到后端连接建立
2.1 入口判断:什么样的请求会走进这个过滤器
WebSocketService.isWebSocketUpgrade()判定条件其实并不复杂,核心就是检查请求头:
Upgrade头是否为websocketConnection头是否包含Upgrade标记- 请求方法通常为 GET(WebSocket 握手标准要求使用 GET)
这里有个容易被忽略的点:请求的 URI scheme(ws或wss)并不是唯一的判断依据。哪怕 URI 写成http://xxx,只要请求头满足上面几个条件,网关也会把它当成 WebSocket 升级请求来处理。反过来也一样,URI 是ws://但请求头不满足,也不会进入这个过滤器。
我见过有人把 HTTP 和 WebSocket 的识别理解成“看 scheme”,这是不对的。网关在源码里更依赖协议头来做判定,因为 HTTP 请求完全可能带着 Upgrade 头,而实际业务要的就是一个长连接。
2.2 握手请求的组装与校验
确定请求是 WebSocket 升级之后,过滤器会进入真正的升级处理逻辑。这一段的源码实现是:
return this.webSocketService.handleRequest(exchange, new WebSocketHandler() { @Override public Mono<Void> handle(WebSocketSession session) { // 连接建立成功后的业务处理 return Mono.empty(); } });核心对象是WebSocketService,默认实现是HandshakeWebSocketService。它会取出客户端握手请求里的关键信息,组装成服务端握手所需的参数,包括:
Sec-WebSocket-Protocol:子协议列表,后端要根据这些子协议选择一个响应的协议Sec-WebSocket-Extensions:扩展列表,例如压缩扩展permessage-deflateSec-WebSocket-Version:协议版本号Origin:来源校验信息
这些头部信息并不是简单透传给后端就完事了。网关作为反向代理,需要在此刻同时扮演两个角色:对客户端是服务端,对后端是客户端。它要在两边就协议版本、扩展、子协议达成一致,握手才能成功。
有一段代码如果认真看源码会发现很关键:HandshakeWebSocketService内部会通过RequestUpgradeStrategy来实际发起升级。旧版本默认使用的是基于 Reactor Netty 的ReactorNettyRequestUpgradeStrategy,新版则使用 Spring WebFlux 自带的 WebSocketClient 实现。但无论底层怎么换,逻辑流程相对稳定。
2.3 建立连接的底层动作
握手请求组装好之后,Gateway 会作为 WebSocket 客户端,主动向后端服务器发起连接。这个动作在不同版本里的实现路径不一样:
在 Spring Cloud Gateway 3.x 时代,底层走的是 Reactor Netty 的 HttpClient,调用的是其websocket()方法;到了 4.x 时代,网关把底层 HTTP 客户端换成了 Spring WebFlux 的 WebClient,对应的 WebSocket 实现也切换过去。虽然底层 API 换了,但做的事情没有变:向后端发送一个带 Upgrade 头的 HTTP 请求,等待后端返回 101。
这个阶段有几个细节值得关注:
- 整个握手过程是异步的,通过 Reactor 的
Mono串联 - 握手成功后,网关会把两端连接“对接”起来,形成一个双向数据管道
- 如果后端没有返回 101,或者连接被拒绝,网关会向客户端返回 502 Bad Gateway
我之前遇到过一个场景:后端服务连接池配置太小,握手高峰期连接全部被占满,网关就会偶尔出现 502。一开始还以为是网关本身出了问题,后来一查,是后端没有及时释放空闲的 WebSocket 连接导致的,网关只是把后端的失败如实反馈给了客户端。
2.4 响应回传与状态标记
握手完成后,网关还需要把后端返回的响应状态、响应头等信息回传给客户端,让客户端感知到“协议升级成功”。同时,网关会在ServerWebExchange中打上标记,确保这条连接后续不会被其他 HTTP 过滤器二次处理。
这里有一个源码常量值得留意:ServerWebExchangeUtils.CLIENT_RESPONSE_HEADER_NAMES,它保存了需要透传给客户端的响应头名称列表。不是后端返回的所有响应头都会被原样转发,网关只挑了一部分它认为必要的响应头进行回传。如果你在后端自定义了一种握手响应头,并且希望客户端能看到,就要考虑用其他方式显式处理。
很多人在排查问题时没注意到这一点,总以为是后端没返回某个头,实际上后端返回了,只是网关没透传而已。
3. 核心代码逐段精读:容易被看走眼的版本差异
3.1 老版本(3.x)的关键实现
看 Spring Cloud Gateway 3.x 的源码,WebSocketRoutingFilter的核心调用链大致是:
this.webSocketService.handleRequest(exchange, new WebSocketHandler() { @Override public Mono<Void> handle(WebSocketSession session) { // 记录连接建立状态 exchange.getAttributes().put(WebSocketRoutingFilter.WEB_SOCKET_SESSION_ATTRIBUTE, session); return Mono.empty(); } });而HandshakeWebSocketService.handleRequest()内部,会先构造一个DefaultHandshakeRequest,再调用RequestUpgradeStrategy.upgrade()。ReactorNettyRequestUpgradeStrategy在升级过程中会使用 Reactor Netty 的HttpClient建立到后端的连接。
这里面有个很隐蔽的细节:DelayedWebSocketHandler。源码里并没有立刻把业务WebSocketHandler交给底层连接,而是先包了一层延迟处理器。为什么要延迟?因为网关要等后端握手成功、真正拿到WebSocketSession之后,才能调用业务handle()方法。如果后端连接都还没建立成功,就提前触发业务逻辑,后续很容易出现空指针或者数据写不进去的问题。
3.2 新版本(4.x)的实现变化
到了 4.x 版本,整体实现出现了比较大的调整。主要变化是底层的 HTTP 客户端切换,从 Reactor Netty 的 HttpClient 切换到了 WebClient,WebSocket 的客户端以及升级策略也随之更换。
不过核心思路依然保持一致:
- 识别 Upgrade 请求
- 组装握手参数
- 向后端发起升级
- 成功后对接双向通道
- 失败时返回错误码
我建议读源码的同学不要只盯着某一个版本的实现,而是要抓住这条主线。不同版本差异主要集中在底层调用 API 上,业务逻辑的骨架是稳定的。版本升级的时候,不能用“旧代码怎么写的”去生搬硬套,要去看新版对应的类和方法。
3.3 版本差异对照表
| 对比项 | 3.x 版本 | 4.x 版本 |
|---|---|---|
| 底层 HTTP 客户端 | Reactor Netty HttpClient | WebClient |
| 升级策略实现 | ReactorNettyRequestUpgradeStrategy | 基于 WebClient 的升级策略 |
| WebSocket 客户端 | Reactor Netty 自带 | Spring WebFlux WebSocketClient |
| 网络超时参数位置 | HttpClient 配置中 | WebClient 配置中 |
| 核心过滤器逻辑 | WebSocketRoutingFilter + HandshakeWebSocketService | 类似,底层 API 变化 |
这个对照表是我维护网关过程中整理出来的。升级版本后最直观的影响就是自定义超时参数的位置变了,原来配httpClient的地方可能需要改到新的 WebClient 配置里。这个坑以前踩过一次,所以特意提醒一下。
4. 实战中绕不开的五个坑
4.1 子协议不匹配导致握手失败
WebSocket 握手时,客户端会通过Sec-WebSocket-Protocol声明支持哪些子协议,服务端需要选择一个它支持的协议返回给客户端。如果网关转发的过程中没有正确处理子协议,前后端就可能出现握手失败或者连接建立后协议不一致的问题。
典型场景是:后端只支持chat子协议,客户端声明了chat, superchat,后端应该返回chat。但如果网关在转发时修改了子协议列表,或者后端看到多个协议时选择了错误的一个,客户端校验响应头时就会不匹配。
排查方法很直接:抓包看握手阶段客户端发了什么、后端回了什么,对比一下两边的手协议头是否对得上。网关在这一层的职责是尽量原样透传,不要自己在中间加戏。
4.2RewritePath与 WebSocket 的组合问题
WebSocket 路由配置里,很多人习惯用Path断言加RewritePath过滤器,比如把/ws/**重写成/**再转发给后端。原理上没问题,但要特别注意:RewritePathGatewayFilterFactory属于路由过滤器,执行顺序在 WebSocketRoutingFilter 之后。
也就是说,WebSocketRoutingFilter 在处理请求时,看到的是原始路径,它要根据路由配置里重写后的 URI 去连接后端。如果路径没被正确重写,或者路由的 URI 配置不对,网关会拿着原始路径去连后端,后端匹配不到对应的 WebSocket 端点,握手就会失败。
一段典型的配置是这样的:
spring: cloud: gateway: routes: - id: ws_route uri: ws://backend-server:8080 predicates: - Path=/ws/** filters: - RewritePath=/ws/(?<segment>.*), /${segment}这里有个细节:uri必须写成ws://或wss://开头。如果uri写成http://,网关大概率会把它当成普通路由处理,WebSocket 握手根本走不通。
我遇到过一个案例,路由的uri写的是lb://backend-service,WebSocket 请求进来后一直报 404,后端日志里压根没看到请求。最后发现是负载均衡解析出来的实例列表地址和实际服务端点对不上,导致握手请求发错了地方。
4.3 负载均衡与会话保持
如果网关配置了lb://serviceId这种形式的路由,网关会在每次连接时动态选择一个后端实例。对普通 HTTP 请求来说这没有任何问题,但对 WebSocket 长连接来说,这隐含着一个需求:同一客户端的连接要尽量绑定到同一个后端实例上,因为后端可能维护了会话状态。
实际部署中,如果后端是单机,这个问题不存在;如果是多实例集群,建议在网关层面调整负载策略,或者在后端实现里通过无状态会话适配,否则就可能出现“连接建立成功,但推送数据时发到了另一个实例”的怪异问题。
网关本身不提供 WebSocket 场景下的粘滞会话支持,通常的做法是配合上层负载或注册中心对实例做哈希绑定,或者让后端把状态外置。这个要结合自己的基础设施来决定方案,没有银弹。
4.4 空闲连接与心跳保活
WebSocket 连接建立后,如果长时间没有消息传输,中间链路(比如防火墙、负载均衡器)可能会把这条连接当成死连接回收掉。这是 WebSocket 场景下最常见的“灵异断连”问题:客户端和后端都觉得连接还在,实际上链路中间已经断了。
网关本身不主动发心跳,所以心跳保活一般由客户端负责。常见做法是客户端每 30 秒发一个ping帧,服务端回pong。如果业务层没有天然的定期消息,可以加一个定时任务来发心跳。
后端的空闲超时参数也要注意。有些后端框架默认空闲 60 秒就关闭连接,如果心跳间隔超过这个时间,连接照样会断。心跳间隔一定要小于后端和中间链路的最小空闲超时时间,这一点需要在压测环境里实际验证。
4.5 抓包与日志排查
排查 WebSocket 问题,我最常用的工具是抓包。网络层抓包(比如 tcpdump )可以看到握手请求是否发出、响应的状态码是什么、有没有重置连接。应用层则看网关日志。
Gateway 相关的日志默认级别不够细,可以临时把日志调成 DEBUG:
logging: level: org.springframework.cloud.gateway.filter.WebSocketRoutingFilter: DEBUG org.springframework.web.reactive.socket: DEBUG调完日志后复现一次握手,重点看有没有Upgrade相关的 INFO 或 DEBUG 输出。抓包和日志结合起来,基本能定位九成以上的问题。
5. 常见问题速查与调优建议
日常运维中,可以把 WebSocket 场景的常见问题整理成一个速查表,排查的时候对着看,效率会高很多:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 握手直接失败 | 子协议不匹配、版本不支持 | 抓包对比客户端与后端的协议头 |
| 连接建立后立即断开 | 后端空闲超时太短、心跳不足 | 检查后端超时参数,调小心跳间隔 |
| 偶尔出现 502 | 后端连接池不足、握手超时 | 看后端连接数和网关超时配置 |
| 请求进了网关但后端没收到 | 路由 uri 写错、RewritePath 没生效 | 检查路由配置和日志输出 |
| 多实例下连接错乱 | 后端会话与实例绑定 | 粘滞会话或后端状态外置 |
| 自定义握手响应头丢失 | 网关只透传部分响应头 | 用显式方式设置响应头 |
关于调优,有一个参数需要根据业务实际调整:maxFramePayloadLength。这个值限制了 WebSocket 单帧数据的最大大小,默认值往往偏保守。如果业务要传输较大的二进制帧(比如图片流),就需要调大,否则帧会被截断或直接报错。
另一个容易忽略的参数是握手超时时间。网关向后端发起握手时,如果后端响应慢,可能超过网关的默认超时时间。这个值不建议盲目调大,否则在高并发下会堆积大量等待中的连接,拖垮网关本身。合理的做法是给握手超时设置一个远小于业务读取超时的值,让失败的连接快速释放资源。
还有一个经验是关于 Reactor Netty 的线程模型。WebSocket 长连接会长期占用连接通道,网关的线程池配置和普通 HTTP 网关不一样,并发连接数很高时需要重点关注内存占用。我见过有人把网关部署在 2GB 内存的容器里,同时撑几千个 WebSocket 连接,结果频繁 GC,最后只能扩容。长连接场景下,网关的资源规划要按“并发连接数”来估算,而不是按 QPS。
我自己的排查习惯是:先抓包确认握手阶段,再开 DEBUG 日志确认网关内部行为,最后看后端日志确认连接是否真正建立。按这个顺序来,基本没有定位不了的问题。
最后再分享一个小技巧:如果你在网关层做了鉴权之类的逻辑,不要试图在 WebSocket 连接建立之后再去修改握手响应头,那个阶段已经来不及了。正确的做法是在握手之前通过专门的过滤器判断,该拒绝的直接终止后续流程。这个看起来不起眼的顺序问题,实际项目中坑过不少人。