1.4 协议先定:WebSocket 还是 TCP
场景
协议选型在动手写代码之前必须定死,因为它决定了客户端 SDK 的形态、接入层的技术栈和未来几年的兼容边界。我们的产品是多端的:手机 App、网页端、PC 客户端将来都要上。网页端天然只能走 WebSocket;手机端为了后台存活和省电,更想走原生 TCP 长连接。团队里为这个争论了好几天,谁也说服不了谁,最后把三个候选方案摆上台面逐条比。
方案
候选其实有三个:WebSocket、原生 TCP(自定义二进制协议)、UDP(可靠层自实现)。UDP 直接被毙了——我们自己实现可靠传输等于重学一遍 TCP 的拥塞控制和重传,预算不允许。剩下两个各有取舍,争论的焦点其实是网页端到底算不算刚需。
WebSocket 的优势是兼容性:浏览器原生支持,HTTP 升级握手天然穿透各种企业代理和办公网络,负载均衡和 CDN 都认它,调试也方便(浏览器开发工具直接看帧)。劣势是握手套了一层 HTTP,报文头比裸 TCP 大那么几字节;本质是帧协议,自定义二进制内容的封装稍微绕一点,但完全可以接受。
原生 TCP 的优势是紧凑、省流量、握手里可以塞自定义鉴权字段,手机端用着最舒服,也没有 HTTP 语义的额外开销。劣势是网页端完全用不了,等于要维护两套接入层、两套客户端协议栈,长期维护成本翻倍。综合下来,选型之争本质是接入层复杂度与流量开销之间的权衡。
| 维度 | WebSocket | 原生 TCP | 我们的选择 |
|---|---|---|---|
| 网页端支持 | 原生支持 | 不支持 | WebSocket |
| 代理穿透 | HTTP 握手天然穿透 | 可能被企业代理拦 | WebSocket |
| 报文开销 | 帧头略大 | 最小 | TCP 占优 |
| 移动端省电 | 持平 | 持平 | 平 |
| 负载均衡转发 | 支持 WS 转发 | 需 TCP 透传 | 持平 |
| 客户端维护成本 | 一套 | 网页端要再套一层 | WebSocket |
最后我们选了 WebSocket,一套协议通吃所有端。代价是接受它的帧封装开销——一条文本消息多几个字节,在 IM 场景下完全可忽略,比起维护两套接入层便宜太多了。
折中方案里我们还认真考虑过协议网关:手机端走原生 TCP,网关层做个协议转换再连后端。最后还是否了,原因是转换层本身就是新的故障点,而且无法复用浏览器的调试工具。协议层的复杂度一旦上去了,排障成本比省下的那点流量高一个数量级。最终结论是:只要有网页端,就别动两套协议的念头。
配置与代码
应用层协议我们设计得很薄:WebSocket 握手通过后,所有业务都是定长头加变长体的二进制帧。定长头里放版本、压缩标记、指令类型、序列号、包长:
// 帧头 12 字节:版本(1) + 压缩标记(1) + 指令(2) + 序列号(4) + 包长(4)
type FrameHead struct {
Version uint8
Compress uint8
Cmd uint16
Seq uint32
Length uint32
}
编码时严格按网络字节序写,避免不同端的大小端差异埋雷:
func (f *FrameHead) Encode() []byte {
buf := make([]byte, 12)
buf[0] = f.Version
buf[1] = f.Compress
binary.BigEndian.PutUint16(buf[2:4], f.Cmd)
binary.BigEndian.PutUint32(buf[4:8], f.Seq)
binary.BigEndian.PutUint32(buf[8:12], f.Length)
return buf
}
握手那段额外做 HTTP 层面的事情:在 WebSocket 的子协议字段里带上连接票据(见 登录鉴权:Token 与连接票据),服务器在升级前先校验票据,通过才允许升级,拒绝的请求直接返回 401。
为了让多端复用同一套帧逻辑,我们把协议层做成一个纯函数式的 codec,不依赖具体传输,测试时可以直接拿它喂字节流,不用真的起一个 WebSocket 服务。这个决定后来救过我们两次:一次是压测工具要跑裸 TCP 压力,一次是 PC 客户端想先跑在加密隧道上,两边都是零改动接 codec。
type Codec struct{}
// 从字节流解出一帧,返回 consumed 表示消费了多少字节
func (c *Codec) Decode(buf []byte) (f *Frame, consumed int, err error) {
if len(buf) < 12 {
return nil, 0, ErrPartial // 头都不全,等下次读
}
h := parseHead(buf[:12])
if len(buf) < int(12+h.Length) {
return nil, 0, ErrPartial
}
return &Frame{Head: h, Body: buf[12 : 12+h.Length]}, int(12 + h.Length), nil
}
这套 codec 后来在网关、逻辑层、压测工具三处复用,成了整个项目里最稳的模块之一。
再补一个设计决策:心跳指令、ack、read_report 这类控制帧完全不进入业务消息表,Cmd 字段单独标记,网关直接处理完就回,不经过逻辑层。这样心跳流量再大也不会增加存储压力,压测时的数字干净很多。这个决策在 心跳与重连:弱网下怎么保活 里有完整展开。
帧头里保留一位版本号也是很值的设计。有它之后我们敢在线上灰度替换解析逻辑,老版本帧按老逻辑解,新版本走新分支,灰度期协议层零事故。
踩坑
第一个坑是粘包。虽然 WebSocket 帧本身有边界,但我们早期在 TCP 直连测试时手写了拆包逻辑,按固定 4KB 读缓冲切片,结果一条长消息被切成两半、两条短消息被拼成一帧,客户端疯狂解析失败。老老实实写了一个带状态机的 Reader:先读 12 字节头,按头里的 Length 再读定长 body,差一字节就等下一次读,从此没再出问题。这个状态机后来直接进了 codec 的 Decode 方法。
第二个坑是压缩。我们图省事开了 WebSocket 的 permessage-deflate 压缩,结果心跳包反而变大——压缩字典本身有开销,小于 100 字节的包压缩后比原来还大。后来改成只对大于 256 字节的消息做应用层压缩,心跳和控制帧裸跑,流量反而降了。
第三个坑是序列号撞车。seq 用 uint32 循环复用,某次客户端发了序列号为 N 的请求后断线重连,之前那条响应还在路上,重连后又分配到 N,客户端按 seq 匹配响应就串包了。改成握手时用时间戳做 seq 偏移基点,保证单连接内不撞车。
第四个坑是网关兼容性。某客户的公司网络强制走 HTTP 代理,我们的 WebSocket 连接被代理拦掉,客户端一直转圈。排查发现代理只允许 443 端口,我们却监听在 8080 上。之后网关统一走 443 加 TLS,浏览器也更友好。另外 TLS 握手会带来额外两个 RTT,我们开了会话复用(Session Resumption)把重连开销降下来,弱网下体感改善明显。
小结
协议选型的关键不是谁更先进,而是你的端需要什么:有网页端就别犹豫选 WebSocket,没有网页端且对流量极度敏感,原生 TCP 更合适。帧格式的设计原则是定长头加变长体,把指令和序列号留在头部,方便网关做路由和幂等校验。控制帧与业务帧分流,网关层自己消化心跳。协议一定记得与 长连接网关:连接管理与会话表 的会话设计联动,那篇才是连接层的正戏。
一旦协议定稿,轻易不要改头部长度或字段顺序——所有客户端都在按老格式打包。我们后来加新指令只能往 Cmd 的空余编号里塞,头部结构坚持了一年多没动过,多端升级的阵痛就此避免。
协议层是我们项目里改动最少的模块之一,这反过来印证了早期争论的价值:在最该慢下来的地方慢下来,后面就快了。建议后来者也把协议评审当成一个正式 milestone,别急着写代码。
协议设计里有一个字段决策可以早早做对:帧头里留一个版本号位(两位即可)。上线第一天你觉得它多余,等到要加新功能那一天它是救命的——客户端老版本看到不认识的版本号可以按旧逻辑降级解析,而不是整包解析失败断线。我们第三个版本加群禁言字段时全靠它,只改了一个字段的解析逻辑,灰度按桶放完全没有兼容性事故。协议字段的另一个原则是按固定长度对齐,版本号、消息类型、序号长度固定,内容长度的位置固定,解析器写成状态机后性能也在可控范围。
协议帧里还有一个坑值得早点知道:心跳不要和业务帧挤同一个发送队列。我们早期图省事心跳复用业务通道,结果发大文件消息时心跳被业务帧堵在队尾,对端觉得连接死了主动断开,重连风暴就是这么来的。后来给心跳与控制帧单开一条高优先级通道,业务队列再堵心跳也照常八十秒一跳,连接稳定性立刻好转。顺带一个收益:通过观察心跳回复的 RTT,网关可以自己感知到这条链路的拥塞程度,为限流和降级提供输入信号。
