在需要实时更新的场景里,WebSocket 是浏览器与服务器之间真正的全双工通道。相比早期靠前端定时轮询(polling)去“问”后端有没有新数据,WebSocket 在一次握手后保持长连接,服务端可以随时主动把消息推给客户端。聊天室、协同编辑、行情推送、监控大屏、游戏同步,本质上都依赖这种实时双向通信能力。本文用最小可运行的代码,带你从零搭出一个能上生产的即时通信服务,并讲清横向扩展、心跳保活与反向代理这些“Demo 跑得起来、线上却崩”的关键细节。
一、为什么是 WebSocket:从轮询到全双工
传统 HTTP 是“一问一答”:客户端发起请求,服务端返回,连接就关闭。要实时,只能不断轮询,既浪费带宽也增加延迟。假设行情每 200 毫秒变一次,你每 5 秒轮询一次,那用户看到的永远慢半拍;若把间隔缩到 200 毫秒,服务端又要承受几十倍的无效请求。WebSocket 在 HTTP 握手阶段通过 Upgrade: websocket 头把协议升级为长连接,之后双方用同一个 TCP 连接自由收发帧,服务端无需等待客户端请求即可推送。这正是“实时”二字的关键——把“客户端不断去问”反转成“服务端有变化就喊你”。
握手本身还是一次标准 HTTP 请求:客户端带 Sec-WebSocket-Key,服务端回 Sec-WebSocket-Accept 完成协商。协商完成后,HTTP 连接被“升级”为 WebSocket 连接,此后数据以二进制帧传输,开销极小。理解这一点很重要:WebSocket 不是凭空发明的新协议,而是基于 HTTP 握手、运行在 TCP 之上的长连接通道。
二、最小可用:Node.js + ws 起服务
2.1 安装与基础回声服务
最常用的纯 WebSocket 服务端库是 ws,它只做协议解析、不绑定任何框架,性能好也足够轻。下面不到 20 行就能跑起一个回声服务:
// server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws, req) => {
console.log('client connected, ip=', req.socket.remoteAddress);
ws.on('message', (data) => {
console.log('recv:', data.toString());
// 把收到的内容原样回推
ws.send(`echo: ${data}`);
});
ws.on('close', () => console.log('client disconnected'));
});
console.log('WebSocket server on ws://localhost:8080');
ws.readyState 有四种状态:CONNECTING(0)、OPEN(1)、CLOSING(2)、CLOSED(3)。发消息前务必判等 OPEN,否则连接已断会抛错。本地用 node server.js 起服务后,打开浏览器控制台 new WebSocket('ws://localhost:8080') 即可验证。
2.2 广播与房间:聊天室的雏形
真实业务要的是“一个用户发言,其他人都能看到”。给连接挂上房间号,遍历同房间连接广播即可。注意所有客户端都挂在 wss.clients 这个 Set 上,广播就是遍历它:
// 给每个连接附加 room
wss.on('connection', (ws, req) => {
ws.room = new URL(req.url, 'http://x').searchParams.get('room') || 'lobby';
ws.on('message', (raw) => {
const msg = JSON.stringify({ t: Date.now(), text: raw.toString() });
// 向同房间所有连接广播
for (const client of wss.clients) {
if (client.readyState === 1 && client.room === ws.room) {
client.send(msg);
}
}
});
});
三、浏览器端:原生 API 与 Vue 3 封装
浏览器原生就带 WebSocket 对象,无需任何依赖,事件模型也很直观:onopen 连接成功、onmessage 收到消息、onclose 断开、onerror 出错。下面这段建立连接、收发并做简单的自动重连:
// browser
const ws = new WebSocket('ws://localhost:8080/?room=lobby');
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => render(JSON.parse(e.data));
ws.onclose = () => setTimeout(connect, 2000); // 2s 后重连
在 Vue 3 项目 里,更推荐把连接逻辑封装成一个 composable,组件只关心数据、不关心连接细节,也避免每个页面重复写一堆事件监听:
// useWebSocket.js
import { ref, onUnmounted } from 'vue';
export function useWebSocket(url) {
const messages = ref([]);
const ws = new WebSocket(url);
ws.onmessage = (e) => messages.value.push(JSON.parse(e.data));
onUnmounted(() => ws.close());
const send = (text) => ws.send(text);
return { messages, send };
}
四、生产必过:Nginx 反向代理与 WSS
线上不会让用户直连 8080,而是统一走 443(WSS = WebSocket over TLS)。这里最关键的,是必须把 Upgrade 和 Connection 两个头原样转发,否则连接会在握手阶段被 Nginx 当成普通 HTTP 直接关闭。配置可参考站内的 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_read_timeout 3600s; # 长连接别被过早断掉
}
如果前面有多台 WS 实例做负载均衡,推荐用 ip_hash 或基于房间的粘性路由,尽量减少跨实例转发;也可以干脆像第五节那样用 Redis 总线做无状态广播,这样负载均衡怎么分都行。另外公网务必上 WSS(TLS),否则运营商会劫持、注入甚至悄悄断掉你的长连接。
五、水平扩展:用 Redis 发布订阅打通多实例
单进程扛不住时,你会起多个 Node 实例(比如配合 Docker 横向扩容)。但 A 实例上的连接和 B 实例上的连接互不认识,广播就失效了——用户会发现自己发言只有同实例的人能看到。解决办法是用 Redis 做跨进程的消息总线:每个实例既订阅 Redis 频道,又把自己的消息 publish 出去,由 Redis 把消息扩散到所有实例。
const redis = require('redis');
const pub = redis.createClient(), sub = redis.createClient();
sub.subscribe('chat');
sub.on('message', (ch, payload) => {
// 推给本实例上的所有连接
for (const c of wss.clients) if (c.readyState === 1) c.send(payload);
});
ws.on('message', (raw) => pub.publish('chat', raw.toString()));
这样架构就变成了“无状态 WS 层 + Redis 总线”:WS 实例随时可以增减,消息通过 Redis 全局扩散,前端完全无感。若要按房间隔离,只需把频道名带上房间号(如 chat:room-42),每个实例只订阅自己当前有连接的房间,进一步降低 Redis 的扇出压力。
六、稳定性:心跳、重连与背压
长连接最怕“假死”——TCP 没断但中间链路静默丢包,双方都以为对方还在。服务端应定时发 ping 帧,客户端回 pong;超时未回应就主动 terminate 释放资源,否则僵尸连接会慢慢吃光内存与文件描述符:
// 服务端每 30s 探活
const timer = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
}
}, 30000);
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => (ws.isAlive = true));
});
客户端同样要做指数退避重连:首次断线等 1 秒,再断等 2 秒、4 秒……封顶到比如 30 秒,避免服务端抖动时被海量重连打爆。若单连接消息速率过高,还要做背压——客户端来不及处理时就暂缓发送或合并帧,别让内存堆积。
七、WebSocket 还是 SSE?一张表说清选型
不是所有实时场景都要上 WebSocket。如果只需要服务端单向推送(行情、通知、日志流),SSE(Server-Sent Events)基于普通 HTTP、浏览器原生自动重连,实现更简单。选型看这张表:
| 维度 | WebSocket | SSE | 轮询 |
|---|---|---|---|
| 方向 | 全双工 | 服务端→客户端 | 客户端→服务端 |
| 协议 | 独立 WS 协议 | HTTP 长连接 | 普通 HTTP |
| 断线重连 | 需自己写 | 浏览器原生支持 | 天然 |
| 适用 | 聊天/游戏/协同 | 行情/通知/日志流 | 低频更新 |
八、常见坑与排查清单
- 401/403 握手失败:检查 Nginx 是否正确转发 Upgrade 头,且鉴权放在 WS 握手前的 query 或 header。
- 连接频繁断开:多半是代理
proxy_read_timeout太短,或没做心跳,按第六节补上即可。 - 跨实例收不到消息:确认用了 Redis 这类总线,别只在单进程内广播。
- 内存上涨:未清理的断开连接仍在
wss.clients中,务必在close事件释放引用。 - 生产环境连不上:先确认走的是 WSS(443),再抓包看 Upgrade 头有没有被中间代理吞掉。
实时双向通信并不神秘:一次握手、一个长连接、几段广播代码,就能撑起一个可用的即时服务。把本文的 Nginx 代理、Redis 总线和心跳三件套补齐,就可以放心上生产。当你真正理解了“连接如何建立、消息如何扩散、连接如何保活”,WebSocket 就不再是什么高深技术,而是你工具箱里随时可取的一把利刃。




