HTTP/1.1 的局限:半双工#
TCP 协议本身是全双工的,客户端和服务端可以同时互相发送数据。但我们最常用的 HTTP/1.1 虽然基于 TCP,却是半双工的——在同一时刻,只有一个方向的数据传输在进行。
它的模型非常简单:客户端问,服务端答。只要客户端不发起请求,服务端就永远沉默。
这对于需要服务器主动推送数据的场景来说非常不友好。
过渡方案:轮询与长轮询(Comet)#
在 WebSocket 普及之前,开发者用了一些"曲线救国"的方式来模拟服务器推送:
定时轮询:客户端每隔几秒就发一次请求问"有新消息吗?",大多数时候服务端都在回答"没有",白白浪费带宽。
长轮询:客户端发请求后,服务端不立即回复,而是等到真正有消息时才响应。客户端收到响应后立刻发下一个请求。相比定时轮询有所改善,但本质仍是 HTTP 半双工,高并发下连接开销很大。
对于登录状态检测这类简单场景,这两种方式勉强够用。但对于网页游戏、在线协作文档这类需要高频双向通信的场景,它们的开销太大,体验太差。
WebSocket:从 HTTP 升级而来#
WebSocket 的握手利用了 HTTP,但升级完成后就与 HTTP 彻底无关。
升级握手过程#
客户端发送升级请求:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13服务端同意升级:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=返回 101 Switching Protocols 后,这条 TCP 连接就不再走 HTTP 了,之后全部使用 WebSocket 自己的帧格式收发数据。
正因为握手阶段借用了 HTTP,所有支持 HTTP 的浏览器都可以无缝支持 WebSocket,不需要任何额外的网络基础设施改造。
WebSocket 帧结构#
WebSocket 中的数据包叫做帧(Frame),它有自己的二进制格式:
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=1) |
+---------------------------------------------------------------+
| Payload Data |
+---------------------------------------------------------------+关键字段说明#
| 字段 | 大小 | 说明 |
|---|---|---|
| FIN | 1 bit | 是否是消息的最后一帧,支持大消息分片传输 |
| RSV1/2/3 | 各 1 bit | 保留位,扩展协议用,通常为 0 |
| opcode | 4 bit | 帧类型:0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping |
| MASK | 1 bit | 客户端→服务端的帧必须为 1(掩码) |
| Payload 长度 | 7 bit | 变长编码,见下方说明 |
Payload 长度的变长编码#
这是 WebSocket 帧设计中最精巧的地方,用 7 bit 优雅地表达了三种范围:
7 位值 实际含义
──────────────────────────────────────────
0 ~ 125 就是实际长度
126 后续 2 字节(16 bit)才是真正长度
127 后续 8 字节(64 bit)才是真正长度小消息只需 2 字节头部,极大减少了开销。
Mask-Key(掩码)#
当 MASK=1 时,帧中会携带 4 字节的 Mask-Key,接收方需要对每个 Payload 字节做 XOR 解码:
for i, b := range payload {
payload[i] = b ^ maskKey[i%4]
}设计掩码的目的是防止缓存污染攻击——避免中间代理把 WebSocket 帧误识别为 HTTP 响应并缓存起来。
TCP 粘包问题与 WebSocket 的解法#
什么是粘包?#
TCP 是字节流协议,它不懂"消息"的概念,只负责把字节可靠地送达。
发送方发了: [消息A] [消息B] [消息C]
接收方可能收到:
情况1(粘包):[消息A 消息B] ← 两条消息粘在一起
情况2(拆包):[消息A 的前半段]
[消息A 后半段 + 消息B] ← 一条消息被拆开原因在于 TCP 有发送缓冲区,Nagle 算法会把小包合并发送;网络层的 MTU 限制也可能把大包拆分。接收方完全不知道一条消息从哪开始、到哪结束。
通用解法:消息头 + 消息体#
┌──────────────────┬───────────────────────────┐
│ Header(固定长度)│ Body(Header 中指定的长度)│
└──────────────────┴───────────────────────────┘最简单的实现是在头部放一个 4 字节的 length 字段:
// 发送方
func sendMessage(conn net.Conn, data []byte) error {
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(len(data)))
_, err := conn.Write(append(header, data...))
return err
}
// 接收方:循环读,永不丢弃缓冲区数据
func recvLoop(conn net.Conn) {
for {
header := make([]byte, 4)
io.ReadFull(conn, header) // 精确读 4 字节
length := binary.BigEndian.Uint32(header)
body := make([]byte, length)
io.ReadFull(conn, body) // 按长度精确读消息体
handle(body)
// 循环继续,缓冲区剩余数据留给下次读取
}
}
io.ReadFull会阻塞直到读满指定字节数——缓冲区里还没到的数据不会被丢弃,而是等待内核继续收包填充进来。
WebSocket 帧结构本身就解决了粘包#
回头看 WebSocket 的帧格式,它的 Payload 长度字段就是"消息头+消息体"思路的具体实现。库的实现者已经帮我们处理好了边界问题,业务层直接拿到完整的消息即可。
这也是为什么几乎所有成熟的二进制协议——WebSocket、gRPC、Redis RESP、MySQL 协议——都把长度字段放在帧头最显眼的位置。
WebSocket 的适用场景#
WebSocket 完美继承了 TCP 的全双工能力,并通过帧格式解决了粘包问题,适用于所有需要服务端与客户端高频双向通信的场景:
- 网页 / 小程序游戏:实时同步玩家状态
- 在线聊天室:消息即时送达
- 协同办公软件(如飞书文档、在线白板):多人实时编辑同步
- 实时行情 / 监控大盘:服务端主动推送数据变化
总结#
- TCP 全双工,HTTP/1.1 半双工:HTTP 的请求-响应模型决定了它不适合服务端主动推送。
- 轮询是过渡方案:定时轮询和长轮询(Comet)能凑合用,但高并发下开销太大。
- WebSocket 借 HTTP 握手:利用
101 Switching Protocols完成协议升级,之后与 HTTP 彻底无关。 - 帧结构解决粘包:Payload 长度字段让接收方知道每条消息的精确边界,这是所有成熟二进制协议的通行做法。
- WebSocket ≠ Socket:两者只是名字相似,WebSocket 是应用层协议,Socket 是操作系统提供的网络编程接口。