Posted on ::

在 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
}

现实里的协议基本都是这个路子, 只是头的形状不同:

协议
gRPC1 字节压缩标志 + 4 字节大端长度
TLS record1 字节类型 + 2 字节版本 + 2 字节长度
MySQL3 字节小端长度 + 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))

这三条都是从"对端不一定是善意的, 也不一定是正确的"这个前提推出来的. 自己写协议的时候很容易默认对面跑的是自己写的客户端, 但只要那个端口暴露在网络上, 这个假设就不成立.

Table of Contents