WebSocket 长连接在生产环境频繁断开,十有八九不是业务代码的 bug,而是 Nginx 反向代理的超时配置与缺失心跳共同导致的。本文用一套 5 步排查法,从 proxy_read_timeout 到应用层 ping/pong,帮你彻底终结 WebSocket 断连问题。
一、先看清断连的特征
排查前先分类,避免南辕北辙:
- 代理层掐断:连接在固定时间(如 60s、75s)被干净关闭,客户端收到 1006 异常码,服务端日志无业务报错。这是 Nginx / K8s Ingress 超时的典型表现。
- 业务层掉线:连接随机中断,伴随心跳超时日志,通常与代码、GC 停顿、下游依赖有关。
如果你的断连时间高度规律,几乎可以锁定是代理层超时。这也是本文重点。基础环境可回顾 Nginx 反向代理完整配置 与 WebSocket 实时通信实战 两篇,这里不重复展开。
二、根因一:Nginx proxy_read_timeout 默认只有 60s
Nginx 的 proxy_read_timeout 默认值是 60 秒。它代表”两次从上游读取数据之间允许的最长空闲时间”。WebSocket 是长连接,客户端可能几十秒不发任何消息,于是 Nginx 认为连接已死,直接断开——而此时业务层毫无感知。
修复:为 WebSocket 单独拉高超时
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# 关键:把空闲超时拉到 1 小时以上
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
注意:不要全局把 proxy_read_timeout 调大,那只会影响普通 HTTP,应仅对 /ws/ 这类 WebSocket 路径单独设置,否则会拖慢故障连接的回收。
三、根因二:缺少应用层心跳
即便 Nginx 超时已经放开,TCP 层面仍然可能静默丢包,或者中间网络设备回收空闲连接。最稳妥的做法是应用层自己发心跳,让连接”始终有数据流动”。
浏览器端定时 ping
const ws = new WebSocket('wss://example.com/ws/');
let heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000); // 每 25s 一次,远小于代理超时
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') {/* 收到服务端回应,连接健康 */}
};
ws.onclose = () => clearInterval(heartbeat);
Node.js 服务端回应 pong
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
}
});
// 服务端主动探测:30s 内无消息则判定掉线
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
// 每 30s 巡检一次
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
心跳间隔建议取代理超时的 1/3 到 1/2。例如 Nginx 设 3600s,心跳 25s 绰绰有余;若代理超时只有 75s,则心跳应 ≤ 30s。更多超时相关排查思路可参考 Nginx 502/504 排查实录。
四、根因三:K8s Ingress / 负载均衡的 idle timeout
如果 WebSocket 跑在 K8s 后面,Ingress NGINX 默认也有 proxy-read-timeout,需要在 Ingress 上用注解单独放开:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ws-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
spec:
rules:
- host: example.com
http:
paths:
- path: /ws/
pathType: Prefix
backend:
service:
name: backend
port:
number: 80
云厂商 CLB / ALB 同样有空闲超时(如阿里云 CLB 默认 60s、900s 不等),务必同步检查,否则上游全改了仍会断。
五、5 步排查清单
按下面顺序逐项确认,基本能定位 90% 的 WebSocket 断连:
| 步骤 | 检查项 | 正常表现 |
|---|---|---|
| 1 | 断连是否呈固定时间间隔 | 规律断连→代理超时;随机→业务层 |
| 2 | Nginx proxy_read / send_timeout | WebSocket 路径 ≥ 业务空闲时长 |
| 3 | 应用层心跳 | 客户端定期 ping,服务端回 pong |
| 4 | Ingress / CLB idle timeout | 与 Nginx 一致,均拉高 |
| 5 | 客户端重连策略 | 指数退避 + 断线自动恢复 |
六、前端重连:别让用户看到断线
再稳的长连接也可能短暂抖动,健壮的重连机制能掩盖绝大多数瞬时故障:
function connect() {
const ws = new WebSocket('wss://example.com/ws/');
let retries = 0;
ws.onclose = () => {
retries++;
const delay = Math.min(1000 * 2 ** retries, 30000); // 指数退避,上限 30s
setTimeout(connect, delay);
};
}
connect();
七、一个常见误区
有人以为开了 TCP keepalive 就万事大吉,但 keepalive 默认探测间隔长达 2 小时,且很多 NAT / 防火墙根本不转发 keepalive 包。它只能兜底死连接回收,无法替代应用层心跳。两者应同时使用。
八、小结
WebSocket 断连的本质,是”长连接”撞上了”为短请求设计的超时”。记住三件事:代理层超时单独拉高、应用层心跳持续保活、全链路(Nginx + Ingress + CLB)超时一致。再加上前端指数退避重连,生产环境的实时通信就能稳如磐石。




