Websocket是什么
1. WebSocket 的核心定位
WebSocket 是一种在单条 TCP 连接上实现全双工、低开销、长连接通信的协议。它通常先借助一次 HTTP/1.1 请求完成握手升级,随后双方传输的不再是 HTTP 报文,而是 WebSocket 帧。
它解决的核心问题是:HTTP 请求-响应模型不适合高频实时双向通信。如果用轮询、长轮询实现聊天室、行情推送、协同编辑,会产生大量重复请求、连接管理开销和较高延迟。
2. WebSocket 与 HTTP 的主要区别
| 维度 | WebSocket | HTTP |
|---|---|---|
| 通信模型 | 全双工,双方都可主动发消息 | 半双工,请求-响应 |
| 连接形态 | 一次握手,长期复用同一条 TCP 连接 | 通常按请求处理 |
| 协议开销 | 建连后帧头很小 | 每次请求都带较完整的 Header |
| 适用场景 | 聊天、推送、实时协同、游戏 | 页面加载、表单提交、常规 API |
| 服务端推送 | 原生支持 | 需轮询、长轮询、SSE 等变通方案 |
3. 建立连接的过程
3.1 握手为什么基于 HTTP
WebSocket 没有重新发明一套端口发现和代理穿透机制,而是复用 HTTP 常见的 80/443 端口和代理链路。这样更容易穿过防火墙、网关和浏览器安全模型。
3.2 典型握手报文
客户端先发起升级请求:
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
服务端校验通过后返回:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
3.3 Sec-WebSocket-Accept 是怎么来的
服务端会把客户端传来的 Sec-WebSocket-Key 与固定 GUID:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接后做 SHA-1,再进行 Base64 编码,结果放入 Sec-WebSocket-Accept。这样可以证明服务端理解 WebSocket 协议,而不是把这次请求当成普通 HTTP 请求处理。
4. WebSocket 帧的数据格式是怎么设计的
4.1 帧结构总览
连接建立后,应用层数据会被切成一个个 WebSocket 帧。基础格式如下:
| 字段 | 位数 | 作用 |
|---|---|---|
FIN |
1 bit | 是否为当前消息的最后一帧 |
RSV1/RSV2/RSV3 |
各 1 bit | 为扩展预留,常见于压缩扩展 |
Opcode |
4 bit | 帧类型,如文本、二进制、关闭、心跳 |
MASK |
1 bit | 是否带掩码 |
Payload len |
7 bit | 载荷长度或长度标记 |
Extended payload length |
16/64 bit | 当载荷较大时补充真实长度 |
Masking key |
32 bit | 客户端发给服务端时必须携带 |
Payload data |
N byte | 业务数据 |
4.2 常见 Opcode
Opcode |
含义 |
|---|---|
0x0 |
延续帧(Continuation) |
0x1 |
文本帧(Text) |
0x2 |
二进制帧(Binary) |
0x8 |
关闭帧(Close) |
0x9 |
Ping |
0xA |
Pong |
4.3 长度字段为什么这样设计
WebSocket 的长度设计兼顾了小消息高频传输和大消息兼容性:
- 长度小于等于
125字节时,直接放在Payload len中,帧头最短只有 2 字节。 - 长度等于
126时,后续再用 16 bit 表示真实长度。 - 长度等于
127时,后续再用 64 bit 表示真实长度。
这样做的好处是:聊天、通知这类小消息头部极小,大文件或音视频分片又不会被协议限制死。
4.4 为什么客户端必须 Mask
浏览器发往服务端的帧必须带 Masking key,服务端收到后需要做一次异或还原。其核心目的不是保密,而是避免中间代理把 WebSocket 数据误识别为普通 HTTP 流量,从而导致缓存投毒、协议混淆等问题。
注意:
- 客户端到服务端必须 mask。
- 服务端到客户端通常不 mask。
4.5 为什么支持分片
WebSocket 允许一个完整消息拆成多个帧发送,靠 FIN 和 Continuation 帧拼起来。这样设计主要有三个原因:
- 避免超大消息一次性占满发送缓冲区。
- 允许控制帧插队,比如长消息传输中间仍能插入
Ping/Pong。 - 便于和压缩、流式处理结合。
4.6 控制帧的约束
控制帧包括 Close、Ping、Pong,它们有两个重要限制:
- 控制帧负载长度不能超过
125字节。 - 控制帧不能被分片。
这是为了保证控制语义简单、可快速处理,不会被超长消息阻塞。
5. 前端是如何创建和发送 WebSocket 的
5.1 浏览器中如何创建连接
前端通常直接使用浏览器原生 WebSocket API:
const ws = new WebSocket("wss://example.com/ws/chat", ["json"]);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", roomId: 1 }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log("receive:", message);
};
ws.onerror = (event) => {
console.error("websocket error", event);
};
ws.onclose = (event) => {
console.log("closed", event.code, event.reason);
};
5.2 前端调用 send() 后发生了什么
前端代码只是在逻辑层调用 ws.send(data),真正的底层工作由浏览器完成:
- 浏览器检查
readyState是否为OPEN。 - 把字符串编码为 UTF-8,或直接处理
ArrayBuffer/Blob。 - 根据数据类型组装
Text或Binary帧。 - 生成 masking key,对 payload 做异或。
- 把帧写入浏览器网络栈,再交给 TCP 发送。
也就是说,前端开发者通常是“按消息编程”,浏览器是“按帧编程”。开发者看见的是消息,浏览器负责拆成帧、掩码、重传、粘包拆包处理。
5.3 前端需要关心的几个 API
| API / 属性 | 作用 |
|---|---|
new WebSocket(url) |
创建连接 |
onopen |
握手成功后的回调 |
onmessage |
收到服务端消息 |
send(data) |
发送字符串或二进制数据 |
close(code, reason) |
主动关闭连接 |
readyState |
当前状态 |
bufferedAmount |
尚未真正发出的字节数 |
5.4 前端常见实践
- 一般会先发送认证信息,或把
token放在 URL / Cookie / 首包中。 - 应用层最好自定义消息体,如
{type, requestId, data},便于扩展。 - 大消息要考虑压缩和拆分,不能无限制
send()。 - 断线重连要带退避策略,不能无脑瞬时重连。
6. 后端是如何处理 WebSocket 的
6.1 后端处理的本质
后端通常不是“收一个 HTTP 请求,回一个 HTTP 响应”,而是维护一个长生命周期连接对象。连接建立后,服务端会持续执行以下动作:
- 处理握手升级。
- 保存连接上下文。
- 循环读取帧并解析。
- 把帧聚合成完整消息。
- 执行业务逻辑。
- 再把响应编码成 WebSocket 帧返回。
6.2 服务端的典型处理链路
| 阶段 | 服务端要做什么 |
|---|---|
| 握手阶段 | 校验 Upgrade、Connection、版本、来源、鉴权 |
| 建连阶段 | 创建会话对象,记录用户、房间、订阅关系 |
| 收包阶段 | 解析帧头、长度、mask,并对 payload 解码 |
| 聚合阶段 | 如果是分片帧,组装成完整消息 |
| 分发阶段 | 根据消息类型路由到聊天、通知、订阅等处理器 |
| 回包阶段 | 编码为 Text/Binary/Ping/Pong/Close 帧发回 |
| 断连阶段 | 清理连接、订阅、资源占用 |
6.3 一个简化的后端示例
下面以 Spring 的 TextWebSocketHandler 为例:
public class ChatWebSocketHandler extends TextWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 建立连接后,可以绑定用户和会话信息
session.sendMessage(new TextMessage("{\"type\":\"connected\"}"));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
// 这里通常会做鉴权、反序列化、消息路由
session.sendMessage(new TextMessage("{\"type\":\"ack\",\"data\":" + payload + "}"));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 连接关闭后,清理会话和订阅关系
}
}
这个例子只体现“消息级”处理。更底层的框架,比如 Netty,还会显式处理 HTTP 升级、帧解码、聚合和心跳。
6.4 后端真正要补上的能力
光能“收发消息”还不够,工程上通常还要补这些能力:
- 鉴权:连接建立时确认用户身份,并处理过期、顶号、踢人。
- 会话管理:用户和连接可能是一对多,例如一个用户多个设备在线。
- 消息顺序:单连接内通常天然有序,多连接、多节点场景需要业务序号。
- 消息确认:重要消息可以设计
ack机制,避免只靠 TCP 成功送到内核缓冲区。 - 广播与路由:房间广播、用户单播、集群跨节点转发常依赖 Redis、MQ 或网关层。
- 限流与背压:防止慢客户端拖垮发送队列。
7. WebSocket 发送一条消息的完整过程
7.1 前端发消息到服务端
- 业务代码调用
ws.send()。 - 浏览器把业务数据编码成 WebSocket 消息。
- 浏览器把消息封装为一个或多个帧,并添加 mask。
- 帧交给 TCP 协议栈,切成 TCP segment 发出。
- 网络设备转发到服务端机器。
- 服务端内核收到 TCP 数据,放入 socket 接收缓冲区。
- WebSocket 框架从 socket 读取字节流,解析帧结构。
- 服务端执行 unmask、拼帧、反序列化,得到业务消息。
- 业务处理器执行逻辑,比如鉴权、入库、广播。
7.2 服务端把消息回给客户端
- 业务层生成响应消息。
- WebSocket 框架把消息编码成 Text 或 Binary 帧。
- 服务端把帧写入 socket 发送缓冲区。
- 数据经 TCP 发往客户端。
- 浏览器网络栈接收字节流并解析帧。
- 浏览器把完整消息投递给
onmessage回调。 - 前端代码更新页面、状态管理或本地缓存。
7.3 这条链路上分别由谁保证什么
| 层次 | 负责内容 |
|---|---|
| WebSocket 协议 | 消息边界、帧类型、心跳、关闭语义 |
| TCP | 字节流传输、重传、顺序、流控、拥塞控制 |
TLS (wss) |
加密、防窃听、防篡改 |
| 业务协议 | 消息类型、鉴权、幂等、顺序号、确认机制 |
要注意:TCP 只保证字节流可靠送达,不保证你的业务“消息已被处理成功”。 如果消息不能丢,就要在应用层补 ack、重试、去重、幂等。
8. 开发时需要注意什么
8.1 鉴权不要只做一次“表面登录”
很多系统只是连接建立时校验一次 token,后续完全信任连接,这容易出问题。更稳妥的方式是:
- 握手阶段完成基础鉴权。
- 会话中保留用户身份、租户、权限信息。
- 对敏感消息继续做业务权限校验。
- 处理
token过期、用户被踢下线、权限变更。
8.2 心跳机制要分清层次
- TCP 有
keepalive,但默认周期很长,且不够灵活。 - WebSocket 有
Ping/Pong,适合协议层保活。 - 业务层还可以定义心跳消息,用于统计 RTT、用户在线状态、链路健康。
通常线上更常用的是:应用层心跳 + 服务端空闲连接回收。
8.3 长连接场景必须关注负载均衡
WebSocket 连接建立后会长期停留在某台机器上,因此和普通 HTTP 请求不一样:
- 如果连接状态保存在本机内存,需要粘性会话。
- 如果要无状态扩容,需要把会话路由、订阅关系、用户状态放入共享层。
- 广播消息常要做跨节点分发,否则用户只会收到本机连接的推送。
8.4 注意消息大小和背压
不能假设客户端永远消费得过来,也不能假设网络永远稳定:
- 限制单条消息最大长度,防止内存被打爆。
- 关注发送队列长度,避免慢连接积压。
- 大消息尽量压缩、分片或改走对象存储。
- 前端可观察
bufferedAmount,后端可监控发送缓冲区和队列深度。
8.5 断线重连不是简单重连
重连会带来三个典型问题:
- 重复连接:用户旧连接未完全清理,新连接又上来了。
- 消息丢失:断线期间的消息是否补发。
- 消息重复:重连补偿时同一消息可能收到两次。
常见做法是增加:
- 会话唯一标识。
- 消息序号或游标。
ack/ 补拉机制。- 幂等消费。
8.6 WebSocket 不一定适合所有实时场景
如果只是服务端单向推送,且不要求双向交互,SSE 可能更简单。若消息量极低、链路无需常驻,也可能普通 HTTP 更合适。不要为了“实时”就默认上 WebSocket。