Nginx 反代 WebSocket,连接每隔 60 秒必断——排查与彻底修复

Nginx 反代 WebSocket,连接每隔 60 秒必断——排查与彻底修复 症状WebSocket 功能在开发环境完全正常部署到生产后连接会在建立后约 60 秒时被断开。前端会收到CloseEventcode: 1006异常关闭非正常断开。刷新页面重新连接后再过 60 秒又断。背景技术栈Vue 3 前端 Spring Boot WebSocket 后端部署架构为用户浏览器 → Nginx反向代理 → Spring Boot 应用使用 STOMP over WebSocket 实现实时消息推送。开发时前端直连ws://localhost:8080无任何代理层一切正常。排查过程第一步确认断开方向在前端监听onclose事件打印关闭信息socket.onclose(event){console.log(WebSocket closed:,{code:event.code,reason:event.reason,wasClean:event.wasClean,})}输出WebSocket closed: { code: 1006, reason: , wasClean: false }wasClean: false表示连接不是正常握手关闭的而是被强制中断。reason为空说明断开方没有发送 Close Frame——更可能是网络层/代理层直接切断了 TCP 连接而不是应用层主动关闭。第二步排查后端查看 Spring Boot 日志断开时刻没有任何 WebSocket 相关的错误或关闭日志。说明后端应用完全不知道连接断了——是连接在 Nginx 和前端之间被切断的。第三步定位 Nginx 配置问题检查 Nginx 配置# /etc/nginx/conf.d/app.conf问题版本 server { listen 80; server_name example.com; location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } location / { proxy_pass http://127.0.0.1:8080; } }乍看没问题Upgrade和Connection头都设置了。但注意到没有设置proxy_read_timeout。Nginx 的proxy_read_timeout默认值是60 秒——这正是连接断开的时间间隔。根因WebSocket 是长连接在没有消息传输时TCP 连接处于空闲状态。Nginx 默认在 60 秒内没有从后端读到数据就会关闭这个代理连接。对 Nginx 来说它以为这是一个卡住了的 HTTP 请求而不是一个需要长期保持的 WebSocket 连接。修复方案方案一延长超时时间快速修复location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; # 关键延长读超时。WebSocket 连接期间没有数据不代表连接断了 proxy_read_timeout 3600s; # 1 小时 proxy_send_timeout 3600s; # 连接超时建立连接的等待时间保持默认即可 proxy_connect_timeout 10s; }重载 Nginxnginx -s reload问题解决。但这只是治标连接最终还是会在 1 小时时断开。更健壮的做法是配合心跳机制。方案二配置 TCP KeepAlive治本TCP 层的 KeepAlive 会定期发送探测包让 Nginx 知道这条连接是活的location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 超时设置 proxy_read_timeout 86400s; # 24 小时或者更长 proxy_send_timeout 86400s; # TCP KeepAlive需要 Nginx 1.15.6 proxy_socket_keepalive on; }同时在http块开启系统级 TCP KeepAlivehttp { # 开启 TCP KeepAlive tcp_nodelay on; keepalive_timeout 65; keepalive_requests 100; }方案三前端实现心跳最终保障不要只依赖服务器端的保活配置——用户的网络链路上可能还有其他中间设备运营商 NAT、企业防火墙同样会切断空闲连接而你无法控制它们的超时配置。前端实现应用层心跳是最可靠的方案// composables/useWebSocket.tsimport{ref,onUnmounted}fromvueexportfunctionuseWebSocket(url:string){constsocketrefWebSocket|null(null)constisConnectedref(false)letheartbeatTimer:ReturnTypetypeofsetInterval|nullnullletreconnectTimer:ReturnTypetypeofsetTimeout|nullnullletreconnectAttempts0constMAX_RECONNECT5constHEARTBEAT_INTERVAL30_000// 30 秒发一次心跳小于任何合理的超时值constRECONNECT_DELAY3_000functionconnect(){socket.valuenewWebSocket(url)socket.value.onopen(){isConnected.valuetruereconnectAttempts0startHeartbeat()console.log([WS] Connected)}socket.value.onmessage(event){// 过滤掉心跳回应不交给业务层if(event.datapong)returnhandleMessage(event.data)}socket.value.onclose(event){isConnected.valuefalsestopHeartbeat()console.warn([WS] Closed:,event.code,event.reason)// 非主动关闭时自动重连if(event.code!1000){scheduleReconnect()}}socket.value.onerror(error){console.error([WS] Error:,error)}}functionstartHeartbeat(){heartbeatTimersetInterval((){if(socket.value?.readyStateWebSocket.OPEN){socket.value.send(ping)}},HEARTBEAT_INTERVAL)}functionstopHeartbeat(){if(heartbeatTimer){clearInterval(heartbeatTimer)heartbeatTimernull}}functionscheduleReconnect(){if(reconnectAttemptsMAX_RECONNECT){console.error([WS] Max reconnect attempts reached)return}constdelayRECONNECT_DELAY*Math.pow(2,reconnectAttempts)// 指数退避console.log([WS] Reconnecting in${delay}ms (attempt${reconnectAttempts1}))reconnectTimersetTimeout((){reconnectAttemptsconnect()},delay)}functiondisconnect(){stopHeartbeat()if(reconnectTimer)clearTimeout(reconnectTimer)socket.value?.close(1000,Client disconnect)// 1000 正常关闭}functionhandleMessage(data:string){// 业务消息处理逻辑console.log([WS] Message:,data)}onUnmounted(disconnect)return{socket,isConnected,connect,disconnect}}后端对应响应心跳ComponentpublicclassWebSocketHeartbeatHandlerextendsTextWebSocketHandler{OverrideprotectedvoidhandleTextMessage(WebSocketSessionsession,TextMessagemessage){if(ping.equals(message.getPayload())){try{session.sendMessage(newTextMessage(pong));}catch(IOExceptione){// 发送失败说明连接已经断了清理资源}return;}// 处理业务消息...}}完整的生产级 Nginx 配置upstream backend { server 127.0.0.1:8080; keepalive 32; # 保持与后端的长连接池 } server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; # 普通 HTTP 请求 location /api/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; # 清空 Connection 头启用 keepalive proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # WebSocket 请求路径前缀独立配置 location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; # WebSocket 升级头必须 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 超时配合前端 30s 心跳这里设 1 小时绰绰有余 proxy_read_timeout 3600s; proxy_send_timeout 3600s; # TCP 层保活 proxy_socket_keepalive on; # 禁用缓冲WebSocket 不需要缓冲开了反而引入延迟 proxy_buffering off; } location / { root /var/www/html; try_files $uri $uri/ /index.html; } }其他常见的 WebSocket 部署坑问题原因解法连接建立失败101 变 400忘记设Upgrade/Connection头检查 location 块的 header 配置wss:// 连接被拒绝Nginx 没配 SSL或前端写死了 ws://确认 SSL 配置前端动态判断协议负载均衡下连接断开多实例部署请求被路由到不同实例Nginx upstream 用ip_hash或 引入 Redis Pub/Sub 做消息广播K8s Ingress 下 60s 断Ingress Controller 的默认超时加 annotationnginx.ingress.kubernetes.io/proxy-read-timeout: 3600CloudFlare 代理下 100s 断CF 免费版 WebSocket 超时 100s配合前端心跳 100s或升级 CF 计划总结WebSocket 在代理层下的断连95% 是超时配置问题。排查顺序1. 确认断开方向code 1006 wasClean:false 代理层切断 2. 检查每一层的超时配置Nginx / K8s Ingress / CloudFlare / 应用层 3. 增大超时 → 解决立即问题 4. 加前端心跳 → 彻底解决对抗任何中间层的超时 5. 加自动重连指数退避→ 提升健壮性三者缺一不可单纯改 Nginx 超时是不够的。如果你的环境是 Kubernetes记得同时检查 Service 的sessionAffinity配置以及 Ingress 的 annotation 超时设置——坑往往叠着坑。