实时通信是 Web 应用的高频需求:聊天室、股票行情、在线协作、弹幕、监控大屏……这些场景都要求服务端能主动把数据推给浏览器。设想一个最简单的聊天室:十个人在线,哪怕一分钟内只有一个人发了一句“在吗”,另外九个人也得立刻看到。如果靠前端每秒钟轮询一次,服务端每分钟要承受 9×60=540 次几乎都是空结果的请求;人一多,这种“假实时”的成本就指数级爆炸。而传统的 WebSocket 出现之前,前端只能靠反复轮询(Polling)去“问”后端有没有新消息,既浪费带宽又延迟高。WebSocket 协议通过一次 HTTP 升级,把单向的请求-响应变成全双工通道,让数据可以双向实时流动。本文从原理到落地,带你把一个真实可用的实时通信服务跑起来。
一、为什么需要 WebSocket:HTTP 轮询的代价
在 WebSocket 之前,浏览器想“实时”拿到服务端数据,无非三条路:短轮询、长轮询、SSE。但它们都有各自的硬伤:
| 方案 | 原理 | 延迟 | 服务端压力 | 方向 |
|---|---|---|---|---|
| 短轮询 | 每隔 N 秒发一次请求 | 高(取决于间隔) | 大(大量空响应) | 单向(客户端拉) |
| 长轮询 | 请求挂起,有数据才返回 | 中 | 中(连接占用) | 单向(客户端拉) |
| SSE | 服务端单向事件流 | 低 | 低 | 单向(服务端推) |
| WebSocket | 全双工长连接 | 极低 | 低(单连接复用) | 双向 |
核心矛盾在于:HTTP 本身是“一问一答”的,服务端没法主动找客户端。哪怕用长轮询把延迟压低,也是用“占着连接”换来的,并发一高就撑不住。WebSocket 直接在 TCP 之上建立持久连接,彻底解决了这个结构性问题。一个粗略的对比:同样支撑一万名在线用户,短轮询可能需要后端扛住每秒上万次请求,而 WebSocket 只需一万个长连接常驻,请求数几乎为零,资源消耗差出几个数量级。
二、WebSocket 握手:一次特殊的 HTTP 升级
WebSocket 并不是凭空出现的协议,它的连接从一次普通的 HTTP 请求开始。客户端在请求头里带上两个关键字段,向服务端“申请升级”协议:
GET /chat HTTP/1.1
Host: fsdata.site
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端收到后,会把客户端发来的 Sec-WebSocket-Key 拼上一个固定的魔法字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做 SHA-1 再 Base64,作为 Sec-WebSocket-Accept 返回。这个握手校验确保了“真的是 WebSocket 客户端在请求升级”,而不是普通 HTTP 请求被误升级。服务端同意时返回 101 Switching Protocols,此后这条 TCP 连接就不再走 HTTP 语义,而是被 WebSocket 帧协议接管。注意这和 跨域 CORS 一样,浏览器对 WebSocket 也有同源约束:默认只允许同源连接,跨域需要在服务端显式配置允许的 Origin。
三、五分钟搭一个实时聊天室
3.1 Node 服务端(ws 库)
Node 生态里最常用的 WebSocket 服务端库是 ws。下面这段代码就能撑起一个支持广播的聊天室:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
console.log('client connected:', ip);
ws.on('message', (data) => {
const msg = data.toString();
// 广播给所有在线客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'chat', msg, ts: Date.now() }));
}
});
});
ws.on('close', () => console.log('client left:', ip));
});
console.log('websocket server on ws://localhost:8080');
上面的示例代码为了好读省略了健壮处理。生产环境里至少要把 message 用 JSON.parse 包在 try/catch 里,避免一条脏数据让整个广播循环崩溃;同时要在遍历 wss.clients 时跳过那些还没完全打开的连接,否则会给半开连接写数据而抛异常。ws 库本身也提供了 ws.ping() / message 的事件式心跳能力,可以和客户端对端的心跳配合。
3.2 浏览器客户端
浏览器原生就支持 WebSocket,无需任何依赖。在 Vue3 + TS 或普通页面里这样用:
const ws = new WebSocket('wss://fsdata.site/chat');
ws.onopen = () => ws.send(JSON.stringify({ msg: 'hello' }));
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('收到:', data.msg);
};
ws.onerror = (e) => console.error('ws error', e);
ws.onclose = () => console.log('连接已关闭');
四、生产级 Nginx 反向代理配置
WebSocket 连接要穿透 Nginx,必须正确转发 Upgrade 和 Connection 头,否则握手会卡在 101。这与本站 Nginx 反向代理实战 的写法一脉相承,只是多了两行关键头:
location /chat {
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_buffering off;
}
生产环境强烈建议用 wss://(WebSocket over TLS),让它在 443 端口上走和 HTTPS 同样的加密通道,也更容易穿透企业防火墙。配合 Nginx 缓存优化 时,记得把 /chat 这类长连接路径排除在缓存之外。
五、断线重连与心跳:别让连接悄悄死掉
长连接最容易被忽视的,是“连接看起来还在,其实已经死了”——比如中间网关空闲超时断开了 TCP,但两端都没收到通知。解决方法是心跳(Ping/Pong)加自动重连:
let ws, retry = 0, heartbeat;
function connect() {
ws = new WebSocket('wss://fsdata.site/chat');
ws.onopen = () => {
retry = 0;
heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
}, 20000); // 每 20s 发一次心跳
};
ws.onclose = () => {
clearInterval(heartbeat);
const delay = Math.min(1000 * 2 ** retry, 30000); // 指数退避,上限 30s
retry++;
setTimeout(connect, delay);
};
}
connect();
心跳间隔通常取 20~30 秒,既不会频繁打扰,也能在网关超时(常见 60s)之前保活连接。指数退避的断线重连则避免雪崩式重连把服务端打挂。
六、常见坑与上线 Checklist
| 现象 | 根因 | 修复 |
|---|---|---|
| 握手一直 101 失败 | Nginx 没转发 Upgrade 头 | 加 Connection/Upgrade 转发 |
| 连接几分钟后自动断 | 缺心跳,被网关超时掐断 | 加 20~30s 心跳 |
| 跨域连接被拒 | 服务端未放行 Origin | 白名单校验 Origin |
| 重启丢消息 | 进程内广播无持久化 | 接消息队列或 Redis 发布订阅 |
另外提醒两点:一是 WebSocket 本身不带鉴权,连接建立后第一帧就应校验身份令牌;二是横向扩展时,单进程内存里的连接池不够用,需要用 Redis 的发布订阅或消息队列来做多节点广播。
七、安全与扩展:鉴权、跨域与多节点广播
把 Demo 变成生产系统,有三道绕不开的关。第一是鉴权:WebSocket 握手走的是 HTTP,可以在 Nginx 或应用层对握手请求做登录态校验;连接建立后的第一帧,还应要求客户端带上身份令牌,否则任何人都能冒充用户订阅消息。第二是跨域:和 CORS 同源策略类似,服务端要维护一份允许的 Origin 白名单,对不在名单里的来源直接拒绝升级,防止别的站点偷偷把你当成免费推送后端。
第三是横向扩展。前面聊天室把连接和广播都放在单个 Node 进程的内存里,一旦部署多个实例做负载均衡,A 节点的连接就收不到 B 节点发出的消息。解决办法是用 Redis 的发布订阅把各节点连起来:每个进程既是订阅者也是发布者,收到客户端消息后统一发到 Redis 频道,再由所有节点各自广播给本地连接。
const redis = require('redis');
const pub = redis.createClient(), sub = redis.createClient();
sub.subscribe('chat');
// 收到客户端消息 -> 转发到 Redis 频道
ws.on('message', (data) => pub.publish('chat', data));
// 收到频道消息 -> 广播给本节点所有连接
sub.on('message', (_, data) => {
wss.clients.forEach((c) => {
if (c.readyState === WebSocket.OPEN) c.send(data);
});
});
这样无论用户在哪个节点上线,消息都能经过 Redis 中转触达全网。如果消息量大,还可以进一步把 Redis 换成 Kafka、RabbitMQ 等专业消息队列,并引入持久化避免进程重启丢消息。
结语
WebSocket 不是“更高级的 HTTP”,而是一次协议升级,把单向请求变成了双向通道。记住四件事:轮询浪费资源、握手靠 Upgrade 头、生产必须走 wss 并配好 Nginx 转发、长连接要靠心跳和重连保活。把本文的服务端、客户端、Nginx 三段代码拼起来,一个能上生产的实时通信骨架就成型了。




