WebSocket

WebSocket protocol overview: HTTP upgrade handshake, frames/messages, masking, ping/pong, close handshake, and HTTP/2+ bootstrapping.

#type / concept #status / growing #tech / network #platform / browser #resource / http #resource / javascript

[!info] related notes

WebSocket

可以把 WebSocket 协议理解成一句话:

先用 HTTP 把门敲开,再切换成一个独立的、面向消息的、全双工二进制帧协议,在同一条连接上长期双向通信。 RFC 6455 明确把它分成两部分:opening handshakedata transfer;它出现的动机,就是替代当年用轮询/长轮询“硬凑”双向通信的做法,用一条连接承载双向流量。(IETF Datatracker)

1. 它和 HTTP 到底是什么关系

WebSocket 不是“持续发送 HTTP 请求”。在经典的 HTTP/1.1 版本里,它只是在建立连接时借用一次 HTTP Upgrade:客户端发一个 GET 请求,请求升级到 websocket;服务器如果接受,就返回 101 Switching Protocols。从这一刻开始,后面的字节流就不再按 HTTP 报文解释,而是按 WebSocket 自己的帧格式解释。握手成功后,两端都可以独立地随时发数据,不再是“请求—响应”一问一答的模型。(IETF Datatracker)

2. 经典 HTTP/1.1 握手长什么样

客户端的打开握手至少包含这些关键元素:GETHostUpgrade: websocketConnection: UpgradeSec-WebSocket-Key,以及 Sec-WebSocket-Version: 13;如果是浏览器发起,还必须带 Origin。另外还可以带 Sec-WebSocket-Protocol(子协议)、Sec-WebSocket-Extensions(扩展)、Cookie 和认证头。服务器接受后返回 101Upgrade: websocketConnection: Upgrade,并给出 Sec-WebSocket-Accept。如果状态码不是 101,或者 Upgrade/Connection/Sec-WebSocket-Accept 不合法,客户端必须判定握手失败。(IETF Datatracker)

Sec-WebSocket-Key 不是“密码”,而是客户端每次连接随机生成的 16 字节随机值的 Base64。服务器把这个值(按字符串原样,不是先 Base64 解码)拼上固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做 SHA-1,再 Base64,放进 Sec-WebSocket-Accept。它的作用是:证明服务器确实理解并接受了 WebSocket 握手,避免普通 HTTP 通道被误当成 WebSocket。(IETF Datatracker)

很多人会误会这里的 SHA-1 是“安全加密”。不是。RFC 6455 明说,这里的握手并不依赖 SHA-1 的抗碰撞等安全性质;真正的机密性、完整性和端点认证,靠的是 TLS,也就是 wss://ws:// 是明文,wss:// 才受 TLS 保护。(IETF Datatracker)

3. WebSocket 传输的基本单位:message 和 frame

协议层有两个容易混的概念:

  • message(消息):应用层看到的“这一条消息”
  • frame(帧):线上真正传输的片段

RFC 6455 说得很清楚:握手成功后,两端交换的是概念上的 message;而在网络上,一条 message 可以由一个或多个 frame 组成。所以 WebSocket 是“面向消息”的,不是直接把应用裸字节一股脑儿塞进 TCP。(IETF Datatracker)

一个帧的头部可以粗略记成:

FIN | RSV1 | RSV2 | RSV3 | opcode | MASK | payload length | [masking key] | payload

其中 FIN 表示是不是这条消息的最后一个片段;RSV1/2/3 是保留位,除非协商了扩展,否则必须为 0opcode 表示这是文本帧、二进制帧、延续帧还是控制帧;MASK 表示有没有掩码;payload length 用 7 位基础长度表示,小消息直接放在这里,大消息再跟 16 位或 64 位扩展长度。(IETF Datatracker)

opcode 里最常用的是:

  • 0x1:文本帧
  • 0x2:二进制帧
  • 0x0:延续帧
  • 0x8:Close
  • 0x9:Ping
  • 0xA:Pong

文本消息要求最终重组出来的整条消息必须是 有效 UTF-8;二进制消息则完全由应用自己解释。(IETF Datatracker)

一个非常重要的细节是:应用不要依赖“帧边界”。RFC 6455 明确说,除非某个扩展另有定义,否则帧本身没有语义;中间设备在知道扩展语义的前提下,可能会合并或拆分帧。所以你该关注的是“消息边界”,不是“我这次 recv() 恰好收到了哪个帧”。(IETF Datatracker)

4. 为什么只有客户端必须做 masking

这是 WebSocket 最容易被问到的设计:客户端发给服务器的帧必须 mask,服务器发给客户端的帧不能 mask。如果服务器收到未 mask 的客户端帧,应该关闭连接;如果客户端收到服务端 mask 过的帧,也应关闭连接,通常可用 1002(协议错误)。(IETF Datatracker)

mask 的算法并不复杂:客户端给每个帧随机生成一个 4 字节 masking key,然后对 payload 逐字节做 XOR:第 i 个字节与 mask[i mod 4] 异或。这个 key 必须是每帧新生成、不可预测的。(IETF Datatracker)

它的目的不是保密,而是保护网络基础设施。RFC 6455 解释过背景:如果浏览器里恶意脚本能精确控制线上字节长什么样,就可能构造出“看起来像 HTTP 请求”的内容去迷惑某些代理/缓存。于是标准要求客户端把发出去的数据 mask 掉,让脚本无法直接控制线上明文字节;而威胁模型主要针对的是 client → server 方向,所以服务端返回的数据不要求 mask。(IETF Datatracker)

举个纯演示的例子:假设客户端要发文本 "Hi",payload 是 48 69,mask key 取 01 02 03 04,那线上 payload 就会变成 49 6B,因为 48 xor 01 = 4969 xor 02 = 6B。这只是协议编码,不是加密。其依据就是上面的 XOR 规则。(IETF Datatracker)

5. 分片、控制帧、心跳

如果消息很大,或者发送方一开始就不知道总长度,可以把一条 message 分片。规则是:

  • 不分片:一个帧,FIN=1
  • 分片:第一帧用文本/二进制 opcode,FIN=0
  • 中间帧都用 opcode=0x0(continuation)
  • 最后一帧 opcode=0x0FIN=1

分片的主要意义是不用先把整条大消息全部缓冲完再发,也能让大消息别长期独占输出通道。(IETF Datatracker)

控制帧是另一类特殊帧:Close、Ping、Pong。它们有两个硬性限制:payload 最多 125 字节,且不能分片。同时,控制帧可以插入到一个大消息的分片中间,这也是为什么 Ping 不会被一个超大消息彻底“堵死”。(IETF Datatracker)

Ping/Pong 规则也很直白:收到 Ping 后,若还没进入关闭流程,就必须尽快回 Pong;Pong 的 application data 要和对应 Ping 一致。Ping 可以用来做 keepalive 或探测对端是否还活着;Pong 也可以不经请求主动发送,作为单向心跳。(IETF Datatracker)

6. 关闭连接不是“直接断 TCP”那么简单

WebSocket 设计了一个关闭握手。Close 帧的 opcode 是 0x8,它的 body 可以带:

  • 前 2 字节:状态码
  • 后面:UTF-8 的 reason

发送了 Close 之后,应用就不能再发送数据帧。如果你收到对方的 Close,而自己还没发过,就必须回一个 Close。等到双方都发送并接收了 Close,才算 WebSocket 层关闭,然后再关闭底层 TCP。(IETF Datatracker)

常见关闭码你可以这样记:

  • 1000:正常关闭
  • 1001:对端要离开了,比如服务下线或页面跳走
  • 1002:协议错误
  • 1003:收到自己不能接受的数据类型
  • 1007:文本消息里 UTF-8 非法
  • 1008:策略违规
  • 1009:消息太大
  • 1011:服务端遇到意外错误

100510061015保留值,不能真正放进 Close 帧发送;例如 1006 只是给应用层表示“异常断开,没有收到 Close 帧”。(IETF Datatracker)

RFC 6455 还顺手给了一个实践建议:如果是异常关闭后重连,客户端不要疯狂立即重连,而应该做随机延迟和指数退避,否则一堆客户端同时重试会把服务打挂。(IETF Datatracker)

7. 子协议和扩展,不是一回事

这两个字段经常被混淆:

子协议 Sec-WebSocket-Protocol

它是应用层语义协商。意思是:“底层我都用 WebSocket,但这条连接上的业务消息到底按哪套规则解释?”客户端可以带一个按优先级排序的列表,服务器从中选一个回给你,或者一个也不选。RFC 6455 规定服务器不能回一个客户端没请求过的子协议;WHATWG 的浏览器标准还更严格:如果浏览器端创建连接时明确请求了子协议,而响应里没有确认,浏览器会直接判定连接失败。(IETF Datatracker)

扩展 Sec-WebSocket-Extensions

它是协议级能力协商,会影响 framing 或 payload 的解释。服务器不能回一个客户端没请求过的扩展,而且扩展的顺序是有意义的。RFC 6455 本身只定义了协商机制,不定义具体扩展;一个常见扩展是 RFC 7692 的 permessage-deflate,它按“每条消息”压缩 payload,并使用 RSV1 标记这条消息是否被压缩。(IETF Datatracker)

8. 安全模型里最容易踩的坑

第一,浏览器 WebSocket 请求会带 Origin,服务器如果只允许某些站点发起,就应该校验它;不接受就可以直接回 403 Forbidden。但 RFC 6455 也明确提醒:非浏览器客户端可以伪造 Origin,所以它不是强认证手段,只能作为浏览器场景下的一层来源约束。真正的认证还是应该用 Cookie、HTTP 认证、TLS 认证,或者你自己的应用层 token。(IETF Datatracker)

第二,尽量用 wss://wss 表示 WebSocket over TLS,提供机密性、完整性和端点认证;ws 是明文。(IETF Datatracker)

第三,服务端要做实现层面的限制。RFC 6455 明说,实现应该对帧大小和消息重组后的总大小设上限,否则恶意对端可以发超大帧或无休止分片,吃掉你的内存。(IETF Datatracker)

9. 到了 HTTP/2 和 HTTP/3,玩法变了

很多教程只讲 HTTP/1.1 的 Upgrade: websocket,但在 HTTP/2HTTP/3 里,WebSocket 的引导方式已经不是那个套路了。

在 HTTP/2 里,RFC 8441 定义了 Extended CONNECT:客户端要发 :method = CONNECT:protocol = websocket,并带上 :scheme:path:authority。这时 Connection / Upgrade / Host 这些 HTTP/1.1 头不能再用;RFC 8441 还明确说,HTTP/1.1 里 Sec-WebSocket-Key / Sec-WebSocket-Accept 那套处理在这里被 :protocol 机制取代了。它给出的示例响应状态码是 200,不是 101。握手完成后,双方就在这个 HTTP/2 stream 上继续跑 RFC 6455 的 WebSocket 帧。(IETF Datatracker)

HTTP/3 则由 RFC 9220 把同样的思路移植过去:依然是 Extended CONNECT,语义基本沿用 HTTP/2,只是对应到 HTTP/3 的设置和流关闭语义上。(IETF Datatracker)

10. 真正理解 WebSocket,可以抓住这几个关键词

HTTP 开门、协议切换、消息与帧分离、客户端 masking、子协议与扩展协商、Ping/Pong 心跳、Close 关闭握手、wss 才有 TLS 安全。 这几个点抓住了,WebSocket 协议本身就算吃透大半了。(IETF Datatracker)

下一条我可以把 一次完整握手 + 一条文本消息 + 一次 Ping/Pong + 一次 Close 按字节级别拆给你看。

面试要点

来自 websocket-heartbeat-interview-question 的面试视角整理。

一句话回答

心跳的核心作用是检测连接是否假死。因为网络断开、代理中断、服务端异常时,TCP 连接不一定立刻断开,前端可能误以为连接还活着。心跳通过定时发送轻量消息,及时发现异常并触发重连。

面试回答主线

为什么需要心跳

  • TCP 连接假死:网络断开后,TCP 四次挥手可能没完成,两端都不知道连接已断
  • 代理/防火墙超时:中间设备可能静默关闭空闲连接,但不通知两端
  • 服务端异常:服务端进程崩溃或重启,连接状态不一致
  • 移动端网络切换:WiFi 切流量、飞行模式等场景连接状态不确定

怎么实现

客户端心跳

const HEARTBEAT_INTERVAL = 30_000; // 30 秒
const MAX_MISSED = 3; // 允许丢失 3 次
let missedCount = 0;

const timer = setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'ping' }));
    missedCount++;
    if (missedCount >= MAX_MISSED) {
      clearInterval(timer);
      ws.close();
      reconnect();
    }
  }
}, HEARTBEAT_INTERVAL);

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'pong') {
    missedCount = 0; // 收到响应,重置计数
  }
};

服务端心跳

  • 服务端也可以主动发 ping,客户端回 pong
  • RFC 6455 定义了原生的 Ping/Pong 控制帧
  • 有些框架(如 ws)自动处理原生 ping/pong

频率怎么权衡

心跳不是越频繁越好:

  • 太频繁:增加服务端压力,尤其连接数多时
  • 太稀疏:假死发现慢,用户体验差
  • 常见值:15-30 秒,结合业务实时性和连接规模调整

权衡因素:

  • 业务对实时性的要求
  • 服务端能承受的额外流量
  • 网络环境稳定性(移动端通常需要更频繁)
  • 连接总数(万级连接时心跳成本显著)

和重连的配合

  • 心跳检测到异常后,关闭连接
  • 触发重连逻辑(指数退避,避免雪崩)
  • 重连成功后,重新建立心跳计时器
  • 重连失败次数超限后,给用户明确提示

常见误区

只说”定时发消息”

要补上”怎么判断异常、连续丢失几次算异常、异常后怎么处理”。

心跳和重连不分

心跳是检测机制,重连是恢复机制,两者配合但职责不同。

忽略服务端心跳

心跳可以是客户端发起,也可以是服务端发起,或者双向都有。

最短记忆方式

心跳 = 定时探测 + 超时判定 + 异常重连 + 频率权衡

创建于 2026/3/13 更新于 2026/5/27