吐槽一下最近踩到的一个关于分布式集群下 WebSocket 连接不稳定的巨坑。
上周我们把聊天和通知服务从单实例平滑扩容到了三个节点,前面挂了一层 Nginx 做负载均衡。结果刚上线,前端同事就找上门来,疯狂抱怨说用户连接经常无故断开,重连频繁。而且离谱的是,A 用户发的消息,B 用户经常收不到;或者重连一下,刚才还在的会话连接又没了。
这明显是分布式环境下会话不同步以及负载均衡策略没配对导致的翻车现场。
为什么多实例下 WebSocket 会失效?
传统的 HTTP 请求是无状态的,Nginx 默认采用轮询(Round-Robin)把请求均匀分发给后端的三个节点。但 WebSocket 是一种长连接(Stateful Connection)。
当客户端发起 HTTP 握手并升级为 WebSocket 协议后,连接会持久化绑定在具体的某一个节点上。
如果我们在负载均衡层没有配置粘性会话(Sticky Session),当客户端网络微弱抖动导致重连时,Nginx 会把这个重连请求分发到另一个全新的节点上。而这个节点根本没有之前握手时的 Session 状态,甚至连该用户的连接信息都没有,直接导致重连失败或长连接校验崩溃。
此外,Nginx 默认的 proxy_read_timeout 是 60 秒。如果客户端与服务端在 60 秒内没有任何数据往来,Nginx 就会单方面关闭这个连接。
完美的 Nginx 代理配置
要彻底解决这两个问题,我们需要在 Nginx 配置中做三件事:
- 使用
ip_hash算法:强制把来自同一个 IP 的请求始终定向到同一个后端节点,确保会话粘性。 - 正确透传 WebSocket 的 Upgrade 协议头。
- 合理配置代理超时时间:将
proxy_read_timeout调大,防止因为短暂没有消息而断开连接,同时在客户端配合心跳机制。
以下是优化后的 Nginx 核心配置:
# 1. 配置后端服务集群,启用 ip_hash
upstream websocket_servers {
ip_hash;
server 192.168.1.100:8080 weight=1 max_fails=3 fail_timeout=30s;
server 192.168.1.101:8080 weight=1 max_fails=3 fail_timeout=30s;
server 192.168.1.102:8080 weight=1 max_fails=3 fail_timeout=30s;
}
# 动态映射 Upgrade 头部,确保非 WebSocket 请求也能正常透传
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ws.example.com;
location /ws/ {
proxy_pass http://websocket_servers;
# 2. 透传必要的 Websocket 协议升级头
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 透传客户端真实 IP 与 Host
proxy_set_header Host $host;
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;
# 3. 核心超时时间调整
# Nginx 默认是 60s,如果 60s 内没有数据读写就会断开连接
# 我们把超时时间设为 10 分钟 (600s),要求客户端在此期间必须发送心跳
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# 禁用缓冲区,让消息实时投递
proxy_buffering off;
}
}
前端心跳保活脚本
光改 Nginx 超时时间还不够,如果遇到中间网络链路(如宽带运营商网关、代理防火墙)强制清理空闲 TCP 连接,连接照样会断。
我们必须在前端应用层设计心跳重连机制。以下是一个健壮的前端 WebSocket 客户端封装,带有自动重连和心跳保活:
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.pingInterval = null;
this.reconnectTimeout = null;
this.heartbeatInterval = 30000; // 30秒一次心跳
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log("WebSocket 连接已建立,启动心跳");
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
if (event.data === "pong") {
console.log("收到心跳响应");
return;
}
// 处理常规业务数据
this.handleMessage(event.data);
};
this.ws.onclose = () => {
console.warn("连接断开,尝试重连...");
this.stopHeartbeat();
this.scheduleReconnect();
};
this.ws.onerror = (err) => {
console.error("WebSocket 发生错误", err);
this.ws.close();
};
}
startHeartbeat() {
this.pingInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send("ping");
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
clearInterval(this.pingInterval);
}
scheduleReconnect() {
if (this.reconnectTimeout) return;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, 5000); // 5秒后重连
}
handleMessage(data) {
// 具体的业务逻辑
}
}
const socketClient = new ReconnectingWebSocket("ws://ws.example.com/ws/chat");
socketClient.connect();
配置好这套方案后,上线跑了几天,WebSocket 的意外连接断开率下降了 99%。
不过,ip_hash 也有其缺点,比如客户端如果都在同一个局域网(出口公网 IP 相同),会导致负载极度不均衡。
你们在多节点部署时,更倾向于用 ip_hash 这种粗暴的会话保持,还是基于 Redis Pub/Sub 的全广播方案?