第11讲:WebSocket — 从HTTP升级到全双工实时通信

5 阅读12分钟

第11讲:WebSocket — 从HTTP升级到全双工实时通信

目录

⏱️ 预计阅读时间: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 的签名证明

关键细节

  1. 101 Switching Protocols:HTTP 中极少使用的状态码,唯一用途就在这里
  2. Sec-WebSocket-Key / Sec-WebSocket-Accept:服务器将客户端 Key 加上固定 GUID 后做 SHA-1 哈希,证明"我确实支持 WebSocket,不是在响应普通请求"
  3. 握手后,协议切换完成: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)                  |
+---------------------------------------------------------------+

各字段含义

字段长度含义
FIN1 bit是否为最后一帧(用于分片)
RSV 1-33 bits保留位,必须为 0(用于扩展协议)
opcode4 bits帧类型(最重要)
MASK1 bit客户端→服务器是否掩码(必须为 1)
Payload Len7 bits负载长度(0-125),126 表示后面 2 字节,127 表示后面 8 字节
Masking-Key32 bits用于掩码数据的 4 字节密钥
Payload Data可变实际数据

opcode 帧类型

opcode类型用途
0x0继续帧分片消息的后续帧
0x1文本帧UTF-8 文本数据
0x2二进制帧二进制数据(ArrayBuffer、Blob)
0x8关闭帧关闭连接请求
0x9Ping 帧心跳检测(发送端)
0xAPong 帧心跳响应

为什么客户端到服务器的帧需要掩码(MASK)?

这是一个安全设计。WebSocket 帧中,客户端发送给服务器的数据必须用 MASK 位掩码,服务器到客户端不需要。

原因:防止缓存投毒攻击(Cache Poisoning)。攻击者可以在网页中嵌入恶意 JavaScript,通过 WebSocket 发送精心构造的数据。如果 WebSocket 数据没有被掩码,这些数据可能被中间代理(不理解 WebSocket)误以为是 HTTP 响应内容并缓存,导致后续用户访问时收到被污染的响应。

全双工通信示例

客户端                        服务器
  │                             │
  │── 握手请求 ──────────────→  │
  │← 101 Switching Protocols ← │  ✅ 握手完成
  │                             │
  │── 文本帧: "Hello!" ─────→  │  客户端随时发
  │── 二进制帧: [图片数据] ─→ │  客户端随时发
  │← 文本帧: "你好!" ←────── │  服务器随时发
  │← Ping ───────────────────│  服务器心跳检测
  │── Pong ──────────────────→  │  客户端响应
  │── 关闭帧 ────────────────→  │  任何一方发起关闭
  │← 关闭帧 ←──────────────── │
  │  连接关闭                   │

实时通信终于实现了:不需要轮询、不需要长连接、没有 HTTP 头部开销——在 WebSocket 连接上,双方是对等的,随时可以发送数据。

WebSocket 的局限

  1. 没有自动重连:连接断开后需要自己实现重连逻辑
  2. 没有多路复用:一个 WebSocket 连接只有一个逻辑通道。如果需要多路复用,只能开多个 WebSocket 连接
  3. 没有流量控制:如果发送方太快,接收方来不及处理,可能造成内存溢出(应用层需自行实现背压)
  4. 与 HTTP/2 不兼容:HTTP/2 的多路复用帧与 WebSocket 的帧模型不兼容。在 HTTP/2 上使用 WebSocket 需要额外的扩展(RFC 8441)

总结

阶段机制优点缺点
P0:HTTP 请求-响应一问一答简单,成熟服务器不能主动推送
P1:短轮询客户端频繁请求实现简单99% 流量浪费,延迟不可控
P2:长轮询挂住请求等消息零延迟通知仍有头部开销,连接管理复杂
P3:WebSocketHTTP 升级 + 二进制帧全双工真正的双向实时通信,开销极低无多路复用、无自动重连、与 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 连接,对比总传输字节数。