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
| 字段 | 位数 | 含义 | 工程注意 |
|---|---|---|---|
| FIN | 1 | 是否为消息的最后一帧 | 为 0 表示后面还有 continuation 帧 |
| RSV1~3 | 3 | 扩展保留位 | 未协商扩展时必须为 0,否则对端以 1002 关闭 |
| opcode | 4 | 帧类型 | 见下表 |
| MASK | 1 | 载荷是否被掩码 | 客户端发出的帧必须为 1,服务端发出的必须为 0 |
| Payload len | 7 | 长度或长度指示 | 126/127 是"后跟扩展长度"的标记,不是真实长度 |
| Masking-key | 32 | 掩码键 | 防的是中间代理缓存污染,不是加密,可被轻易还原 |
| opcode | 名称 | 说明 |
|---|---|---|
| 0x0 | continuation | 分片消息的后续帧 |
| 0x1 | text | UTF-8 文本,载荷非法时对端以 1007 关闭 |
| 0x2 | binary | 二进制数据 |
| 0x8 | close | 关闭握手,载荷可为 2 字节关闭码 + UTF-8 原因 |
| 0x9 | ping | 控制帧,对端必须回 pong |
| 0xA | pong | 对 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。