在 TCP 上跑一个自己的业务协议, 第一件要决定的事往往不是用什么序列化, 也不是字段怎么排, 而是一条消息到哪里结束.
这听上去不太像个问题, 但它是自定义协议里最容易写错的地方, 而且错了以后在测试环境里往往一切正常.
先把问题说清楚
常见的说法是"TCP 粘包". 这个说法其实不太准确, 因为它暗示 TCP 本来应该给你"包", 只是不小心粘上了.
TCP 从来没承诺过包. 它承诺的只有三件事: 你写进去的字节会按顺序到达, 不丢, 不重. 至于这些字节怎么切分成一次次 Read, 完全不受你控制:
- 发送侧, 两次
Write可能被合并进同一个 TCP 段 (Nagle 算法就是干这个的). - 一次
Write也可能被拆成多段, 因为 MSS, 因为发送窗口, 因为路径 MTU. - 中间的设备还可能再切一刀.
所以下面这段代码是错的:
buf := make([]byte, 4096)
n, err := conn.Read(buf) // 读到的不一定是一条完整消息
handle(buf[:n]) // 可能是半条, 也可能是两条半它在本机测试里几乎永远是对的 —— 消息短, 网络快, 一次一条. 一上生产, 报文变长, 网络变差, 就开始随机地解析失败. 而且因为是随机的, 查起来非常烦人.
边界是应用层自己的事. 划边界的办法总共就那么几种.
fixed |<-- 64 bytes -->|<-- 64 bytes -->|<-- 64 bytes -->|
prefixed |len=12|<-- 12 bytes -->|len=5|<-5B->|len=40|<-- 40 bytes -->|
delimited | hello\r\n | world\r\n | a-longer-line\r\n |一, 定长报文
每条报文固定 N 字节, 读满 N 就是一条. 没有比这更简单的了:
const frameSize = 64
func readFrame(r io.Reader) ([]byte, error) {
buf := make([]byte, frameSize)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}好处是解析成本为零, 不需要任何状态机; 缓冲区可以预分配甚至复用; 而且天然没有"对方声明了一个 2GB 的长度"这类问题 —— 长度根本不由对方决定.
代价是短消息要补白, 浪费带宽; 更要命的是改结构就是不兼容. 定长意味着字段的偏移量写死在两边的代码里, 加一个字段就得双方同时升级.
所以它适合字段本来就固定的场景: 心跳, 传感器上报, 行情快照, 工控设备. 另外, 很多老协议的包头是定长的, 那本质上也是这一类 —— 只不过定长的部分只是头, 后面还跟着变长的身体, 也就是下面这种.
二, 定长头部 + 长度字段
最常用的一种. 头部长度固定, 头部里带着后面还有多少字节, 于是读取变成明确的两步:
const (
headerSize = 4
maxBodyLen = 1 << 20 // 必须有上限
)
func readMessage(r io.Reader) ([]byte, error) {
var head [headerSize]byte
if _, err := io.ReadFull(r, head[:]); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(head[:])
if n > maxBodyLen {
return nil, fmt.Errorf("frame too large: %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}现实里的协议基本都是这个路子, 只是头的形状不同:
| 协议 | 头 |
|---|---|
| gRPC | 1 字节压缩标志 + 4 字节大端长度 |
| TLS record | 1 字节类型 + 2 字节版本 + 2 字节长度 |
| MySQL | 3 字节小端长度 + 1 字节序号 |
| Redis bulk string | $<len>\r\n |
有几个坑值得单独拎出来.
长度到底算什么, 必须定义清楚. 是 body 的长度, 还是包含头部的总长? 差一个头长度, 结果就是每读一条报文错位一次, 而且第一条往往还是对的. 有些协议干脆两个都给, 各有各的用处: 总长用来切帧, body 长用来在解压或者解密之后确定有效数据到哪里为止 —— 因为变换过后的长度和线上的长度不是一回事.
长度字段的编码要和字节序一起说清楚. 大端还是小端 (网络字节序习惯上是大端, 但 MySQL 就是小端), 是二进制还是 ASCII 数字, 老一点的金融协议里还会用 BCD 把长度压成半个字节.
一定要校验上限. 不校验就是一个远程内存耗尽漏洞: 对方发一个 0xFFFFFFFF, 你就 make([]byte, 4G). 这不需要什么高深的攻击手法, 一个写错的客户端就能把你打挂. 上面代码里 maxBodyLen 那三行不是可选的.
长度也可能是分段的. MySQL 的长度只有 3 字节, 所以超过 16MB 的包会被拆成多个, 最后一个包的长度小于 0xFFFFFF 才算结束. 设计协议的时候, 与其给长度字段留很宽的位数, 不如明确规定一个上限加分段规则.
考虑一下错位之后怎么办. 一旦解析错位, 后面读到的全是垃圾. 头部里放一个魔数或版本号可以帮你发现这件事. 至于发现之后怎么恢复 —— 老实说, 大多数情况下直接断开重连比"尝试重新同步"要安全得多, 因为你已经不知道自己在流的什么位置了.
三, 分隔符
用一个约定不会出现在正文里的字节序列切分, 最常见的就是 \r\n:
sc := bufio.NewScanner(conn)
sc.Buffer(make([]byte, 0, 4096), 64*1024) // 显式给出上限
for sc.Scan() {
handle(sc.Bytes())
}
if err := sc.Err(); err != nil {
// 注意 bufio.ErrTooLong
}好处是人可读. 可以直接 telnet 上去敲命令, 抓包能直接看懂, 出问题的时候这一点非常值钱. 而且发送方不需要预先知道长度, 天然适合流式产生的内容.
代价主要是两条.
一是正文里出现分隔符怎么办. 只有两个选择: 禁止 (规定正文不能含 \n), 或者转义 (然后接收方要解转义). 前者限制了能传什么, 后者带来解析复杂度和长度膨胀, 而且转义规则一旦有歧义就是安全问题的温床 —— HTTP 请求走私那一类漏洞, 根子上就是收发双方对边界的理解不一致.
二是同样必须设上限. 对方发一个永远不带换行的流, 你就一直往缓冲区里塞. bufio.Scanner 默认 64KB 的上限就是干这个用的, 别把它调成无限大; Go 的 net/http 对请求行和 header 也都有长度限制, 同理.
顺便, 扫描分隔符是 O(n) 的, 数据量大的时候比读一个长度字段贵不少.
实际上, 混着用才是常态
纯分隔符协议处理二进制正文太难受, 所以文本协议基本都是混合的:
- HTTP/1.1: header 用
\r\n分行, 空行表示 header 结束; body 的长度由Content-Length给出. 而 chunked 编码本身又是一个混合体 —— 每个 chunk 是"十六进制长度 +\r\n+ 数据 +\r\n", 长度前缀和分隔符一起上. - Redis RESP: 行结构用
\r\n, 但 bulk string 是$5\r\nhello\r\n—— 先给长度, 再读定长数据, 后面那个\r\n纯粹是为了人看着舒服. 正因为有长度, 值里面含\r\n完全没问题.
这两个例子里的逻辑是一样的: 控制信息用分隔符, 因为要人可读; 数据本身用长度, 因为要能装任意字节.
还有一种极端做法值得一提: 一条连接只发一条消息, 用连接关闭当边界. HTTP/1.0 的默认行为就是这样, 没有 Content-Length 的响应读到 FIN 为止. 它的代价是每条消息一次三次握手, 而且你没法区分"对方发完了"和"对方挂了".
怎么选
大致是这么几条:
- 正文是二进制 → 长度前缀. 没什么可犹豫的.
- 正文是文本, 而且你希望能 telnet 上去调试 → 分隔符, 但数据部分还是给长度.
- 字段完全固定且很短 → 定长, 省掉一切解析.
- 想都不想就用 protobuf / gRPC / HTTP → 也挺好, 它们只是把上面这些选择替你做完了, 该有的上限校验也都做了.
不管选哪种, 这三件事都要做
一, 用 ReadFull, 别相信单次 Read. 这是最常见的一个 bug, 而且如上所述, 它在测试环境里不会暴露.
二, 设上限. 长度字段的上限, 单行的上限, 缓冲区的上限. 少一个就是一条拒绝服务的路径.
三, 设读超时. 一个只发了半条报文然后就不动了的连接, 如果没有 deadline, 会永远占着你的 goroutine 和缓冲区. 而且这种连接在操作系统看来完全正常, 你不主动超时就发现不了.
conn.SetReadDeadline(time.Now().Add(30 * time.Second))这三条都是从"对端不一定是善意的, 也不一定是正确的"这个前提推出来的. 自己写协议的时候很容易默认对面跑的是自己写的客户端, 但只要那个端口暴露在网络上, 这个假设就不成立.