第11讲:WebSocket — 从HTTP升级到全双工实时通信
目录
- 📚 学习导航
- ⚡ 认知冲突
- 一句话定义
- 1 从零开始:HTTP 的"一问一答"困局
- 2 第一次演进:轮询与长轮询
- 3 第二次演进:WebSocket 握手与升级
- 4 第三次演进:WebSocket 帧协议与数据交换
- 总结
- 场景决策指南
- 技术生态位
- 自测卡片
- 🎮 上瘾学习路径
⏱️ 预计阅读时间:18 分钟
📚 学习导航
| 前置知识 | 核心问题 | 预计收获 | 阅读路径 |
|---|---|---|---|
| 第 3 讲(HTTP 连接管理)、第 1 讲(TCP 连接) | WebSocket 和 HTTP 是什么关系?它为什么能实现实时通信? | 理解 WebSocket 的握手升级机制、帧协议设计,以及它和 HTTP/2 流、SSE 的异同 | 快速:P0→P2→P3;深度:全读 |
⚡ 认知冲突
💡 你可能不知道:WebSocket 连接始于一个标准的 HTTP 请求——它使用 HTTP Upgrade 头部从 HTTP/1.1 协议"升级"而来。但握手完成后,HTTP 协议就彻底退出了,连接进入一个全新的、与 HTTP 完全不同的二进制帧协议。
更有意思的是:WebSocket 的握手请求可以被任何 HTTP 中间件处理——代理服务器、负载均衡器、反向代理都能参与握手协商。但握手后的 WebSocket 流量,这些中间设备就完全看不懂了。
一句话定义
WebSocket(RFC 6455) 是一种在单个 TCP 连接上提供全双工通信(Full-Duplex Communication)的协议。它通过 HTTP Upgrade 机制从 HTTP 握手升级而来,但升级后的数据传输完全脱离 HTTP 协议,采用轻量的二进制帧格式,允许客户端和服务器随时向对方推送数据。
类比:HTTP 是"对讲机"——你说一句,对方答一句,轮流来。WebSocket 是"电话"——接通后双方可以同时说话、随时说话。WebSocket 的握手就像打电话前的"拨号"——你通过 HTTP 告诉对方"我想切换到电话模式",对方同意后,对讲机就变成了电话。
1 从零开始:HTTP 的"一问一答"困局
HTTP 的先天限制
HTTP 从诞生起就是**请求-响应(Request-Response)**模式:
客户端 服务器
│ │
│── 请求 ──────────────────→ │
│← 响应 ←────────────────── │
│ │
│── 请求 ──────────────────→ │
│← 响应 ←────────────────── │
│ │
永远是这个模式:客户端主动,服务器被动
服务器无法主动向客户端推送数据——它只能在收到客户端请求后响应。
2000 年代初期的"实时"需求
随着互联网发展,越来越多的场景需要服务器主动推送数据:
- 即时聊天:别人给你发了消息,你的浏览器怎么知道?
- 实时股价:股票价格变了,服务器怎么通知浏览器?
- 协作编辑:同事修改了文档,你的页面怎么收到更新?
- 游戏:对手移动了棋子,你的客户端怎么立刻知道?
问题浮现:HTTP 的"一问一答"模型在面对这些场景时,就像用"寄信"的方式聊即时通讯——你不断写信问"有消息吗?",对方不断回信说"没有"——大部分请求和响应都在做无用功。
2 第一次演进:轮询与长轮询
2.1 短轮询(Polling):暴力解法
服务器不能主动推,那就让客户端频繁去"问":
// 每隔 1 秒问一次服务器:"有新消息吗?"
setInterval(async () => {
const response = await fetch('/api/messages');
const data = await response.json();
if (data.length > 0) {
// 有消息了!
updateUI(data);
}
}, 1000);
时间线:
客户端: ──请求──→ ←──空响应── ←──空响应── ←──空响应── ←消息响应──
服务器: (无消息) (无消息) (无消息) (有消息!)
浪费流量 浪费流量 浪费流量 ✅ 终于到了
问题:
- 巨量浪费:99% 的请求都在"空转",带宽和服务器资源被严重浪费
- 延迟不可控:消息到达延迟 = 平均半个轮询间隔(如 1 秒轮询 = 平均 500ms 延迟)
- 服务器压力大:每个客户端每秒发 1 个请求,1 万用户 = 每秒 1 万个请求
2.2 长轮询(Long Polling):改进版
长轮询的思路:服务器收到请求后,如果没有新消息就挂住连接,等有消息了再响应。
客户端 服务器
│ │
│── 请求 ──────────────────→ │ 请求被"挂住"
│ │ 服务器等待消息...
│ │ (连接保持打开)
│ │ (等待中...)
│ │ (等待中...)
│← 有新消息!←────────────── │ ✅ 消息到达,立即响应
│ │
│── 立即发起新的长轮询 ────→ │ 又挂住...
好处:消息到达时几乎零延迟(优于短轮询),服务器只在有消息时才发送数据。
仍然的问题:
- 每个长轮询请求仍然有 HTTP 头部开销(几百字节头部)
- 连接需要保持打开状态,占用服务器连接数
- 实现复杂:请求超时、断线重连、消息顺序都需要处理
- 仍不是真正的"服务器主动推送"——服务器只是在"延迟响应"
核心矛盾:所有这些方案都在绕过 HTTP 的请求-响应模型。它们能用,但本质上是"戴着镣铐跳舞"。要真正解决问题,需要一个根本不是在 HTTP 框架内工作的协议。
3 第二次演进:WebSocket 握手与升级
2011 年:RFC 6455 发布
WebSocket 的核心设计思路:先用 HTTP 完成握手(协商),然后彻底脱离 HTTP 协议。
握手过程
客户端发送一个特殊的 HTTP Upgrade 请求:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket ← 告诉服务器"我想升级到 WebSocket"
Connection: Upgrade ← 这个连接不再使用 HTTP 协议
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 用于校验服务器支持 WebSocket
Sec-WebSocket-Version: 13 ← 协议版本
服务器确认并响应:
HTTP/1.1 101 Switching Protocols ← 101 状态码!"协议切换"
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← 对 client key 的签名证明
关键细节:
- 101 Switching Protocols:HTTP 中极少使用的状态码,唯一用途就在这里
Sec-WebSocket-Key/Sec-WebSocket-Accept:服务器将客户端 Key 加上固定 GUID 后做 SHA-1 哈希,证明"我确实支持 WebSocket,不是在响应普通请求"- 握手后,协议切换完成:HTTP 的 80 端口、代理服务器、防火墙都通过了这个连接,但从此它们只转发字节,不再解析
握手前(HTTP 模式) 握手后(WebSocket 帧模式)
┌─────────────────────┐ ┌─────────────────────┐
│ GET /chat HTTP/1.1 │ │ 0x81 0x05 0x48 0x65 │ ← 二进制帧
│ Host: example.com │ │ 0x6c 0x6c 0x6f │
│ Upgrade: websocket │ ──→ │ │
│ ... │ │ 服务器可以随时发送: │
│ │ │ 0x81 0x05 0x57 0x6f │
└─────────────────────┘ │ 0x72 0x6c 0x64 │
HTTP 文本协议 └─────────────────────┘
二进制帧协议
为什么能通过中间设备?
天才之处:WebSocket 握手是一个合法的 HTTP 请求。Proxy、防火墙、负载均衡器看到的是:
GET /chat HTTP/1.1
Upgrade: websocket
这是一个标准的 HTTP/1.1 请求——所有中间设备都能理解、转发、处理。握手完成后,连接升级为 WebSocket,中间设备只知道"这个连接还在活跃",但它们看不懂后续的二进制帧——这正好,因为它们本来也不需要解析内容。
4 第三次演进:WebSocket 帧协议与数据交换
WebSocket 帧结构(详细)
握手完成后,所有数据都是二进制帧格式,与 HTTP 的文本格式完全不同:
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 | 是否为最后一帧(用于分片) |
RSV 1-3 | 3 bits | 保留位,必须为 0(用于扩展协议) |
opcode | 4 bits | 帧类型(最重要) |
MASK | 1 bit | 客户端→服务器是否掩码(必须为 1) |
Payload Len | 7 bits | 负载长度(0-125),126 表示后面 2 字节,127 表示后面 8 字节 |
Masking-Key | 32 bits | 用于掩码数据的 4 字节密钥 |
Payload Data | 可变 | 实际数据 |
opcode 帧类型:
| opcode | 类型 | 用途 |
|---|---|---|
0x0 | 继续帧 | 分片消息的后续帧 |
0x1 | 文本帧 | UTF-8 文本数据 |
0x2 | 二进制帧 | 二进制数据(ArrayBuffer、Blob) |
0x8 | 关闭帧 | 关闭连接请求 |
0x9 | Ping 帧 | 心跳检测(发送端) |
0xA | Pong 帧 | 心跳响应 |
为什么客户端到服务器的帧需要掩码(MASK)?
这是一个安全设计。WebSocket 帧中,客户端发送给服务器的数据必须用 MASK 位掩码,服务器到客户端不需要。
原因:防止缓存投毒攻击(Cache Poisoning)。攻击者可以在网页中嵌入恶意 JavaScript,通过 WebSocket 发送精心构造的数据。如果 WebSocket 数据没有被掩码,这些数据可能被中间代理(不理解 WebSocket)误以为是 HTTP 响应内容并缓存,导致后续用户访问时收到被污染的响应。
全双工通信示例
客户端 服务器
│ │
│── 握手请求 ──────────────→ │
│← 101 Switching Protocols ← │ ✅ 握手完成
│ │
│── 文本帧: "Hello!" ─────→ │ 客户端随时发
│── 二进制帧: [图片数据] ─→ │ 客户端随时发
│← 文本帧: "你好!" ←────── │ 服务器随时发
│← Ping ───────────────────│ 服务器心跳检测
│── Pong ──────────────────→ │ 客户端响应
│── 关闭帧 ────────────────→ │ 任何一方发起关闭
│← 关闭帧 ←──────────────── │
│ 连接关闭 │
实时通信终于实现了:不需要轮询、不需要长连接、没有 HTTP 头部开销——在 WebSocket 连接上,双方是对等的,随时可以发送数据。
WebSocket 的局限
- 没有自动重连:连接断开后需要自己实现重连逻辑
- 没有多路复用:一个 WebSocket 连接只有一个逻辑通道。如果需要多路复用,只能开多个 WebSocket 连接
- 没有流量控制:如果发送方太快,接收方来不及处理,可能造成内存溢出(应用层需自行实现背压)
- 与 HTTP/2 不兼容:HTTP/2 的多路复用帧与 WebSocket 的帧模型不兼容。在 HTTP/2 上使用 WebSocket 需要额外的扩展(RFC 8441)
总结
| 阶段 | 机制 | 优点 | 缺点 |
|---|---|---|---|
| P0:HTTP 请求-响应 | 一问一答 | 简单,成熟 | 服务器不能主动推送 |
| P1:短轮询 | 客户端频繁请求 | 实现简单 | 99% 流量浪费,延迟不可控 |
| P2:长轮询 | 挂住请求等消息 | 零延迟通知 | 仍有头部开销,连接管理复杂 |
| P3:WebSocket | HTTP 升级 + 二进制帧全双工 | 真正的双向实时通信,开销极低 | 无多路复用、无自动重连、与 HTTP/2 不兼容 |
场景决策指南
需要实时通信 → 选择合适的协议:
│
├── 简单通知/单向推送 → Server-Sent Events (SSE)
│ 只用服务器推,不需要客户端发
│
├── 双向实时通信 → WebSocket
│ 聊天、游戏、协作编辑、实时行情
│
├── 大规模实时数据流 → WebTransport (QUIC 时代未来方案)
│ 多路复用 + 无队头阻塞 + 0-RTT
│
└── 现有 HTTP/2 基础设施 → HTTP/2 流 + SSE
利用现有 HTTP/2 连接,避免额外握手
技术生态位
- Server-Sent Events (SSE):只需要服务器推送的场景(如新闻推送、日志流)。SSE 基于 HTTP,一行
EventSource即可使用,有自动重连。但只支持文本,且只有服务器→客户端方向。 - WebTransport:基于 QUIC 的下一代 Web 实时通信协议。提供多路复用(类似 HTTP/2 的流)、无队头阻塞、0-RTT 握手。仍在标准化中,可视为"WebSocket 在 QUIC 时代的进化版"。
- HTTP/2 Server Push:常被误认为是"推送"技术,但它其实是"预测性推送"——服务器预测客户端会请求某个资源并提前发送。它不是真正的事件驱动推送,且浏览器实现存在各种问题。
- gRPC 双向流:在 HTTP/2 上实现了类似 WebSocket 的双向流通信,但 gRPC 更适用于微服务间通信(强类型、Protobuf 编码)。
自测卡片
🤔 问题:WebSocket 握手使用 HTTP/1.1 的 Upgrade 机制,这是否意味着 WebSocket 不能在 HTTP/2 上工作?
答案:是的,WebSocket 的握手协议基于 HTTP/1.1 的行和头部格式,与 HTTP/2 的二进制帧编码不兼容。要在 HTTP/2 上使用 WebSocket,需要通过 RFC 8441 定义的扩展——将 WebSocket 的握手和帧封装在 HTTP/2 的流中。实践中,大多数 WebSocket 连接使用 HTTP/1.1,即使服务器支持 HTTP/2。
追问:WebTransport 在 QUIC 上实现了 WebSocket 做不到的哪些功能?
🔄 迁移问题:HTTP 的"一问一答"(请求-响应)和 WebSocket 的"全双工"(对等通信),与计算机体系结构中的"轮询"(Polling)和"中断"(Interrupt)有什么相似之处?
思路:HTTP 轮询 ≈ CPU 轮询 I/O 设备状态(不断问"数据准备好了吗?"——浪费 CPU 周期);WebSocket ≈ 中断驱动 I/O(设备数据就绪时主动通知 CPU——零等待开销)。这两种模式的深层权衡是:轮询实现简单但效率低,中断效率高但实现复杂。计算机体系结构中这条基本权衡,在网络协议设计中以完全相同的形式再次出现。
🎮 上瘾学习路径
👣 Step 1:用浏览器 DevTools 观察 WebSocket 握手
打开任意使用了 WebSocket 的网站(如 trading view、在线聊天工具)→ Network → WS 标签 → 点击一个 WebSocket 连接 → 查看 Headers 中的 Upgrade 请求和响应。
👣 Step 2:用 wscat 测试 WebSocket 服务
# 安装
npm install -g wscat
# 连接公共 WebSocket 测试服务
wscat -c wss://echo.websocket.org
# 输入消息,服务器会原样返回
👣 Step 3:自己写一个 WebSocket 服务器 + 客户端
// 服务器 (Node.js + ws 库)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', ws => {
ws.on('message', msg => ws.send(`Echo: ${msg}`));
});
// 客户端 (浏览器)
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = e => console.log('收到:', e.data);
ws.send('Hello!');
👣 Step 4:用 Wireshark 抓 WebSocket 包
抓包时设置过滤条件 websocket,观察握手帧和后续数据帧的区别——注意客户端帧的 MASK 位为 1,服务器帧的 MASK 位为 0。
👣 Step 5:对比 WebSocket 与轮询的流量差异
用 Wireshark 抓取 1 分钟的短轮询请求和 1 分钟的 WebSocket 连接,对比总传输字节数。