WebSocket
WebSocket protocol overview: HTTP upgrade handshake, frames/messages, masking, ping/pong, close handshake, and HTTP/2+ bootstrapping.
[!info] related notes
WebSocket
可以把 WebSocket 协议理解成一句话:
先用 HTTP 把门敲开,再切换成一个独立的、面向消息的、全双工二进制帧协议,在同一条连接上长期双向通信。 RFC 6455 明确把它分成两部分:opening handshake 和 data 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 握手长什么样
客户端的打开握手至少包含这些关键元素:GET、Host、Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key,以及 Sec-WebSocket-Version: 13;如果是浏览器发起,还必须带 Origin。另外还可以带 Sec-WebSocket-Protocol(子协议)、Sec-WebSocket-Extensions(扩展)、Cookie 和认证头。服务器接受后返回 101、Upgrade: websocket、Connection: 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 是保留位,除非协商了扩展,否则必须为 0;opcode 表示这是文本帧、二进制帧、延续帧还是控制帧;MASK 表示有没有掩码;payload length 用 7 位基础长度表示,小消息直接放在这里,大消息再跟 16 位或 64 位扩展长度。(IETF Datatracker)
opcode 里最常用的是:
0x1:文本帧0x2:二进制帧0x0:延续帧0x8:Close0x9:Ping0xA: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 = 49,69 xor 02 = 6B。这只是协议编码,不是加密。其依据就是上面的 XOR 规则。(IETF Datatracker)
5. 分片、控制帧、心跳
如果消息很大,或者发送方一开始就不知道总长度,可以把一条 message 分片。规则是:
- 不分片:一个帧,
FIN=1 - 分片:第一帧用文本/二进制 opcode,
FIN=0 - 中间帧都用
opcode=0x0(continuation) - 最后一帧
opcode=0x0且FIN=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:服务端遇到意外错误
而 1005、1006、1015 是保留值,不能真正放进 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/2 和 HTTP/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 秒,结合业务实时性和连接规模调整
权衡因素:
- 业务对实时性的要求
- 服务端能承受的额外流量
- 网络环境稳定性(移动端通常需要更频繁)
- 连接总数(万级连接时心跳成本显著)
和重连的配合
- 心跳检测到异常后,关闭连接
- 触发重连逻辑(指数退避,避免雪崩)
- 重连成功后,重新建立心跳计时器
- 重连失败次数超限后,给用户明确提示
常见误区
只说”定时发消息”
要补上”怎么判断异常、连续丢失几次算异常、异常后怎么处理”。
心跳和重连不分
心跳是检测机制,重连是恢复机制,两者配合但职责不同。
忽略服务端心跳
心跳可以是客户端发起,也可以是服务端发起,或者双向都有。
最短记忆方式
心跳 = 定时探测 + 超时判定 + 异常重连 + 频率权衡