WebSocket 实现原理详解
从协议规范 (RFC 6455) 到 OS 内核 Socket,一次性讲清楚
1. 为什么需要 WebSocket
HTTP 的"半双工"困境
传统 HTTP 协议是请求-响应模型:客户端发请求,服务端回响应。服务端不能主动推送数据给客户端。这就催生了各种"不优雅"的变通方案:
| 方案 | 原理 | 问题 |
|---|---|---|
| 短轮询 (Polling) | 客户端定时发 HTTP 请求查询新数据 | 大量无效请求、高延迟、浪费带宽 |
| 长轮询 (Long Polling) | 客户端请求后,服务端 hold 住直到有数据才响应 | 连接超时处理复杂、每个消息仍需完整 HTTP 头 |
| HTTP Streaming | 服务端持续向响应流写入数据(分块传输) | 代理/防火墙兼容性差、单向(仅服务端推送) |
WebSocket 协议栈位置
┌─────────────────────────────┐
│ 应用层 (Application) │ ← WebSocket 协议 (RFC 6455)
├─────────────────────────────┤
│ 传输层 (Transport) │ ← TCP
├─────────────────────────────┤
│ 网络层 (Internet) │ ← IP
├─────────────────────────────┤
│ 链路层 (Link) │ ← Ethernet / Wi-Fi
└─────────────────────────────┘
WebSocket 位于 OSI 模型的应用层,但它建立在 TCP 之上,与 HTTP 同级 — 只是借用了 HTTP 的 80 / 443 端口来完成初始握手。
2. HTTP 升级握手 (Opening Handshake)
WebSocket 连接的生命始于一次特殊的 HTTP 请求。客户端通过 HTTP Upgrade 机制,将普通 HTTP 连接"升级"为 WebSocket 连接。
协议升级过程
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHN...
Sec-WebSocket-Version: 13
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept:
s3pPLMB...
关键字段逐一解析
| 字段 | 方向 | 含义 |
|---|---|---|
Upgrade: websocket |
双向 | 请求升级为 WebSocket 协议 |
Connection: Upgrade |
双向 | 告知对方这是一个升级请求(HTTP/1.1 必须是 hop-by-hop 头) |
Sec-WebSocket-Key |
C → S | 客户端生成的 16 字节随机数,经 Base64 编码。防止缓存代理误接 WebSocket 连接 |
Sec-WebSocket-Accept |
S → C | 服务端对 Key 的确认值。计算方式:Base64(SHA1(Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) |
Sec-WebSocket-Version |
C → S | 协议版本,当前为 13 |
Sec-WebSocket-Protocol |
可选 | 子协议协商,如 chat, soap, wamp |
Sec-WebSocket-Extensions |
可选 | 扩展协商,如 permessage-deflate(压缩) |
Sec-WebSocket-Accept 的计算过程
伪代码
// 1. 客户端生成随机 16 字节
key = random_bytes(16) // 例如: [0xdG, 0xhl, 0xIH, ...]
key_b64 = base64_encode(key) // → "dGhlIHNhbXBsZSBub25jZQ=="
// 2. 服务端拼接魔术字符串后 SHA1
magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64_encode(sha1(key_b64 + magic))
// → "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
// 3. 客户端本地验证:用同样的 key 计算,对比 Accept 值
// 一致 → 握手成功,不一致 → 断开连接
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 是一个全球唯一的 GUID,确保即使客户端 Key 被攻击者猜到,也无法伪造 Accept 值(因为攻击者不知道服务端会做 SHA1 拼接)。同时它让 WebSocket 握手与普通 HTTP 响应在语义上完全不同,避免与 HTTP 中间件冲突。
握手成功后,TCP 连接不再走 HTTP 协议,之后所有数据都按 WebSocket 帧格式传输。
3. 帧协议 (Frame Protocol) — 核心
握手完成后,所有通信数据都被封装成帧 (Frame)。帧是 WebSocket 的最小传输单元。
3.1 帧格式完整图解
3.2 逐字段详解
| 字段 | 位数 | 说明 |
|---|---|---|
FIN | 1 bit | 是否为消息的最后一个分片帧。1 = 最后一帧 |
RSV1/2/3 | 3 bit | 保留位,用于扩展(如压缩标记)。通常为 0 |
Opcode | 4 bit | 帧类型,见下方表格 |
MASK | 1 bit | Payload 是否经过掩码处理。客户端→服务端必须为 1,服务端→客户端必须为 0 |
Payload len | 7 bit | 载荷长度:0-125=实际长度;126=后续 2 字节是长度;127=后续 8 字节是长度 |
Masking-Key | 0/32 bit | 当 MASK=1 时出现,4 字节随机密钥,用于对 Payload 做 XOR 解码 |
Payload Data | 变长 | 实际传输的数据(文本 UTF-8、二进制等) |
3.3 Opcode(操作码)一览
| Opcode | 含义 | 方向 | 说明 |
|---|---|---|---|
0x0 | Continuation | 双向 | 分片消息的后续帧 |
0x1 | Text | 双向 | 文本帧(UTF-8 编码) |
0x2 | Binary | 双向 | 二进制帧 |
0x8 | Close | 双向 | 关闭连接 |
0x9 | Ping | 双向 | 心跳探测 |
0xA | Pong | 双向 | 心跳响应 |
0x3-0x7 | — | — | 保留,用于将来的非控制帧 |
0xB-0xF | — | — | 保留,用于将来的控制帧 |
3.4 掩码 (Masking) — 防止缓存投毒攻击
客户端发往服务端的每一帧必须加掩码。原因:2011 年安全研究者发现,如果浏览器发送的 WebSocket 帧不加密,恶意代理可能将帧内容解释为 HTTP 请求,造成缓存投毒 (Cache Poisoning)。
掩码算法 (客户端编码 / 服务端解码)
// 客户端:对 Payload 做 XOR
masking_key = random_4_bytes()
for i in range(len(payload)):
payload[i] = payload[i] ^ masking_key[i % 4]
// 服务端:用同样的 key 再做一次 XOR 即恢复原文
// 因为 A ^ K ^ K = A
for i in range(len(payload)):
payload[i] = payload[i] ^ masking_key[i % 4]
A ⊕ K ⊕ K = A(自逆性),所以编码和解码使用完全相同的算法。同时每次帧的掩码密钥随机生成,即便是相同的 Payload,每次传输的密文也完全不同,彻底杜绝了缓存投毒的可能性。
3.5 分片 (Fragmentation)
当一条消息过大时,可以拆分成多个帧发送:
帧 1: FIN=0 Opcode=Text Payload="Hello, this is a ve"
帧 2: FIN=0 Opcode=0x0 Payload="ry long message that spa"
帧 3: FIN=1 Opcode=0x0 Payload="ns multiple frames."
分片允许流式发送——不需要在发送前就知道整个消息的大小。这对于实时转码、大文件传输等场景非常有用。
4. 连接生命周期
CONNECTING (0) — TCP 三次握手
new WebSocket("ws://...") 触发 DNS 解析 + TCP SYN → SYN-ACK → ACK,建立底层 TCP 连接。
OPENING — HTTP Upgrade 握手
客户端发送 Upgrade 请求,服务端返回 101。握手成功后,readyState 变为 OPEN。
OPEN (1) — 数据传输
双方通过帧协议自由收发数据。可以同时进行(全双工)。onmessage 事件持续触发。
CLOSING (2) — 关闭握手
任一方发送 Close 帧 (Opcode 0x8),另一方回复 Close 帧确认。Close 帧可包含 2 字节状态码 + 原因文本。
CLOSED (3) — TCP 四次挥手
底层 TCP 连接关闭,释放端口资源。onclose 事件触发。
Close 帧状态码
| 状态码 | 含义 |
|---|---|
1000 | 正常关闭 |
1001 | 端点离开(如页面关闭、服务器关闭) |
1002 | 协议错误 |
1003 | 收到不支持的数据类型 |
1006 | 连接异常关闭(无 Close 帧就断开) |
1007 | 数据格式不合法(如非 UTF-8 文本) |
1008 | 策略冲突 |
1009 | 消息过大 |
1011 | 服务端内部错误 |
1012 | 服务重启 |
5. 全双工通信 — 它是如何做到的
核心:TCP 本身就是全双工的
TCP 连接在建立后,双方可以同时、独立地发送数据。每个方向都有自己的序列号 (SEQ) 和确认号 (ACK),互不干扰。
客户端 服务端
│ │
│ ─── SEQ=1001 "Hello" ──────────────→ │
│ ←──────── SEQ=5001 "Hi" ──────────────── │
│ │
│ ← 两条消息可以同时在链路上传输 → │
│ (不同方向,互不阻塞) │
│ │
│ ─── SEQ=1007 "World" ───────────────→ │
│ ←──────── SEQ=5004 "Bye" ──────────────── │
WebSocket 做了什么额外工作?
WebSocket 在 TCP 之上加了帧边界:
onmessage(msg) 回调,交给上层应用处理。6. 方案全面对比
| 特性 | 短轮询 | 长轮询 | SSE | WebSocket |
|---|---|---|---|---|
| 通信方向 | 单向(请求-响应) | 单向 | 单向(S→C) | 双向全双工 |
| 协议 | HTTP | HTTP | HTTP | WebSocket (ws/wss) |
| 头部开销 | 大(每次完整 HTTP 头) | 大 | 中 | 极小(2-10 字节帧头) |
| 实时性 | 差 | 中 | 好 | 极好 |
| 服务端推送 | 模拟 | 模拟 | 原生支持 | 原生支持 |
| 二进制支持 | 需编码 | 需编码 | 仅文本 | 原生二进制帧 |
| 连接数 | 极多 | 多 | 少 | 极少(复用单连接) |
| 浏览器兼容 | 全部 | 全部 | 除 IE 外全部 | 所有现代浏览器 |
| 自动重连 | 无 | 无 | 内置 | 需自行实现 |
| 代理/防火墙友好 | 是 | 是 | 是 | WSS 是(加密后代理不可见) |
7. 代码实现 — 从浏览器到服务器
7.1 浏览器端 (Client)
JavaScript (Browser)
// 创建连接
const ws = new WebSocket('wss://echo.websocket.org');
// 连接打开
ws.onopen = () => {
console.log('✅ 连接已建立');
ws.send('Hello Server!'); // 发送文本
ws.send(new Uint8Array([1,2,3])); // 发送二进制
};
// 接收消息
ws.onmessage = (event) => {
console.log('📩 收到:', event.data);
// event.data 类型: String (文本帧) 或 Blob/ArrayBuffer (二进制帧)
};
// 错误处理
ws.onerror = (err) => console.error('❌ 错误:', err);
// 连接关闭
ws.onclose = (event) => {
console.log(`🔒 关闭: code=${event.code} reason=${event.reason}`);
};
// 主动关闭
ws.close(1000, '正常关闭');
7.2 Node.js 服务端 (原生实现思路)
Node.js (简化示意)
const crypto = require('crypto');
const net = require('net');
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const server = net.createServer(socket => {
socket.once('data', data => {
const req = data.toString();
// 1. 解析 Sec-WebSocket-Key
const keyMatch = req.match(/Sec-WebSocket-Key: (.+)\r\n/);
if (!keyMatch) { socket.destroy(); return; }
// 2. 计算 Accept
const accept = crypto
.createHash('sha1')
.update(keyMatch[1].trim() + MAGIC)
.digest('base64');
// 3. 返回 101 响应
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n` +
'\r\n'
);
console.log('🤝 WebSocket 握手完成');
// 此后走帧协议...
});
});
server.listen(8080);
7.3 帧解析 (核心逻辑)
帧解码器
function decodeFrame(buffer) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const FIN = (firstByte & 0x80) >> 7; // 第 0 位
const opcode = firstByte & 0x0F; // 低 4 位
const MASK = (secondByte & 0x80) >> 7; // 第 0 位
let len = secondByte & 0x7F; // 低 7 位
let offset = 2;
// 扩展长度
if (len === 126) {
len = buffer.readUInt16BE(offset);
offset += 2;
} else if (len === 127) {
// 注意:JavaScript 中 Number 只能安全表示到 2^53-1
len = Number(buffer.readBigUInt64BE(offset));
offset += 8;
}
// 掩码密钥
let maskKey;
if (MASK) {
maskKey = buffer.slice(offset, offset + 4);
offset += 4;
}
// 载荷数据
let payload = buffer.slice(offset, offset + len);
// XOR 解掩码
if (MASK) {
for (let i = 0; i < payload.length; i++) {
payload[i] ^= maskKey[i % 4];
}
}
return { FIN, opcode, payload };
}
8. 底层原理 — 从内核看 WebSocket
8.1 TCP Socket 基础
WebSocket 的底层就是一个普通的 TCP Socket。在 Linux 内核中,每个 TCP 连接由以下数据结构承载:
8.2 服务端架构模式
| 模式 | 原理 | 适用规模 |
|---|---|---|
| 单线程 + epoll | 一个线程通过 epoll/kqueue 多路复用管理所有连接。C 语言高性能实现如 libwebsockets。 | 数万连接 |
| Event Loop (Node.js) | 单线程事件循环 + 非阻塞 I/O。ws 库底层使用 net.Socket,通过 libuv 与内核 epoll 交互。 | 数千-数万连接 |
| 多线程/协程 (Go) | goroutine 每连接模型,配合 Go netpoller(基于 epoll 的异步 I/O)。goroutine 在 I/O 等待时自动挂起。 | 数十万连接 |
| 多进程 + 共享端口 (SO_REUSEPORT) | 多个进程绑定同一端口,内核将新连接均匀分发到各进程。Nginx 反向代理 + 后端 WebSocket 服务集群。 | 百万级连接 |
8.3 数据流转全过程
浏览器 JS 调用: 服务端处理链:
ws.send("hello") 用户代码: conn.on('message', ...)
│ ▲
▼ │
浏览器 WebSocket 实现 帧解码器 (decodeFrame)
· 编码为 Text 帧 · XOR 解掩码
· MASK + XOR 掩码 · 校验 UTF-8
· 写入 TCP Send Buffer · 组装完整消息
│ ▲
▼ │
┌─────────────┐ ┌─────────────┐
│ 内核 TCP │ ── IP 网络 ──→ │ 内核 TCP │
│ · 分段 │ │ · 接收重组 │
│ · 拥塞控制 │ │ · ACK 确认 │
│ · 超时重传 │ │ · 存入 Recv │
└─────────────┘ └─────────────┘
▲
│
epoll 通知: socket 可读
→ Event Loop 调度回调
8.4 内存与缓冲
每个 WebSocket 连接涉及的内存结构:
- 内核 Recv Buffer:默认约 87KB (Linux),可通过
SO_RCVBUF调整 - 内核 Send Buffer:默认约 16KB,可通过
SO_SNDBUF调整 - 用户态帧缓冲区:应用程序用于组装分片消息的 buffer
- 连接元数据:URL、子协议、扩展信息、自定义状态等
以百万连接为例:假设每连接用户态 10KB,内核态 200KB,总计约 210GB 内存。这也是为什么大规模 WebSocket 服务需要精细的 buffer 管理和连接池设计。
9. 安全与优化
9.1 WSS (WebSocket Secure)
wss:// 即 WebSocket over TLS。工作流程:
9.2 心跳保活 (Ping/Pong)
服务端心跳示例
const HEARTBEAT_INTERVAL = 30000; // 30 秒
const interval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping(); // 发送 Ping 帧 (Opcode 0x9)
}
}, HEARTBEAT_INTERVAL);
ws.on('pong', () => {
// 收到 Pong (Opcode 0xA),连接正常
ws.isAlive = true;
});
Ping/Pong 是协议层面的控制帧(Opcode 0x9/0xA),不会被 onmessage 拦截。它们在帧头中明确区分于数据帧,可以穿插在正常数据流中发送。如果连续几次 Ping 没有 Pong 响应,即可判定连接已断开。
9.3 常见优化策略
| 策略 | 说明 |
|---|---|
| permessage-deflate | 消息级压缩扩展,对 Text 帧压缩率可达 60-80%。需在握手时协商 |
| 连接池 | 客户端维护多个 ws 连接,负载均衡分发消息 |
| 消息合并 | 将短时间内的多条小消息合并为一个 Binary 帧发送,减少帧头开销 |
| 自动重连 + 指数退避 | 断开后延迟重连:1s → 2s → 4s → 8s ... 最大 30s |
| 二进制协议 | 对于高频数据(如游戏状态同步),使用 Protobuf/MessagePack 替代 JSON,减少序列化开销 |
| 水平扩展 | 通过 Redis Pub/Sub 或 Kafka 在 WebSocket 服务集群间广播消息 |
10. 在线演示 — 帧的构造与解析
帧构造器 (Frame Builder)
输入消息,模拟浏览器构造 WebSocket 帧的过程。可以看到完整的帧字节结构。
协议模拟 — 握手 + 消息收发
模拟一次完整的 WebSocket 通信过程。
WebSocket Protocol — RFC 6455 | 技术深入学习 · 第一性原理