主题
WebSocket
为什么需要 WebSocket
HTTP 是请求-响应模型——客户端不发请求,服务器就无法主动推送数据。对于需要实时更新的场景(聊天、股票行情、协同编辑),传统方案存在明显的局限:
短轮询(Short Polling):
客户端每隔 N 秒发一次请求,询问"有新数据吗?"
客户端 服务器
|── GET /messages ──→| "有新消息吗?"
|←── 200 (无新数据) ──|
| | 等待 3 秒...
|── GET /messages ──→| "有新消息吗?"
|←── 200 (无新数据) ──|
| | 等待 3 秒...
|── GET /messages ──→| "有新消息吗?"
|←── 200 (有新数据!) ──|
问题:
- 大量无效请求(90% 以上返回"无新数据")
- 实时性差(最坏延迟 = 轮询间隔)
- 每次请求都带完整 HTTP Header(~800 字节),浪费带宽
长轮询(Long Polling):
客户端发请求,服务器有数据才响应,否则 hold 住连接
客户端 服务器
|── GET /messages ──→| 挂起,等待新数据...
| (30s hold) |
|←── 200 (新数据!) ──| 有数据了,立即返回
|── GET /messages ──→| 立刻发起下一次请求
| (hold...) |
改进:减少了无效请求
仍存在的问题:
- 每次响应后必须重新建立连接(HTTP 开销)
- 服务器维护大量挂起连接,占用资源
- 单向通信——服务器无法随时向客户端推送WebSocket 的出发点:让浏览器和服务器之间建立一条持久的、双向的通信通道。
WebSocket:
客户端 服务器
|── HTTP 升级请求 ──→| "我想升级到 WebSocket"
|←── 101 Switching ──| "同意,升级成功"
| |
|══ 全双工通信通道 ══| TCP 连接保持
| |
|──→ 发送消息 | 客户端随时发
| ←── 推送消息| 服务器随时推
|──→ 发送消息 |
| ←── 推送消息|
| ←── 推送消息| 服务器可以连续推送
| |
优势:
- 全双工:双方随时发送数据,无需等待
- 低开销:握手后数据帧最小只有 2 字节头部(vs HTTP ~800 字节)
- 实时性:数据即时到达,无轮询延迟
- 持久连接:一次握手,长期通信握手流程
WebSocket 复用 HTTP 的端口(80/443),通过 HTTP Upgrade 机制完成协议升级:
客户端 服务器
| |
|── HTTP GET + Upgrade 请求 ──────────────────→ |
| GET /chat HTTP/1.1 |
| Host: example.com |
| Upgrade: websocket |
| Connection: Upgrade |
| Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== |
| Sec-WebSocket-Version: 13 |
| Origin: https://example.com |
| |
|←────────────────── 101 Switching Protocols ── |
| HTTP/1.1 101 Switching Protocols |
| Upgrade: websocket |
| Connection: Upgrade |
| Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= |
| |
|═══════════ WebSocket 连接已建立 ════════════════|
| 此后不再使用 HTTP 协议 |
| 双方通过 WebSocket 帧通信 |请求头详解
客户端发送的关键请求头:
Upgrade: websocket
→ 告诉服务器希望升级到 WebSocket 协议
Connection: Upgrade
→ 告诉服务器这是一个升级连接的请求
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
→ 客户端随机生成的 16 字节 Base64 编码值
→ 用于防止缓存代理误将 WebSocket 握手当作普通 HTTP 缓存
→ 不是加密用途,不提供安全保障
Sec-WebSocket-Version: 13
→ WebSocket 协议版本(当前标准版本是 13)
Origin: https://example.com
→ 来源页面地址(服务器可用于验证来源,防止跨站 WebSocket 劫持)
Sec-WebSocket-Protocol: chat, superchat(可选)
→ 子协议协商,客户端提供支持的子协议列表
Sec-WebSocket-Extensions: permessage-deflate(可选)
→ 扩展协商,如消息压缩响应头详解
服务器返回的关键响应头:
HTTP/1.1 101 Switching Protocols
→ 状态码 101 表示协议切换成功
Upgrade: websocket
Connection: Upgrade
→ 确认协议升级
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
→ 服务器对 Sec-WebSocket-Key 的校验响应
→ 计算方式:
1. 拼接:Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
→ "dGhlIHNhbXBsZSBub25jZQ==" + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
2. SHA-1 哈希
3. Base64 编码
→ 结果:"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
→ 客户端收到后也做同样计算,比对结果
→ 确保服务器真的理解 WebSocket 协议,而不是一个普通 HTTP 服务器意外返回 101Sec-WebSocket-Accept 计算过程
js
const crypto = require('crypto');
const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const accept = crypto
.createHash('sha1')
.update(key + MAGIC_STRING)
.digest('base64');
// accept === 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='数据帧格式
握手完成后,通信不再使用 HTTP,而是通过 WebSocket 帧(Frame) 传输数据:
WebSocket 帧结构(RFC 6455):
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) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data (continued) |
+---------------------------------------------------------------+各字段含义
FIN(1 bit):
1 = 这是消息的最后一个帧(或唯一帧)
0 = 后面还有后续帧(消息分片)
RSV1/RSV2/RSV3(各 1 bit):
保留位,默认为 0
扩展(如 permessage-deflate 压缩)使用 RSV1
opcode(4 bit):帧类型
0x0 继续帧(Continuation)—— 分片消息的后续帧
0x1 文本帧(Text)—— UTF-8 编码的文本数据
0x2 二进制帧(Binary)—— 任意二进制数据
0x8 关闭帧(Close)—— 请求关闭连接
0x9 Ping 帧 —— 心跳探测
0xA Pong 帧 —— 心跳响应
MASK(1 bit):
客户端 → 服务器的帧必须设为 1(必须掩码)
服务器 → 客户端的帧必须设为 0(不掩码)
掩码用于防止缓存污染攻击(恶意数据被代理服务器缓存)
Payload length(7 bit / 7+16 bit / 7+64 bit):
0~125 → 直接表示长度
126 → 后续 2 字节表示实际长度(最大 65535 字节)
127 → 后续 8 字节表示实际长度(最大 2^63 字节)
Masking-key(0 或 4 字节):
MASK=1 时存在,4 字节的掩码密钥
解码算法:payload[i] XOR mask[i % 4]
Payload Data:
实际传输的数据帧类型分类
数据帧:
┌────────────────────────────────────────────────┐
│ 文本帧(opcode=0x1) │
│ 传输 UTF-8 文本(JSON、纯文本等) │
│ 最常用的帧类型 │
│ │
│ 二进制帧(opcode=0x2) │
│ 传输任意二进制数据(图片、音频、Protobuf 等) │
└────────────────────────────────────────────────┘
控制帧:
┌────────────────────────────────────────────────┐
│ Close 帧(opcode=0x8) │
│ 请求关闭连接,可携带状态码和原因 │
│ 收到 Close 帧后必须回复 Close 帧 │
│ │
│ Ping 帧(opcode=0x9) │
│ 心跳探测,可携带应用数据(≤125 字节) │
│ 收到 Ping 必须回复 Pong │
│ │
│ Pong 帧(opcode=0xA) │
│ 心跳响应,必须携带与 Ping 相同的应用数据 │
└────────────────────────────────────────────────┘
消息分片(Fragmentation):
大消息可以拆分为多个帧发送:
第一帧:FIN=0, opcode=0x1(文本帧,表示消息类型)
中间帧:FIN=0, opcode=0x0(继续帧)
最后帧:FIN=1, opcode=0x0(继续帧,FIN=1 表示结束)
接收方将所有帧的 payload 拼接后得到完整消息浏览器 API
WebSocket 构造函数
js
const ws = new WebSocket('wss://example.com/chat');
// ws:// → 非加密(类似 HTTP)
// wss:// → TLS 加密(类似 HTTPS),生产环境必须使用readyState 状态
WebSocket.CONNECTING = 0 连接正在建立(握手中)
WebSocket.OPEN = 1 连接已建立,可以通信
WebSocket.CLOSING = 2 连接正在关闭
WebSocket.CLOSED = 3 连接已关闭事件与方法
js
const ws = new WebSocket('wss://example.com/chat');
ws.onopen = (event) => {
console.log('连接已建立');
ws.send('Hello Server!');
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
const buffer = new ArrayBuffer(8);
ws.send(buffer);
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
console.log('收到文本消息:', message);
} else if (event.data instanceof Blob) {
console.log('收到二进制消息:', event.data);
}
};
ws.onerror = (event) => {
console.error('连接错误');
};
ws.onclose = (event) => {
console.log('连接关闭:', event.code, event.reason);
// event.code → 状态码(1000=正常关闭, 1006=异常断开)
// event.reason → 关闭原因(字符串)
// event.wasClean → 是否正常关闭
};
// 主动关闭
ws.close(1000, '用户主动断开');常见关闭状态码
1000 正常关闭
1001 端点离开(页面关闭、服务器关机)
1002 协议错误
1003 收到不支持的数据类型
1005 未收到状态码(保留,不应出现在 Close 帧中)
1006 异常关闭(未收到 Close 帧,如网络断开)
1007 数据类型不一致(如文本帧中非 UTF-8 数据)
1008 策略违规
1009 消息过大
1010 客户端期望的扩展未被服务器支持
1011 服务器内部错误完整聊天室示例
js
class ChatClient {
constructor(url) {
this.url = url;
this.ws = null;
this.messageHandlers = new Set();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('[Chat] 已连接');
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.messageHandlers.forEach((handler) => handler(message));
};
this.ws.onclose = (event) => {
console.log(`[Chat] 连接关闭: ${event.code}`);
};
this.ws.onerror = () => {
console.error('[Chat] 连接错误');
};
}
send(type, payload) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
}
}
onMessage(handler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
disconnect() {
this.ws?.close(1000, '用户离开');
}
}
const chat = new ChatClient('wss://example.com/chat');
chat.connect();
chat.onMessage((msg) => console.log('收到:', msg));
chat.send('chat', { text: '你好!' });心跳保活机制
为什么需要心跳
WebSocket 连接建立后,如果长时间没有数据传输,连接可能被中间设备断开:
1. NAT 超时
家用路由器/运营商 NAT 设备维护连接映射表
如果一段时间(通常 1~5 分钟)没有数据包经过,NAT 条目被清除
→ 后续数据包无法路由 → 连接实质断开,但双方不知道
2. 代理/负载均衡超时
Nginx 默认 proxy_read_timeout 60s
AWS ALB 默认 idle timeout 60s
如果超时未收到数据,代理主动关闭连接
3. 检测断线
TCP 层的 keep-alive 探测间隔太长(默认 2 小时)
应用层无法及时发现"连接已经死了"的情况
→ 用户看到界面正常,但消息发不出去也收不到
解决方案:定期发送心跳包,保持连接活跃 + 检测连接状态协议层心跳:Ping/Pong
WebSocket 协议内建了 Ping/Pong 机制:
服务器 → 客户端:发送 Ping 帧(opcode=0x9)
客户端 → 服务器:自动回复 Pong 帧(opcode=0xA)
浏览器的 WebSocket API 会自动响应 Ping 帧(无需开发者处理)
但浏览器不暴露发送 Ping 帧的 API
→ 协议层 Ping/Pong 通常由服务器端发起
Node.js 服务端示例(使用 ws 库):js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(interval);
});应用层心跳
浏览器端无法发送协议层 Ping,因此实践中常用"应用层心跳":
通过 send() 发送自定义心跳消息,约定消息格式
优势:
- 客户端和服务端都可以主动发起
- 可以携带业务数据(如在线状态、延迟检测)
- 不依赖底层协议实现js
class HeartbeatWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.heartbeatTimer = null;
this.heartbeatTimeout = null;
this.HEARTBEAT_INTERVAL = 30000;
this.HEARTBEAT_TIMEOUT = 5000;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.resetHeartbeatTimeout();
return;
}
this.handleMessage(data);
};
this.ws.onclose = () => {
this.stopHeartbeat();
};
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
this.heartbeatTimeout = setTimeout(() => {
console.warn('心跳超时,关闭连接');
this.ws.close();
}, this.HEARTBEAT_TIMEOUT);
}
}, this.HEARTBEAT_INTERVAL);
}
resetHeartbeatTimeout() {
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
this.resetHeartbeatTimeout();
}
handleMessage(data) {
console.log('业务消息:', data);
}
}断线重连策略
网络波动、服务器重启等都会导致 WebSocket 断线。生产环境必须实现自动重连。
指数退避算法(Exponential Backoff)
为什么不能立即重连?
服务器宕机后,如果所有客户端同时重连 → 瞬间涌入大量连接 → 服务器雪崩
指数退避:每次重连等待时间翻倍 + 随机抖动
第 1 次重连:等待 1s + 随机 0~500ms
第 2 次重连:等待 2s + 随机 0~500ms
第 3 次重连:等待 4s + 随机 0~500ms
第 4 次重连:等待 8s + 随机 0~500ms
第 5 次重连:等待 16s + 随机 0~500ms
第 6 次重连:等待 30s(达到上限)+ 随机 0~500ms
...
公式:delay = min(baseDelay × 2^attempt, maxDelay) + random(0, jitter)
随机抖动(Jitter)的作用:
打散重连时间点,避免"惊群效应"(所有客户端同一时刻重连)完整重连实现
js
class ReconnectWebSocket {
constructor(url, options = {}) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.baseDelay = options.baseDelay ?? 1000;
this.maxDelay = options.maxDelay ?? 30000;
this.jitter = options.jitter ?? 500;
this.forceClosed = false;
this.messageQueue = [];
this.onMessageCallback = null;
this.onStateChange = null;
}
connect() {
this.forceClosed = false;
this._updateState('CONNECTING');
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this._updateState('OPEN');
this._flushMessageQueue();
};
this.ws.onmessage = (event) => {
this.onMessageCallback?.(JSON.parse(event.data));
};
this.ws.onclose = (event) => {
this._updateState('CLOSED');
if (!this.forceClosed && event.code !== 1000) {
this._reconnect();
}
};
this.ws.onerror = () => {
this.ws.close();
};
}
send(data) {
const message = JSON.stringify(data);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
this.messageQueue.push(message);
}
}
close() {
this.forceClosed = true;
this.ws?.close(1000, '主动关闭');
}
_reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this._updateState('FAILED');
console.error(`已达最大重试次数 (${this.maxReconnectAttempts}),停止重连`);
return;
}
const delay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts),
this.maxDelay
);
const actualDelay = delay + Math.random() * this.jitter;
this.reconnectAttempts++;
this._updateState('RECONNECTING');
console.log(`第 ${this.reconnectAttempts} 次重连,等待 ${Math.round(actualDelay)}ms`);
setTimeout(() => {
if (!this.forceClosed) {
this.connect();
}
}, actualDelay);
}
_flushMessageQueue() {
while (this.messageQueue.length > 0) {
this.ws.send(this.messageQueue.shift());
}
}
_updateState(state) {
this.onStateChange?.(state);
}
}
const ws = new ReconnectWebSocket('wss://example.com/chat', {
maxReconnectAttempts: 10,
baseDelay: 1000,
maxDelay: 30000,
});
ws.onMessageCallback = (data) => console.log('收到:', data);
ws.onStateChange = (state) => console.log('状态:', state);
ws.connect();
ws.send({ type: 'chat', text: '你好' });与其他实时通信方案对比
| 特性 | WebSocket | SSE (Server-Sent Events) | HTTP 长轮询 | HTTP 短轮询 |
|---|---|---|---|---|
| 通信方向 | 全双工(双向) | 单向(服务器→客户端) | 单向(服务器→客户端) | 单向(服务器→客户端) |
| 底层协议 | 独立协议(ws://) | HTTP | HTTP | HTTP |
| 连接数 | 1 个持久连接 | 1 个持久连接 | 每次响应后重建 | 每次请求新连接 |
| 数据格式 | 文本 / 二进制 | 纯文本(UTF-8) | 任意 | 任意 |
| 浏览器支持 | 所有现代浏览器 | 除 IE 外所有 | 所有浏览器 | 所有浏览器 |
| 自动重连 | ❌ 需手动实现 | ✅ 浏览器原生支持 | ❌ 需手动实现 | ❌ 需手动实现 |
| 跨域 | 需服务器验证 Origin | 遵循 CORS | 遵循 CORS | 遵循 CORS |
| 代理/防火墙兼容 | ⚠️ 可能被拦截 | ✅ 普通 HTTP | ✅ 普通 HTTP | ✅ 普通 HTTP |
| 服务器资源消耗 | 低(帧开销小) | 低 | 中(挂起连接) | 高(频繁建连) |
| 适用场景 | 聊天、游戏、协同编辑 | 通知推送、实时数据流 | 简单通知、兼容性要求高 | 简单场景、低频更新 |
选型建议:
需要双向通信(聊天、游戏、协同编辑)
→ WebSocket
只需服务器推送(新闻推送、股票行情、日志流)
→ SSE(更简单,原生支持重连和事件 ID)
需要最大兼容性(老旧浏览器/企业环境/严格防火墙)
→ 长轮询
更新频率低、实时性要求不高
→ 短轮询(实现最简单)SSE 简要对比示例
js
// SSE —— 只需 3 行代码
const source = new EventSource('/api/notifications');
source.onmessage = (event) => {
console.log('通知:', JSON.parse(event.data));
};
// 浏览器自动重连、自动从 Last-Event-ID 恢复
// WebSocket —— 需要手动管理连接
const ws = new WebSocket('wss://example.com/notifications');
ws.onmessage = (event) => {
console.log('通知:', JSON.parse(event.data));
};
// 需要手动实现重连、心跳、状态管理Socket.IO
Socket.IO 是基于 WebSocket 的实时通信库,提供了大量生产级功能。
核心功能
Socket.IO 在原生 WebSocket 之上提供:
1. 自动降级(Transport Fallback)
WebSocket 不可用时自动降级为 HTTP 长轮询
→ 适应严格防火墙/老旧代理环境
2. 自动重连
内建指数退避重连策略
断线期间的消息会在重连后自动发送(buffered events)
3. 房间(Rooms)
服务端可以将 socket 加入/移出房间
支持向特定房间广播消息
4. 命名空间(Namespaces)
在同一个连接上隔离不同的通信通道
如 /chat 和 /notifications 使用不同的命名空间
5. 确认机制(Acknowledgements)
发送消息后可以收到对方的确认回调
类似 HTTP 的请求-响应模式
6. 二进制支持
自动处理 ArrayBuffer / Blob 的序列化与原生 WebSocket 的区别
原生 WebSocket Socket.IO
───────────── ─────────
协议 标准 WebSocket 协议 自定义协议(基于 WebSocket + 降级)
传输层 仅 WebSocket WebSocket + HTTP 长轮询
互操作性 任何 WebSocket 服务端 必须使用 Socket.IO 服务端
消息格式 自定义 内建事件系统(emit/on)
重连 手动实现 自动
房间/命名空间 无 内建
包体积 0(浏览器原生) ~48KB (gzip ~16KB)
选择建议:
- 需要与第三方 WebSocket 服务通信 → 原生 WebSocket
- 对包体积敏感的轻量场景 → 原生 WebSocket
- 需要房间/命名空间/可靠投递 → Socket.IO
- 需要兼容严格网络环境 → Socket.IO基本使用示例
js
// 客户端
import { io } from 'socket.io-client';
const socket = io('wss://example.com', {
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
auth: { token: 'jwt-token-here' },
});
socket.on('connect', () => {
console.log('已连接:', socket.id);
});
socket.emit('join-room', { room: 'general' });
socket.emit('chat-message', { text: '你好!' }, (ack) => {
console.log('服务器确认:', ack);
});
socket.on('chat-message', (data) => {
console.log('收到消息:', data);
});
socket.on('disconnect', (reason) => {
console.log('断开:', reason);
});js
// 服务端(Node.js)
const { Server } = require('socket.io');
const io = new Server(3000, {
cors: { origin: 'https://example.com' },
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (verifyToken(token)) {
next();
} else {
next(new Error('认证失败'));
}
});
io.on('connection', (socket) => {
console.log('新连接:', socket.id);
socket.on('join-room', ({ room }) => {
socket.join(room);
socket.to(room).emit('user-joined', { userId: socket.id });
});
socket.on('chat-message', (data, callback) => {
const room = [...socket.rooms][1];
socket.to(room).emit('chat-message', {
...data,
from: socket.id,
timestamp: Date.now(),
});
callback({ status: 'ok' });
});
socket.on('disconnect', () => {
console.log('断开:', socket.id);
});
});安全
wss:// 加密
ws:// → 明文传输,类似 HTTP
wss:// → TLS 加密传输,类似 HTTPS
生产环境必须使用 wss://,原因:
1. 防止中间人窃听/篡改 WebSocket 数据
2. 很多代理/防火墙会拦截非加密的 ws:// 连接
3. 如果页面是 HTTPS,浏览器会阻止连接 ws://(Mixed Content)
wss:// 的 TLS 握手和 HTTPS 完全相同
→ 复用 443 端口
→ 使用同一张 SSL 证书
→ 对中间设备来说,wss 流量和 HTTPS 流量无法区分Origin 验证
WebSocket 握手是一个 HTTP 请求,浏览器会自动附带 Origin 头:
GET /chat HTTP/1.1
Origin: https://evil-site.com ← 浏览器自动设置,JS 无法伪造
服务器必须验证 Origin:
const allowedOrigins = ['https://example.com', 'https://app.example.com'];
wss.on('headers', (headers, req) => {
const origin = req.headers.origin;
if (!allowedOrigins.includes(origin)) {
req.destroy(); // 拒绝连接
}
});
为什么 Origin 验证很重要:
- 防止跨站 WebSocket 劫持(Cross-Site WebSocket Hijacking, CSWSH)
- 恶意网站可以在用户不知情的情况下向你的 WebSocket 服务发起连接
- 如果不验证 Origin,恶意站点可以利用用户的 Cookie 建立认证连接Token 认证
WebSocket 不能使用标准 HTTP 认证头的原因:
浏览器的 WebSocket API 不支持设置自定义请求头:
new WebSocket('wss://example.com/chat');
// 没有办法传入 Authorization: Bearer <token>
// 这不是 fetch,没有 headers 选项
Cookie 方案的问题:
- 跨域时受 SameSite 策略限制
- 容易被 CSWSH 攻击利用(恶意站点可以借用用户 Cookie)
- 需要额外的 CSRF 防护
推荐方案:通过 URL 参数或首条消息传递 Token方案一:URL 参数传递(最常用)
js
const token = getAuthToken();
const ws = new WebSocket(`wss://example.com/chat?token=${token}`);js
// 服务端验证
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'wss://example.com');
const token = url.searchParams.get('token');
try {
const user = verifyJWT(token);
ws.userId = user.id;
} catch {
ws.close(1008, '认证失败');
}
});注意:Token 会出现在 URL 中
- 可能被记录到服务器访问日志
- 使用短期 Token(如 5 分钟有效)降低泄露风险
- 或者使用一次性 Token(用后即失效)方案二:连接后首条消息认证
js
const ws = new WebSocket('wss://example.com/chat');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
};js
// 服务端:在认证完成前拒绝其他消息
wss.on('connection', (ws) => {
ws.isAuthenticated = false;
const authTimeout = setTimeout(() => {
ws.close(1008, '认证超时');
}, 5000);
ws.on('message', (data) => {
const message = JSON.parse(data);
if (!ws.isAuthenticated) {
if (message.type === 'auth') {
try {
const user = verifyJWT(message.token);
ws.userId = user.id;
ws.isAuthenticated = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_ok' }));
} catch {
ws.close(1008, '认证失败');
}
} else {
ws.close(1008, '未认证');
}
return;
}
handleMessage(ws, message);
});
});方案三:Socket.IO 的 auth 选项
js
const socket = io('wss://example.com', {
auth: { token: getAuthToken() },
});
// 服务端通过中间件验证
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
socket.user = verifyJWT(token);
next();
} catch {
next(new Error('认证失败'));
}
});面试高频题
1. WebSocket 和 HTTP 有什么关系?WebSocket 连接是如何建立的?
WebSocket 和 HTTP 是两个不同的协议,但 WebSocket 的连接建立依赖 HTTP。握手流程:客户端发送一个带有 Upgrade: websocket 和 Connection: Upgrade 头的 HTTP GET 请求,同时携带 Sec-WebSocket-Key(随机 Base64 值)和 Sec-WebSocket-Version: 13。服务器如果同意升级,返回 101 Switching Protocols,并在响应中包含 Sec-WebSocket-Accept(将客户端的 Key 拼接魔术字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 后做 SHA-1 + Base64)。握手完成后,底层 TCP 连接保持不变,但协议从 HTTP 切换为 WebSocket 的二进制帧协议。之所以复用 HTTP 握手,是为了兼容现有的 80/443 端口和代理基础设施。
2. WebSocket 的心跳机制是怎么实现的?为什么需要心跳?
心跳的目的有三个:①保持 NAT/代理的连接映射不超时(NAT 通常 1~5 分钟超时、Nginx 默认 60 秒超时);②及时检测死连接(TCP keep-alive 默认 2 小时太慢);③确认对端仍然存活。实现分两层:协议层——WebSocket 内建 Ping/Pong 帧,服务器发送 Ping,客户端自动回复 Pong;应用层——通过 send() 发送自定义心跳消息(如 {type:'ping'}),适用于浏览器端(浏览器 API 无法主动发 Ping 帧)。实践中常用应用层心跳,配合超时检测:发送 ping 后启动定时器,若超时未收到 pong 则判定连接已断,主动关闭并触发重连。心跳间隔通常 15~30 秒。
3. WebSocket 断线重连如何实现?为什么要用指数退避?
断线重连核心:监听 onclose 事件,如果不是主动关闭(code !== 1000)则自动重连。使用**指数退避(Exponential Backoff)的原因:如果服务器宕机后所有客户端同时重连,瞬间的连接风暴会导致服务器再次崩溃。指数退避让每次重连等待时间翻倍(如 1s → 2s → 4s → 8s),再加上随机抖动(Jitter)**打散客户端的重连时间点,避免惊群效应。实现要点:①记录重试次数,计算 min(base × 2^attempt, maxDelay) + random(jitter);②设置最大重试次数(如 10 次),超过则停止重连并通知用户;③连接成功后重置计数器;④断线期间的消息放入队列,重连成功后按序发送。
4. WebSocket 如何做身份认证?为什么不直接用 Cookie?
浏览器的 WebSocket API 不支持设置自定义请求头(没有 headers 选项),因此无法通过 Authorization 头传 Token。Cookie 虽然会自动携带,但有安全隐患:①跨站场景下受 SameSite 限制;②容易被 **CSWSH(跨站 WebSocket 劫持)**利用——恶意网站打开到你服务器的 WebSocket 连接,浏览器自动附带用户 Cookie,攻击者就能以用户身份通信。推荐方案:URL 参数传 Token(wss://example.com/chat?token=xxx),服务端在握手阶段验证 Token 有效性,无效则拒绝连接。注意 URL Token 可能被日志记录,应使用短期或一次性 Token。另一种方案是连接建立后通过首条消息传 Token,服务端在认证前拒绝其他消息。
5. WebSocket 与 SSE(Server-Sent Events)如何选型?各自的优劣是什么?
SSE 是基于 HTTP 的单向推送协议,服务器通过持久的 HTTP 连接持续发送事件流。优势:①浏览器原生支持自动重连和 Last-Event-ID 恢复;②基于 HTTP,对代理/防火墙友好;③API 极简(3 行代码)。劣势:①只支持服务器→客户端的单向通信;②只支持 UTF-8 文本;③IE 不支持。WebSocket 支持全双工双向通信和二进制数据,适合需要客户端频繁发消息的场景。选型原则:只需服务器推送(通知、行情、日志流)选 SSE;需要双向通信(聊天、游戏、协同编辑)选 WebSocket。如果对兼容性和简单性要求高,且不需要客户端发消息,SSE 几乎总是更好的选择。
追问思考
- WebSocket 握手时的
Sec-WebSocket-Key和Sec-WebSocket-Accept的设计目的是什么?它能防止什么攻击?为什么说它不提供安全保障? - WebSocket 帧中为什么要求客户端→服务器的数据必须掩码(Masking),而服务器→客户端不需要?这个设计解决了什么问题?
- 在微服务架构中,WebSocket 连接如何处理负载均衡?如果用户被分配到不同的 WebSocket 服务器实例,消息如何路由?
- WebSocket 的
permessage-deflate扩展是什么?它如何压缩数据?什么时候应该开启,什么时候不应该? - 如果在一个大规模系统中需要支持百万级 WebSocket 长连接,从服务端架构角度需要考虑哪些问题(连接数限制、内存、消息广播效率)?