WebSocket 协议:握手、帧结构与连接状态机

WebSocket 不是新的传输层,而是"借一次 HTTP 请求完成协商,然后在同一个 TCP 连接上跑自己的帧协议"(RFC 6455)。排障时只有三个抓手:握手(101 没出现就是协商问题)、帧头(消息半截、一连上就断多半是帧格式问题)、状态机与关闭码(断开原因藏在 close 帧里)。把这三样弄清楚,再看 gorilla/websocket 的 API 就只剩语法问题。

握手:一次 HTTP 请求换一条长连接

请求必须满足:HTTP/1.1 或更高、方法为 GET、带 Upgrade、Connection、Sec-WebSocket-Key、Sec-WebSocket-Version:

GET /ws?uid=1001 HTTP/1.1
Host: im.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: im.v1
Origin: https://im.example.com

服务端用 101 应答,协商就结束了,之后这条连接上出现的字节都是帧:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: im.v1
  • Sec-WebSocket-Key 是 16 字节随机数的 base64,作用是防止缓存代理误判与误连,不是鉴权凭证:鉴权放在握手期的 Cookie/Header,或建连后的第一条业务消息(第 08 篇)。
  • Sec-WebSocket-Accept = base64(SHA1(Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")),其中 GUID 是 RFC 6455 写死的常量。算错的表现是服务端回了 101 但客户端立刻断开,或报 Invalid Sec-WebSocket-Accept。
  • Sec-WebSocket-Protocol 是子协议(应用层协议版本)协商,服务端必须回一个客户端提供过的值或干脆不回;扩展走 Sec-WebSocket-Extensions,IM 这种小消息场景一般不开 permessage-deflate。

帧结构:2 字节头 + 可选扩展长度 + 可选掩码键

byte0 : FIN(1) | RSV1..RSV3(3) | opcode(4)
byte1 : MASK(1) | payloadLen(7)
其后  : payloadLen == 126 时跟 2 字节大端长度;== 127 时跟 8 字节大端长度
再后  : maskingKey(4 字节,仅客户端→服务端有) + payload
字段位数含义工程注意
FIN1是否为消息的最后一帧为 0 表示后面还有 continuation 帧
RSV1~33扩展保留位未协商扩展时必须为 0,否则对端以 1002 关闭
opcode4帧类型见下表
MASK1载荷是否被掩码客户端发出的帧必须为 1,服务端发出的必须为 0
Payload len7长度或长度指示126/127 是"后跟扩展长度"的标记,不是真实长度
Masking-key32掩码键防的是中间代理缓存污染,不是加密,可被轻易还原
opcode名称说明
0x0continuation分片消息的后续帧
0x1textUTF-8 文本,载荷非法时对端以 1007 关闭
0x2binary二进制数据
0x8close关闭握手,载荷可为 2 字节关闭码 + UTF-8 原因
0x9ping控制帧,对端必须回 pong
0xApong对 ping 的应答,也可主动发送用于保活

两条硬规则:控制帧(0x8 / 0x9 / 0xA)不能被分片,载荷不超过 125 字节;ping/pong 可以夹在一个分片消息的中间帧之间。自己解析帧头时,长度字段的三种形态都要处理:

// 只解析帧头不读载荷,便于在读完长度后就拦掉超长帧
// masked 表示客户端是否按协议加了掩码(服务端可据此直接以 1002 关闭)
func readFrameHeader(r io.Reader) (opcode byte, masked bool, size int64, err error) {
	var b [2]byte
	if _, err = io.ReadFull(r, b[:]); err != nil {
		return
	}
	opcode = b[0] & 0x0f
	masked = b[1]&0x80 != 0
	switch size = int64(b[1] & 0x7f); size {
	case 126:
		var ext [2]byte
		if _, err = io.ReadFull(r, ext[:]); err != nil {
			return
		}
		size = int64(binary.BigEndian.Uint16(ext[:]))
	case 127:
		var ext [8]byte
		if _, err = io.ReadFull(r, ext[:]); err != nil {
			return
		}
		size = int64(binary.BigEndian.Uint64(ext[:]))
	}
	return
}

状态机与关闭码

连接只有四个状态:CONNECTING(已发握手请求,可被超时或拒绝中断)→ OPEN(收到 101,双方可自由发帧)→ CLOSING(任一方发出 close 帧,等待对端应答,期间不再发数据帧)→ CLOSED(TCP 关闭,资源回收)。关闭是一次握手而不是直接关 TCP:先发 close 帧(可带关闭码),对端回 close 帧,谁先发谁等回应,等不到就超时强关;直接断开会让对方只能看到 1006。

关闭码含义使用建议
1000 / 1001正常关闭 / 端点离开(going away)业务主动断开、服务端重启
1002 / 1003协议错误 / 不支持的数据类型帧格式非法、RSV 位非 0、控制帧超长
1006异常关闭只能读不能发,代表没收到 close 帧
1007 / 1008非法 UTF-8 / 策略违规鉴权失败、被踢下线
1009 / 1011消息过大 / 服务端内部错误超过读限制;处理消息时 panic
4000~4999应用自定义需双方约定,例如 4001 表示令牌过期

常见坑与排查

  • 101 没出现:先抓包看请求头是否少了必需头,再查中间层是否吃掉了 Upgrade(Nginx 需 HTTP/1.1 并显式透传 Upgrade/Connection)。
  • 一连上就被 1002 关闭:客户端帧没加掩码、RSV 位非 0、控制帧超过 125 字节或被分片。
  • 消息被截断或多出一条:业务把分片当成了多条消息;自己解析帧必须处理 continuation,用 ReadMessage 则由库自动拼装。
  • 关闭码总是 1006:网络中断或对端进程被 kill,看不到 close 帧,只能靠心跳与读超时发现(第 06 篇)。
  • 开了 permessage-deflate 反而更慢:小消息压缩收益极低,还多一层缓冲与拷贝,压测后再决定。

小结:握手决定连接能否建立(101 与 Accept 校验),帧结构决定数据能否被正确切分(长度字段、掩码、opcode 与分片),状态机与关闭码决定断开时能否说清原因。调试长连接时先在 wscat -c ws://host/path 里确认握手与收发,再去查业务逻辑;协议细节以 RFC 6455 官方文档为准,压缩扩展见 RFC 7692。

笔记加载中…