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 是操作系统提供的网络编程接口。
本站总访问量  ·  访客数
你的IP 获取中…