CentOS下Spring WebSocket与SockJS连接失败排查与优化实践
1. 问题现象与整体排查思路先说一下我这边的现场。项目用的是 Spring 后端前端页面在 Chrome 和 Node 环境下开发调试一切正常结果部署到 CentOS 服务器上之后浏览器控制台就开始报各种连接错误。最典型的报错是下面几种来回换WebSocket connection to ws://xxx/info failed: Error during WebSocket handshake: Unexpected response code: 404紧接着 STOMP.js 那边也刷出来Whoops! Lost connection to http://xxx/info有的机器上还会出现SockJS polling connection failed Firefox can establish a connection to the server wss://xxx/websocket.如果你的项目也在用 Spring 的 WebSocket SockJS 做消息推送同时前端选的是 STOMP.js 这套组合那这篇文章基本就是按着我踩坑的路径来的。我会把整个排查过程拆开讲从最基础的环境差异讲起到 Nginx 配置、防火墙策略、内核参数调优最后到 STOMP.js 前端的重连机制全部过一遍。先说结论免得你越看越急大部分 CentOS 下的 SockJS 报错不是代码的问题而是环境对 WebSocket 长连接“不友好”导致的。所谓不友好集中在三个方面——网络层的代理和防火墙把 Upgrade 请求掐了、KeepAlive 超时把空闲连接断了、TCP 层的内核参数没调整导致连接回收太慢。下面我会按这个方向一层一层拆。2. SockJS 和 STOMP.js 原理拆解2.1 SockJS 的降级机制很多人报错之后首先怀疑的是代码写错了其实在排代码之前得先搞清楚 SockJS 到底做了什么。SockJS 本质上是一个 WebSocket 的“兼容层”。它的目标很朴素让浏览器不管在什么年代、什么网络环境下都能和一个支持 STOMP 的服务端进行消息通信。如果浏览器原生支持 WebSocketSockJS 就走websocket通道如果浏览器太老或者中间的网络设备把 WebSocket 的 Upgrade 握手掐断了SockJS 就会降级成 HTTP 轮询、HTTP 流等方式继续工作。这个降级机制的代价是当真正走的是 HTTP 轮询时连接状态变得更复杂报错的界面更难看。所以你会发现一个现象本地开发时用的是ws://直连异常少部署到 CentOS 后经过 Nginx、防火墙层层转发WebSocket 的 Upgrade 在某一层被中断SockJS 就不得不退化到 polling而 polling 模式下各种 404、403、超时问题会集中爆发。用生活化的类比来说SockJS 像一个“会选路的司机”WebSocket 是高速公路HTTP 轮询是普通国道。你本地开发时高速直达到了 CentOS 服务器上高速公路入口被收费站堵了司机被迫走国道一路上红绿灯、修路、限高杆都来了。你看到的报错不是司机不会开车而是路况变了。2.2 STOMP.js 在连接建立阶段的动作STOMP.js 是 STOMP 协议的 JavaScript 客户端它依赖 SockJS 提供一个可用的传输通道。连接建立时STOMP.js 会经历三个阶段第一阶段探测。SockJS 会先向后端发送一个/info请求用来探测服务器支持的传输方式并获取 session 信息。这个请求在 CentOS 下的典型表现是一个正常的 HTTP 请求由于不涉及 Upgrade所以经过 Nginx 和防火墙时通常不会太惨但是如果后端地址路径配置不对这里就会直接 404。第二阶段协商传输通道。拿到/info的响应后SockJS 根据服务器返回的传输方式列表结合浏览器的能力选一条最优通道。如果浏览器支持 WebSocket就会发起一次带有Upgrade: websocket头的请求。这一步是 CentOS 环境下的重灾区一旦中间层的 Nginx 没有配置标准的 Upgrade 转发规则或者防火墙拦截了非标准端口的数据包这个请求就会失败。第三阶段进入 STOMP 协议层。传输通道建好之后STOMP.js 会发送CONNECT帧以及后续的SUBSCRIBE、SEND、DISCONNECT等帧。这个阶段主要涉及认证、订阅地址是否正确、消息格式是否合法。结合上面的三个阶段你可以对照自己的报错时间点来判断问题出在哪一层如果是浏览器一打开就立刻出现握手失败多半是第二阶段的传输层问题如果握手过了、但客户端收到ERROR帧那才是 STOMP 层的问题。2.3 为什么偏偏在 CentOS 上报错本地开发环境几乎不会出问题一上 CentOS 就“原形毕露”核心原因有三个。第一个是CentOS 服务器默认的网络策略更严格。开发时你的请求是localhost或者内网 IP 直连不走防火墙的复杂规则服务器上则要面对firewalld或iptables默认 zone 的策略、端口放行与否直接影响连接能否建立。第二个是生产环境几乎必有反向代理或网关。你不太可能把 Spring Boot 的 8080 端口直接暴露出去至少要套一层 Nginx 做域名转发或负载均衡。Nginx 对普通 HTTP 请求的转发很成熟但对 WebSocket 的 Upgrade 支持需要显式配置少一个请求头、少一句超时设置都会引发连锁反应。第三个是TCP 长连接在 Linux 服务器上的生命周期管理。WebSocket 长连接可能需要维持几分钟、几小时甚至几天。Linux 内核参数比如tcp_keepalive_time、tcp_fin_timeout和网络设备的空闲连接回收策略决定了连接能不能长期存活。CentOS 7/8/9 各版本默认参数还不一样所以你会看到同样的代码CentOS 7.9 上没事CentOS 9 上频繁掉线。理解了上面这几点再回头看报错思路就会清晰很多下文中所有问题排查与解决本质上是让服务器网络环境“更宽容”地支持长连接。3. 四类典型报错与对应解法3.1 连接类报错握手失败、404、403这一类是出镜率最高的报错信息长下面这样WebSocket connection to ws://yourdomain.com/ws/info failed: Error during WebSocket handshake: Unexpected response code: 404404 的迷惑性很强因为你会天然认为是后端路径写错了。但请注意404 出现在握手阶段也就是浏览器发出的请求已经到达了某台服务器但服务器不认识这个路径或者不认为这是一次 WebSocket 握手请求。常见原因 1Nginx 反向代理没有配置 WebSocket 升级头。如果你用的是 Nginx 1.3 之后的版本代理 WebSocket 时必须显式加上下面的配置location /ws/ { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }重点在proxy_set_header Connection upgrade这一行。普通 HTTP 请求的 Connection 头通常是keep-alive但 WebSocket 握手的 Upgrade 请求必须把这个头改成upgradeNginx 才能把请求正确转发给后端。这一行缺失时后端收到请求后不知道你想升级协议直接按普通请求处理返回 404 或者 400。常见原因 2后端路径配置和 SockJS 端点不匹配。Spring 端的配置类似Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws) .setAllowedOrigins(*) .withSockJS(); }浏览器端的连接地址如果是new SockJS(/ws)那走起来就是http://host/ws和ws://host/ws/info。如果你在 Nginx 里只把/ws这个精确路径转发到后端但/ws/info没匹配上 location就会出现 404。这属于最容易踩的 Nginx location 通配问题。建议 Nginx 的 location 写成前缀匹配而不是精确匹配location ^~ /ws/ { ... }这样/ws、/ws/info、/ws/websocket全部都会落入这个 location。常见原因 3SockJS 跨域限制。如果你把前端页面和后端服务部署在不同的域名或端口下还会出现 403。SockJS 请求会带上Origin头Spring 后端设置了setAllowedOrigins(*)后一般都能放行但 Nginx 层如果开启了跨域拦截也会提前把请求挡回去。排查时先在浏览器 Network 面板里看返回的响应头确认有没有Access-Control-Allow-Origin。检查顺序建议是浏览器 Network 面板 →/info请求的状态码和响应体 → 如果成功继续看 WebSocket 握手请求的状态码 → 如果握手失败再看 Nginx 的error.log。实测下来80% 的 404 都是 location 匹配和 Upgrade 头这两个原因这两个检查一遍基本能定位。3.2 协议解析类报错Unexpected token、Frame validation failed这一类报错的信息看起来像Whoops! Lost connection to http://yourdomain.com/ws/info Unexpected token in JSON at position 0或者STOMP: Unhandled ERROR frame received.这类报错比 404 更隐蔽因为连接本身“建立”了但拿到的数据不是你预期的东西。出现Unexpected token in JSON时说明 SockJS 期望拿到 JSON 格式的响应结果收到了 HTML 内容。这个 HTML 往往是网关、防火墙或 Nginx 的报错页。我之前遇到过一个很典型的场景SockJS 走 HTTP 轮询时请求到达了 Nginx 的后端转发但后端 Spring Boot 处理/info接口时抛了一个异常异常页被 Nginx 当成 200 返回了SockJS 解析时直接炸掉。另一种常见情况是TCP 层传输被中间设备截断导致 SockJS 收到半个数据包。在 WebSocket 层比较少见因为 WebSocket 协议本身有帧机制但在 HTTP 轮询xhr-streaming/xhr-polling模式下SockJS 对数据完整性依赖较高。排查手段先打开浏览器的 Network 面板找到info这个请求看响应体是不是一段 JSON。正常应该是类似{websocket:true,origins:[*:*],cookie_needed:false,entropy:123456}如果看到的是html...那就说明请求压根没有到达真正的后端或者后端抛了个 HTML 错误页。此时看 Nginx 日志和 Spring Boot 日志两个方向并行。如果响应体是正常的 JSON那就要看后续的xhr或eventsource请求返回的数据。SockJS 在轮询模式下对环境比较敏感有安全软件或 WAF 会对请求体做内容检查导致返回内容被改写。这种情况比较少见但如果前后端都在内网安全软件的 HTTP 过滤也可能造成。STOMP 层的Frame validation failed则多半是自定义的 STOMP 帧格式不对比如发送消息时payload里有非法字符、content-length和实际长度不一致。这个跟 CentOS 环境关系不大倒是跟前端代码里有没有正确设置content-type有关。3.3 安全类报错401、403、跨域拦截403 在 CentOS 环境里很常见原因分两类一类是防火墙限制来源 IP另一类是 Nginx 或后端的跨域校验拦截。如果请求来自你自己域名的前端页面后端也配置了setAllowedOrigins(*)仍然报 403那大概率是防火墙策略导致的。用curl在服务器本机测试后端接口如果本机正常、外网访问 403十有八九是firewalld的规则只放行了 80/443 端口但 Nginx 转发到后端时网络不通。检查 firewalld 的常用命令# 查看当前默认 zone 和放行规则 firewall-cmd --list-all # 临时放行一个端口重载后失效 firewall-cmd --add-port8080/tcp # 永久放行一个端口 firewall-cmd --permanent --add-port8080/tcp firewall-cmd --reload401 则通常是 Token 认证失败。STOMP.js 连接时会在CONNECT帧里带上认证信息如果后端在 WebSocket 握手阶段就校验 Token那么 Token 从环境变量、配置文件读取失败也会在这里报错。检查方法就是把连接 URL 的 Token 参数和浏览器 Network 面板里实际发出的 Token 做对比。这个阶段有个容易忽略的问题Nginx 在转发时如果设置了proxy_set_header Authorization 会把认证信息清空。有些团队的安全策略比较严格反向代理默认清空所有敏感头但 WebSocket 握手本身需要Authorization头来认证。到头来不是后端校验失败而是头根本没传到后端。3.4 超时掉线类报错连接频繁断开、心跳超时这类问题不会在一开始出现更多是你页面挂了一会儿之后消息突然不推送了或者控制台刷Whoops! Lost connection to http://yourdomain.com/ws/infoSockJS 和 STOMP.js 是有心跳机制的。客户端会定期发送心跳帧如果一段时间内没有任何数据帧连接就被认为已经失效。默认的 STOMP 心跳配置在 Spring 端大概是registry.setHeartbeatValue(new long[] {10000, 10000});意味着客户端和服务端每 10 秒发送一次心跳。但是如果 Nginx 的proxy_read_timeout默认设置为 60 秒而心跳间隔也是 10 秒理论上连接不会断。真正的问题是心跳帧可能被网络设备缓存或吞掉。排查手段是抓包在服务器上用tcpdump抓指定端口的流量看 10 秒一次的心跳包是否真的到达后端如果tcpdump能看到但应用日志里没有收到心跳帧就需要看 Nginx 配置。Nginx 里调整相应超时的配置proxy_connect_timeout 75s; proxy_send_timeout 600s; proxy_read_timeout 600s;proxy_read_timeout的意思是如果在这个时间内后端没有任何数据传递到 NginxNginx 就会主动断开连接。所以这个值至少要大于 STOMP 心跳间隔的两倍。比如心跳是 10 秒保守起见设成 60 秒以上。实测 600 秒是比较稳妥的取值。另外一个是操作系统的 TCP KeepAlive 参数。Linux 内核默认的tcp_keepalive_time是 7200 秒意思是连接空闲 2 小时后才开始发探测包。对 WebSocket 这种长连接来说这个值有点太长了。可以调小但要注意这并不直接等同于“心跳”只是一个 TCP 层的保活机制。# 查看当前值 sysctl net.ipv4.tcp_keepalive_time sysctl net.ipv4.tcp_keepalive_intvl sysctl net.ipv4.tcp_keepalive_probes # 临时修改为 60 秒开始探测、间隔 10 秒、探测 6 次 sysctl -w net.ipv4.tcp_keepalive_time60 sysctl -w net.ipv4.tcp_keepalive_intvl10 sysctl -w net.ipv4.tcp_keepalive_probes6注意这个调整是全局的会影响服务器上所有的 TCP 连接需要结合业务量来评估不能盲目调得太激进。4. 基于 CentOS 的完整排查与优化实战4.1 先在客户端抓包定位遇到 SockJS 报错我建议第一步不要直接去改代码先把浏览器开发者工具的 Network 面板打开筛选WS、INFO和XHR三类请求按时间顺序拉出下面这些键信息请求URL请求类型状态码响应体或响应头关键内容http://host/ws/infoXHR200JSON 响应确认内容ws://host/ws/websocketWS101 或 4xx101 是成功4xx 要看状态码http://host/ws/xhr_streamingXHR200轮询回包是否完整这一步的核心目标是搞清楚 SockJS 到底选择了哪条传输通道。如果它放弃了 WebSocket、退化成 XHR polling那么连接建立过程中的某个节点必然是 Upgrade 失败了。给个免费的排查路径先看/info的响应里websocket字段是否为true如果是就说明后端打开了 WebSocket。再看浏览器发起的握手请求的状态码101 代表成功404/403/502 分别对应不同问题。4.2 Nginx 配置逐项检查如果你确认客户端发出的请求没问题问题出在 Nginx那参照下面的配置模块逐项核对map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 443 ssl; server_name yourdomain.com; # SSL 证书配置略 location /ws/ { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # 长连接超时调整 proxy_connect_timeout 75s; proxy_send_timeout 600s; proxy_read_timeout 600s; # 透传真实 IP 和协议方便后端日志排查 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 如果后端对来源敏感也可以设置 Host proxy_set_header Host $host; } }这里用到map是因为$http_upgrade在普通 HTTP 请求里是空值此时Connection头应该用close而不是upgrade避免影响普通请求。这种写法是社区里比较标准的做法。改完配置别忘了两件事nginx -t systemctl reload nginxnginx -t是用来检查配置语法的不过多解释但强烈建议每次改完都执行一次语法错误会导致 reload 失败服务直接不响应比不改还严重。4.3 防火墙与网络策略调整CentOS 7/8/9 默认防火墙一般是firewalld。检查步骤systemctl status firewalld firewall-cmd --list-all如果你的 WebSocket 服务映射在 80/443 之后理论上 80/443 端口放行就够用。如果调试阶段用 8080 端口直连需要放行 8080firewall-cmd --permanent --add-port8080/tcp firewall-cmd --reload如果服务器用了云厂商的安全组策略还要去云控制台检查安全组规则某些云环境 CentOS 防火墙规则是“放行所有流量”的但安全组里默认只放行 22、80、443。所以最终要“服务器本地防火墙 云安全组”双端确认。一个更接近实战的快速验证法在本地非服务器上执行telnet yourserver.com 8080如果无法连通大概率是防火墙或云安全组的问题如果能连通继续用下面的方法测 HTTP 请求。4.4 SSH 隧道法验证后端 WebSocket这里分享一个实用小技巧很多老手都在用当你想确认 Nginx 配置是否影响 WebSocket 时可以绕开 Nginx直接建立一条 SSH 隧道访问后端 Spring Boot 端口。ssh -L 8080:localhost:8080 useryourserver.com然后在本地浏览器里访问http://localhost:8080/ws/info如果这个地址下 WebSocket 一切正常就说明后端完全没问题问题 100% 出在 Nginx 或外部网络层。这招能帮你省下大量猜测的时间。4.5 内核参数调优与连接状态观察如果连接频繁出现网络层中断而不是应用层主动断开要考虑操作系统层面的 TCP 参数。下面这些参数是我在 CentOS 7.9 和 CentOS 9 上实测有效的一组cat /etc/sysctl.conf EOF # TCP KeepAlive 调整空闲 120 秒后开始探测 net.ipv4.tcp_keepalive_time 120 net.ipv4.tcp_keepalive_intvl 10 net.ipv4.tcp_keepalive_probes 6 # 减少 TIME_WAIT 回收时间 net.ipv4.tcp_fin_timeout 30 net.ipv4.tcp_max_tw_buckets 10000 # 文件句柄调大避免高并发连接时 fd 不够 fs.file-max 1000000 EOF sysctl -p另外还要检查用户的进程文件句柄限制ulimit -n如果输出是 1024 这种很小的值你的 WebSocket 并发连接数一高进程就会因为“too many open files”而拒绝新连接。可以在/etc/security/limits.conf中临时调高 Java 进程的 nofile 限制或直接使用 systemd 服务的 LimitNOFILE 配置。观察连接状态的命令ss -s ss -tunap | grep 8080ss -s显示系统层面 TCP 连接汇总ss -tunap | grep 8080能看出具体有多少 ESTABLISHED、TIME_WAIT、CLOSE_WAIT。如果TIME_WAIT特别多说明连接在被频繁地关闭重建。这个现象通常意味着代理层超时或心跳机制没生效。5. 常见问题速查表为了方便实际干活时对照我把上面提到的问题集中整理成一张表你可以先用这张表定位方向再针对性地看具体配置。现象可能原因定位方法解决方案握手阶段 404Nginx location 不匹配检查 Nginxerror.log改用^~ /ws/前缀匹配握手阶段 404后端路径和前端不一致对比浏览器请求 URL 与后端注册端点修正端点路径握手阶段 400Nginx 缺少 Upgrade 头检查 Nginx 配置段增加proxy_set_header Upgrade $http_upgrade握手阶段 403跨域拦截检查响应头Access-Control-Allow-Origin后端放开跨域或前端走同源代理握手阶段 403防火墙限制服务器本机curl对比外部curl放行端口或调整安全组Unexpected token 响应不是 JSONNetwork 面板看响应体检查后端异常页面与代理层连接建立后频繁断开Nginxproxy_read_timeout默认 60s查看 Nginx 日志观察断开时间点调大到 600s连接建立后频繁断开STOMP 心跳丢失tcpdump抓包确认检查中间网络设备或关闭代理缓冲200 个连接之后无法建立打开文件数限制ulimit -n调高系统最大文件句柄数CLOSE_WAIT大量堆积服务端程序未及时关闭连接ss -tunap观察状态排查后端代码或线程池配置6. 工程化方案让 SockJS 连接在 CentOS 上更稳问题解决之后如果想做得更稳下面几件事我建议直接做成规范。第一生产环境尽量只走 WebSocket 通道。SockJS 支持通过transports参数控制可用传输通道比如在前端显式指定var sock new SockJS(/ws, null, { transports: [websocket] });这样做的好处是暴露问题更快、更早。如果你加了这行代码后连接失败那说明 WebSocket 通道本身就没打通比拖着 polling 通道慢性出问题要直观得多。缺点是旧浏览器会直接不可用所以是否启用取决于你的用户群。第二给 STOMP.js 配好自动重连。STOMP.js 自身不带重连逻辑断线后需要手动重建连接。工程上比较推荐的做法是把建连逻辑封装成函数在断连回调里做指数退避重试let retryCount 0; function connect() { const socket new SockJS(/ws); const stompClient Stomp.over(socket); stompClient.reconnect_delay 5000; stompClient.connect({}, function (frame) { retryCount 0; // 订阅、发送逻辑 }, function (error) { console.log(STOMP connection closed, error: , error); setTimeout(connect, Math.min(30000, 1000 * Math.pow(2, retryCount))); retryCount; }); } connect();第三服务端心跳比客户端要稍微激进一点。我习惯把 Spring 端的heartbeatValue设成比前端短 1-2 秒比如前端[10000, 10000]后端[8000, 8000]这样服务端能更快发现死链主动清理无效连接。第四所有连接参数统一走配置中心。连接地址、心跳间隔、重连策略这些不要硬编码在页面里在 CentOS 上用 Nginx 变量、环境变量或注册中心下发统一管理。一台机器的 Nginx 超时参数改了其他机器没同步排查起来极其痛苦。7. 个人实操体会最后分享一点真实的排障心得。我处理这个问题的完整周期大约花了两天。第一天一直在逛各种资料、怀疑代码猜测是不是 STOMP.js 版本问题、Spring 版本问题。真正让我豁然开朗的节点是拿curl在服务器上模拟了完整的 WebSocket Upgrade 过程看到响应头里没有Upgrade相关的返回才定位到是 Nginx 的问题。从那之后我面对这类报错的态度就变成了先不管代码直接把请求链路跑一遍看每一层的返回头和报错日志。在 CentOS 这种服务器环境里SockJS 报错有个典型特点开发环境症状不明显一上生产就五花八门。原因不外乎链路长了、网络策略严了、长连接没人管了。排查时的核心方法论是分层排查——客户端、Nginx、后端、防火墙、内核一层一层过每一层都有对应的验证命令和日志千万不要一上来就怀疑代码里的某个参数。希望这篇文章能帮你少走一些弯路。如果你按上面的顺序排查完问题依然没解决建议把完整的请求链路、Nginx 配置、后端日志贴出来让社区里的人帮你一起看。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →