YAOTU INSIGHTS

Spring Cloud Gateway WebSocket大帧1009错误:Max Frame Payload Length调整指南

Spring Cloud Gateway WebSocket大帧1009错误:Max Frame Payload Length调整指南
前阵子在一个实时数据推送服务上排查了一个诡异问题前端通过 WebSocket 订阅交易流水网关走的是 Spring Cloud Gateway平时推几百 KB 的 JSON 一直很稳突然有用户反馈“推着推着就断了像被掐了电源一样”。打开浏览器控制台复现WebSocket 的 close 事件里躺着一个1009 状态码reason 写着The decoded text message was too big for the output buffer and the max message size is 65536。第一反应是去改后端 WebSocket 服务的 Max Frame Length改完之后发现根本没起作用绕了一圈才定位到问题出在 Spring Cloud Gateway 这一层。很多人会有个误区以为 WebSocket 是端到端直连网关只做 TCP 转发帧大小限制是后端说了算。实际上Spring Cloud Gateway 对 WebSocket 的处理是“代理 缓冲”它在浏览器和后端之间维护了两条独立的 WebSocket 连接帧大小限制也分成两层默认值都是 64KB。今天就把我这次改造的过程、原理和踩过的坑完整拆一遍内容包括默认帧大小限制到底卡在哪一层修改 Max Frame Payload Length 的三种落地方式配置属性不生效、消息分帧、Nginx 拦截等常见坑修改之后如何做验证和压测这篇文章适合正在用 Spring Cloud Gateway 转发 WebSocket、并且遇到过“大帧传不上去”这类问题的同学内容也会覆盖旧版本网关的兼容做法。1. 现象还原大帧一到WebSocket 就被网关掐断1.1 线上报错长这样先说清楚一个基础结论WebSocket 的 Max Frame Length 限制的是“接收方解码单帧”时的最大长度。发送方可以把数据一帧一帧地发出去但只要某一帧 payload 超过了接收方设置的上限连接就会直接中断。我这次遇到的是文本帧。后端推送的资产快照 JSON 在极端行情下能到 2MB 左右浏览器收到的关闭码是 1009Message Too Bigreason 就是前面提到的那一串max message size is 65536。不同的 WebSocket 实现报错措辞会略有差异但方向一致浏览器 WebSocket 对象触发onclosecode 1009Netty 后端日志出现TooLongFrameException: WebSocket frame length exceeded ...网关侧偶尔出现Close frame status: 1009 Close reason: ...如果你在日志或者浏览器控制台看到这几类信息基本可以断定是帧大小限制被触发了。1.2 为什么默认限制是 64KB这里有个历史渊源。WebSocket 协议里未扩展长度字段的帧其 payload 长度最多能表示 65535 字节也就是 64KB 减 1超出这个范围需要走扩展长度字段。很多框架为了兼容性和安全默认就把限制定在65536 字节再加上一层“够用就行”的保守心态导致这个数字成了行业默认值。64KB 对绝大多数业务消息来说确实足够了聊天、通知、状态同步都远用不满。但一旦开始传输大 JSON、图片 base64、日志快照、音视频片段64KB 就会成为细水瓶口。而且因为是两层限制浏览器到网关、网关到后端往往你只调了后端前面网关那层照样掐你。所以正确的做法不是改完一边就收工而是把整条链路捋清楚。2. WebSocket 过网关的本质一条连接接力两侧各有上限2.1 网关在 WebSocket 转发中扮演了两个角色Spring Cloud Gateway 转发 WebSocket 时核心逻辑在WebsocketRoutingFilter里。它做的事情可以理解成“接力赛”浏览器发起 HTTP Upgrade 请求网关完成 WebSocket 握手此时网关是WebSocket 服务器。网关拿到请求后按路由规则通常lb://service-id找到后端服务地址再以WebSocket 客户端身份发起一条新的 WebSocket 连接。两条连接建立后网关开始双向转发帧。于是就有了两个独立的限制点链路方向网关扮演角色控制参数默认值浏览器 → 网关WebSocket 服务器gateway.server.websocket.max-frame-payload-length65536网关 → 后端WebSocket 客户端gateway.httpclient.websocket.max-frame-payload-length65536也就是说网关既要“接收浏览器发来的帧”也要“接收后端发来的帧”两个接收方向都有各自的解码上限。只调其中一边另一边该断还是断。2.2 四段链路都要过一遍才对如果画成链路图文字描述一下一条消息从浏览器发到后端实际经过的是浏览器发送大帧 → 网关服务器端解码受server.websocket.max-frame-payload-length约束网关解码成功后重新编码并发给后端 → 后端解码受“后端服务自己的帧大小配置”约束后端回推大帧 → 网关客户端解码受httpclient.websocket.max-frame-payload-length约束网关解码后转发给浏览器 → 浏览器解码一般浏览器端的限制远大于 64KB不怎么需要担心我这次踩的坑就是最初只改了后端服务的maxFramePayloadLength链路 2 放开了但链路 1 还被网关卡着浏览器直接收到 1009。后来把链路 1 和链路 3 的网关配置也改了才真正跑通。理解了这层接力关系下面改起配置来就有明确目标了。3. 把限制放开三种具体改法3.1 方案一直接调配置属性推荐Spring Cloud Gateway 在较新版本中提供了两个面向 WebSocket 的配置项分别管服务器端和客户端。以调整到10MB为例spring: cloud: gateway: server: websocket: max-frame-payload-length: 10485760 # 浏览器到网关 httpclient: websocket: max-frame-payload-length: 10485760 # 网关到后端单位是字节10 * 1024 * 1024 10485760。如果你的业务只需要调一个方向可以只保留对应的一段但大多数场景建议两个一起调毕竟大帧往往是双向的。比如上传大 payload 靠链路 1下载大 payload 靠链路 3缺一不可。这条方案最省事不需要写任何 Java 代码改完配置重启网关就生效。唯一需要注意的是版本如果你发现自己项目的GatewayProperties里没有server.websocket这一项说明网关版本偏老需要走方案二。3.2 方案二自定义 WebSocketService覆盖入站限制对于老版本网关或者需要对入站方向做更细粒度控制的场景可以自定义WebSocketServiceBean把它注册进 Spring 容器。Spring Boot 的自动配置发现存在自定义WebSocketService时会用你的实现替换默认的 Reactor Netty 实现。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.socket.server.WebSocketService; import org.springframework.web.reactive.socket.server.support.ReactorNettyWebSocketService; import reactor.netty.http.server.WebSocketServerSpec; Configuration(proxyBeanMethods false) public class GatewayWebSocketConfig { Bean public WebSocketService webSocketService() { WebSocketServerSpec spec WebSocketServerSpec.builder() .maxFramePayloadLength(10 * 1024 * 1024) .build(); return new ReactorNettyWebSocketService(spec); } }如果你的 Spring 版本较老ReactorNettyWebSocketService可能没有接受WebSocketServerSpec的构造函数那退一步直接用整型参数Bean public WebSocketService webSocketService() { return new ReactorNettyWebSocketService(10 * 1024 * 1024); }注意这个 Bean 只解决了“浏览器到网关”这一段的入站限制。网关到后端那一段依然要靠spring.cloud.gateway.httpclient.websocket.max-frame-payload-length配置项这个配置项在相对较老的版本里就已经存在了所以出站方向一般用配置就够了。3.3 方案三别忘了后端服务自己也得跟着调网关放开了不代表万事大吉。后端 WebSocket 服务如果沿用默认配置网关把大帧转过去后端照样会拒绝。后端如果是基于 Spring WebFlux Reactor Netty调整方式和上一节一样自定义WebSocketServiceBean 即可Bean public WebSocketService backendWebSocketService() { return new ReactorNettyWebSocketService(10 * 1024 * 1024); }后端如果是基于 Spring WebSocket 的 STOMP 场景走的配置入口是WebSocketMessageBrokerConfigurerConfiguration public class BackendWebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureWebSocketTransport(WebSocketTransportRegistration registry) { registry.setMaxTextMessageBufferSize(10 * 1024 * 1024); registry.setMaxBinaryMessageBufferSize(10 * 1024 * 1024); } }我见过的不少同学网关这边调完就兴冲冲去测结果后端日志依旧报TooLongFrameException回头才发现后端 WebSocket 服务的限制没动过。整条链路建议做成一个清单按“浏览器 → 网关 → 后端服务 → 网关 → 浏览器”的顺序逐项核对。4. 实操中容易踩的坑4.1 配置属性写进去却没生效配置属性看似简单但我见过几种导致不生效的情况网关版本太老项目里根本没有server.websocket这个属性节点YAML 写上去也只是被忽略不报错、不提示。判断方法是在 IDE 里看自动补全或者启动后通过 actuator 配置端点检查属性是否被解析。只调了一个方向前面反复强调的双向限制这里最容易漏。如果错误是浏览器发大帧导致的那要查的是server.websocket.max-frame-payload-length如果错误是后端回推大帧导致的那要查的是httpclient.websocket.max-frame-payload-length。误改了spring.codec.max-in-memory-size这是普通 HTTP 请求体的内存缓冲上限与 WebSocket 帧解码不是一回事改了它对 WebSocket 大帧无效。不少人以为它是“全局万能缓冲区”结果折腾半天。所以遇到“配置写了但没效果”先确认版本再确认方向最后确认属性名有没有拼错。4.2 帧大小和消息大小不是一回事WebSocket 协议里一个**消息Message可以由多个帧Frame**组成帧与帧之间通过 FIN 标志位衔接。max-frame-payload-length限制的是单个帧的 payload 长度而不是整个消息的总长度。这意味着一个有趣的现象某些语言/客户端在发送超大消息时会在应用层自动拆成多个小帧每个帧都低于限制从而“绕开”帧长度限制。比如浏览器对大型二进制数据可能分片发送某些 WebSocket 库也会按 MTU 或固定大小分帧。而另一些实现比如直接把整个 byte[] 塞给一个发送 API可能只有一个超大帧直接触发限制。这就导致同样大小的数据有些客户端能发成功有些客户端就是失败排查时会很迷惑。我的建议是不要依赖客户端的自动分帧行为直接把限制调到业务需要的值这才是确定性最高的解法。同时也要理解把限制调大只是允许接收更大的单帧并不代表消息总长度不受限——内存层面一个大消息在接收端往往还是要整包缓冲。4.3 上游还有一层 Nginx 或负载均衡如果浏览器不是直接连网关而是经过 Nginx 等反向代理那 Nginx 这一层也要检查。虽然 Nginx 对 WebSocket 帧本身一般不做大小拦截但下面几项会影响到大帧传输确保代理配置里带了 WebSocket 升级需要的头Upgrade: websocket和Connection: Upgrade大帧场景下建议把proxy_buffering off关掉避免缓冲机制带来的额外内存占用和延迟proxy_read_timeout和proxy_send_timeout默认只有 60 秒传输大帧导致长连接空闲时间变长时连接可能被 Nginx 掐断表现为“莫名其妙断开”且关闭码不一定合规我在实践里遇到过网关配置全调好了Nginx 这一层因为proxy_read_timeout60 秒超时断开前端收到的表现还和帧超限时很像都是 1009 之外的非预期关闭。排查这种问题建议从浏览器到后端逐条连接检查不要默认最外层没问题。4.4 调大限制之后内存和并发都得重新算账把限制从 64KB 调到 10MB意味着网关在接收一个大帧时可能瞬间需要为单个连接分配接近 10MB 的缓冲。这不代表一个连接一直占 10MB但高并发下大帧同时涌入内存峰值会非常可观。Reactor Netty 底层用的是 Netty 的池化直接内存Direct Memory这部分不计入常见的 JVM 堆内存监控。很多团队堆内存 2GB 看着很安全却忽略了堆外内存结果 OOM 或者 GC 异常频繁。我的做法是按业务消息真实最大值加 30%~50% 余量来设置不要张口就是 100MB压测时观察堆外内存指标每个大帧连接的内存占用乘以预期并发数估一个粗阈值如果并发特别高优先考虑业务层把大消息拆小而不是一味调大网关限制说到底调大限制是一次对资源配额的重新分配不是改一行配置就完事。5. 用最直接的方法验证真实发一次 2MB 数据5.1 写一个能构造大帧的测试客户端配置改完光看日志“启动成功”是不够的我习惯用一个最小客户端实测。Python 的 websockets 库就很方便import asyncio import websockets async def test_large_frame(): uri ws://localhost:8080/ws # max_size 是客户端接收消息的上限这里也调到 10MB async with websockets.connect(uri, max_size10 * 1024 * 1024) as ws: payload bx * (2 * 1024 * 1024) # 2MB 二进制大帧 await ws.send(payload) echo await ws.recv() print(frecv {len(echo)} bytes) asyncio.run(test_large_frame())如果网关的入站限制还是 64KB这段脚本会在await ws.recv()处抛异常通常表现为收到关闭帧 1009。限制调开之后脚本能正常收到回显。如果是纯前端页面联调直接在浏览器控制台里试也行const ws new WebSocket(ws://localhost:8080/ws); ws.binaryType arraybuffer; ws.onopen () { const data new Uint8Array(2 * 1024 * 1024); data.fill(1); ws.send(data); }; ws.onclose (e) { console.log(close code:, e.code, reason:, e.reason); };限制未调整时你会看到close code: 1009调整后onclose不会触发说明连接正常维持。5.2 从网关日志和后端日志双向确认客户端测通只是第一步最好同时在网关和后端观察日志确认大帧确实经过了完整链路。网关侧如果仍然有TooLongFrameException或 1009 关闭帧优先怀疑配置没生效或者方向不对。后端侧如果出现同样异常说明网关到后端这段还没放开。两边日志都干净才叫真正调通了。一个更严谨的验证方式分别用 500KB、1MB、2MB、5MB 几档大小去发逐步逼近限制值确认阈值边界符合你的配置而不是只测一个“看起来很大”的值。5.3 大帧压测后看一眼连接数与内存功能验证通过后我会再做一轮简单压测模拟二三十个连接同时互发大帧观察网关进程的内存、GC 和 WebSocket 连接数。重点看这几项堆外内存是不是直线飙升Netty 的 Direct Memory 是否接近MaxDirectMemorySize连接是否出现异常断开或握手失败帧传输延迟是否显著劣化如果二三十个并发就扛不住说明配置和资源不匹配需要折中调小限制或者在业务侧对大消息做分片/压缩处理。我自己的经验是把限制调到业务所需最大值之后再压缩 20% 作为最终值既满足需求又留出缓冲比无脑调到 100MB 稳妥得多。最后补一句实用体会这次排查前后折腾了两天最后发现核心就是“链路接力”这四个字。Spring Cloud Gateway 转发 WebSocket 不是简单透传浏览器到网关、网关到后端是两条独立连接帧大小的限制也分布在两个角色上。调整 Max Frame Payload Length 本身不难难的是想清楚链路里有几个闸门。以后再遇到类似问题我会先画一条数据流向图把所有可能限制单帧大小的节点列出来再逐个配置比凭感觉乱改高效得多。