HTTP 的请求/响应模型在大部分场景够用,但碰到"服务端要主动推消息给客户端"这种需求就显得笨拙 —— 长轮询、SSE、轮询都是 workaround。WebSocket 是为这个场景设计的标准方案:一次握手,建立一条双向、长连接的全双工通道。这篇文章从协议握手讲到生产部署,所有代码都可以直接跑。
WebSocket 协议本身
WebSocket 用 HTTP 完成握手,握手成功后协议升级为 WebSocket,后续的数据帧不再走 HTTP。一次典型的握手请求:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
服务器响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Accept 是 Sec-WebSocket-Key + 固定 GUID 的 SHA-1,再 base64 编码 —— 用于证明服务端确实理解 WebSocket 协议,不是傻乎乎照搬 HTTP 响应。握手后,TCP 连接保留,但数据格式变成 WebSocket 帧:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
关键字段:FIN(是否分片的最后一帧)、opcode(0x1 文本、0x2 二进制、0x8 关闭、0x9 ping、0xA pong)、MASK(客户端发出的帧必须 mask,服务端发出的不需要)、Payload Length(0-125 直接读,126 读后续 2 字节,127 读 8 字节)。理解这层有助于调试 —— 抓包看 wireshark 时不会迷茫。
浏览器侧:原生 WebSocket API
const ws = new WebSocket('wss://example.com/chat');
ws.addEventListener('open', () => {
console.log('connected');
ws.send(JSON.stringify({ type: 'hello', user: 'mores' }));
});
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('received', data);
});
ws.addEventListener('close', (event) => {
console.log('closed', event.code, event.reason);
});
ws.addEventListener('error', (err) => {
console.error('ws error', err);
});
// 主动关闭
// ws.close(1000, 'normal closure');
注意:生产环境永远用 wss://(WebSocket Secure,基于 TLS),理由和 HTTPS 一样 —— 防止中间人嗅探消息内容。很多公司内网代理只允许 443 端口的 TLS,普通 ws:// 会被拦截。
服务端:Node.js + 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 from ${ip}`);
ws.on('message', (data) => {
const msg = JSON.parse(data);
// 广播给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
});
});
ws.on('close', () => console.log(`client ${ip} disconnected`));
ws.send(JSON.stringify({ type: 'welcome', msg: '欢迎' }));
});
这是一个最简单的"广播聊天室"。要做"分房间",在 connection 上加 ws.room 属性,广播时只发给同房间的客户端:
const rooms = new Map(); // roomId -> Set<ws>
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://x');
const room = url.searchParams.get('room') || 'default';
ws.room = room;
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
ws.on('message', (data) => {
rooms.get(room).forEach((c) => {
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(data);
});
});
ws.on('close', () => {
rooms.get(room).delete(ws);
if (rooms.get(room).size === 0) rooms.delete(room);
});
});
关键问题:心跳与断线重连
TCP 连接看上去"长"在那里,但中间任何一个 NAT、防火墙、负载均衡都可能在空闲时把连接静默踢掉。你的代码需要主动维护连接活性。
服务端心跳:检测死连接
// ws 库内置 ping/pong 机制
function heartbeat() { this.isAlive = true; }
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate(); // 上一轮没回 pong,杀掉
ws.isAlive = false;
ws.ping(); // 发 ping,等 pong
});
}, 30000);
wss.on('close', () => clearInterval(interval));
客户端断线重连(带指数退避)
class ReconnectingWS {
constructor(url) {
this.url = url;
this.attempts = 0;
this.handlers = {};
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempts = 0;
(this.handlers.open || []).forEach((fn) => fn());
};
this.ws.onmessage = (e) => {
(this.handlers.message || []).forEach((fn) => fn(e));
};
this.ws.onclose = () => {
const delay = Math.min(30000, 1000 * 2 ** this.attempts);
this.attempts++;
console.log(`reconnect in ${delay}ms (attempt ${this.attempts})`);
setTimeout(() => this.connect(), delay);
};
}
on(event, fn) { (this.handlers[event] ??= []).push(fn); }
send(data) {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(data);
// 没连上的时候要不要 buffer,看业务
}
}
指数退避(1s, 2s, 4s, 8s, 16s, 30s 封顶)避免了"网络抖动时所有客户端同时疯狂重连"打垮服务的雷击效应。生产环境务必加这个。
消息可靠性:WebSocket 不保证
WebSocket 提供的可靠性等同于 TCP —— 字节流不丢、不乱序。但应用层不会自动重发:你 send 了一条消息,如果连接断开,这条消息就丢了。如果业务要求"必送达",需要自己实现:
- 客户端给每条消息加 seq,服务端 ack 确认后客户端删除待发队列里的消息。
- 重连后用 last_seq 续传,服务端补发漏掉的消息。
- 关键消息走 HTTP API 做兜底,WebSocket 只负责实时通知。
// 极简版可靠传输框架
class ReliableWS {
constructor() {
this.pending = new Map(); // seq -> { data, timer }
this.seq = 0;
}
send(data) {
const s = ++this.seq;
this.pending.set(s, { data });
this.ws.send(JSON.stringify({ seq: s, data }));
// 设置超时重发
const timer = setTimeout(() => this.resend(s), 3000);
this.pending.get(s).timer = timer;
}
onMessage(msg) {
if (msg.type === 'ack') {
const p = this.pending.get(msg.seq);
if (p) { clearTimeout(p.timer); this.pending.delete(msg.seq); }
}
}
}
nginx 反向代理 WebSocket
# nginx.conf 片段
location /ws {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # 关键:转发 Upgrade
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # 默认 60s 太短
proxy_send_timeout 3600s;
}
三个最常踩的坑:没设 Upgrade 头 → 502;proxy_read_timeout 没改大 → 长连接 60s 后被切;用了 HTTP/1.0(默认是 1.0) → Upgrade 失效。
横向扩展:多实例间的消息同步
一台机器连不下所有用户,起多个 WebSocket 实例 —— 但用户 A 连在实例 1,用户 B 连在实例 2,A 给 B 发消息怎么办?需要一个消息总线(Redis Pub/Sub、NATS、Kafka 都行)在多实例之间转发。
// 用 Redis Pub/Sub 实现跨实例广播
const Redis = require('ioredis');
const sub = new Redis();
const pub = new Redis();
sub.subscribe('chat');
sub.on('message', (channel, msg) => {
// 收到其他实例广播的消息,本机所有连接转发出去
wss.clients.forEach((c) => {
if (c.readyState === WebSocket.OPEN) c.send(msg);
});
});
wss.on('connection', (ws) => {
ws.on('message', (data) => {
pub.publish('chat', data); // 不直接广播,而是发到 Redis
});
});
Socket.IO 内置 socket.io-redis 适配器做了这件事,推荐直接用。原生 WebSocket 需要自己实现。
WebSocket vs SSE vs 长轮询
- WebSocket:全双工,服务端能主推,客户端也能频繁推。最灵活,但有协议复杂度。适合实时聊天、协同编辑、游戏。
- SSE(Server-Sent Events):单向,服务端推、客户端只能用普通 HTTP 请求回应。基于 HTTP/1.1,自带断线重连,实现简单。适合"通知、行情、AI 流式输出"。
- 长轮询:客户端发请求,服务端 hang 住直到有数据或超时。兼容性最好,但延迟和资源占用最差。现代场景几乎不用了,除非要绕开特殊网络限制。
Socket.IO 与原生 WebSocket 的取舍
Socket.IO 不只是封装,它在 WebSocket 不可用时自动回退到长轮询,还内置了"房间""自动重连""消息确认""二进制传输"等一堆生产级特性。新项目我通常推荐直接用它,自己造这套东西稳定到生产水平至少要 1-2 个月。
// 服务端
const { Server } = require('socket.io');
const io = new Server(httpServer, { cors: { origin: '*' } });
io.on('connection', (socket) => {
socket.on('join', (room) => socket.join(room));
socket.on('msg', ({ room, text }) => {
io.to(room).emit('msg', { from: socket.id, text });
});
});
// 客户端
import { io } from 'socket.io-client';
const socket = io('https://example.com');
socket.emit('join', 'lobby');
socket.on('msg', (m) => render(m));
// 带 ack 的可靠消息(server 端 callback 调用就表示收到)
socket.emit('save', payload, (response) => {
if (response.ok) console.log('saved');
});
选型经验:有特殊协议需求或对协议层完全控制的场景用原生 WebSocket;一般业务直接 Socket.IO,把工程精力省下来做业务。
写在最后
WebSocket 解决了一个具体问题:服务端要主动、低延迟、双向地和客户端通信。它不是 HTTP 的替代品,只是 HTTP 在长连接场景下的补充。用它的代价是一整套你以前不用操心的工程问题:心跳、重连、扩容、消息可靠性 —— 这些都得你自己处理或选 Socket.IO / Phoenix Channels / Centrifugo 这类成熟方案。
给你一个清单做技术选型:消息频率 < 1 次/分钟,选 SSE 或干脆轮询;仅服务端推送、客户端只 ack,选 SSE;双向频繁交互、需要房间/分组,选 WebSocket;对消息可靠性要求高、又有横向扩展,直接用 Socket.IO + Redis 适配器。把"用什么协议"想清楚再写代码,能避开 90% 的实时通信坑。
—— 别看了 · 2026