# 旁支 W1：拆包与组帧

> TCP 送到的是字节，不是消息。把 00 03 h i ! 00 02 o k 切成一条一条的消息。

深入理解 IM 系统 · https://im.liko.page/zh/framing/

第 2 章选了长连接：网页里用 WebSocket，App 里常用 TLS 上的自定义 TCP 协议。那一章留了一句话：走原生 TCP 的话，“一条消息从哪开始、到哪结束”要自己切。这篇旁支讲的就是这件事。

TCP 交给接收方的是一串字节，不是一条一条的消息。它保证这些字节按顺序到、不丢、不重，但不保证“发送方 write 一次，接收方就正好 read 到一次”。接收方调一次 `read()` 拿到多少字节，由网络和内核决定。

所以发送方要在字节里写下边界。最常见的写法是在每条消息前面放两个字节，写这条消息有多长。Ana 连发了“hi!”和“ok”，线上的字节是：

```
00 03 68 69 21 00 02 6f 6b
```

为了好读，下面把能打印的字节直接写成字符：`00 03 h i ! 00 02 o k`。`00 03` 说“后面 3 个字节是一条消息”，于是 `h i !` 是第一条；接着 `00 02` 说“后面 2 个”，`o k` 是第二条。

组帧是每一跳各做一次：服务器先从 Ana 的连接里切出她发的消息，再写到 Ben 的连接上，Ben 的手机再切一次。下面看后一跳。

## 1. 看它坏掉

很多人第一次写的接收代码是这样的：先把 2 字节的长度读全，再调**一次** `read()` 读正文，返回多少就当正文是多少。这错在 `read()` 的约定：它只保证返回“最多”你要的字节数，可能更少。正确的写法是一直读到够数为止（Go 里是 `io.ReadFull`），或者把没读够的部分留到下一次。下面的第一种接收方就少了这一步。

*（互动图：请在网页上查看 https://im.liko.page/zh/framing/）*

先切到“本机测试”：服务器往 Ben 的连接写了 6 次，每次都单独到达，两种接收方都对。本机上不丢包、读得又快，一次 write 几乎总能一次 read 读完，所以这段代码能通过所有测试。

再切到“手机网络”。同样的 47 个字节，分 6 段到达：

- 第 4 段只有 “on my way” 的长度和前 4 个字节 “on m”。长度说 9，正文那次 `read()` 只返回了 4 个字节，接收方就把 “on m” 当成整条显示出来。它没有扔掉任何字节，只是剩下的 “y way” 还留在连接里没读。
- 下一次读长度，读到的是 “y” 和空格：“y” 是 0x79，空格是 0x20，按 2 字节、高位在前读成长度就是 0x7920 = 31,008。正文那次 `read()` 拿走这一段剩下的 17 个字节，显示成一个乱码气泡。“see you at 7” 从此没有单独出现过。
- 第 6 段碰巧从一条消息的开头开始，“where?” 又对了。

6 条消息，Ben 看对了 4 条，而且错得悄无声息：没有报错，只有半截消息和乱码。“再切一次”会看到别的切法；有时候切口全落在长度里或两条消息之间，这段代码又全对了。这就是它难查的原因。

另一种同样常见的写法连长度都没有：每次 write 一个 JSON，每次 read 到的东西直接拿去解析。它在半包时解析失败，在两条消息挤进一次 read 时也失败。

“长度前缀 + 缓冲”那个接收方每次都对。它把读到的字节先放进一个缓冲，只按长度往外取，不够一条就留着，等下一段补齐。Go 里用 `bufio.Reader` 加两次 `io.ReadFull`（一次读长度、一次读正文）是同一个意思。

### 粘包和半包

中文的工程师把这两种情况叫作**粘包**（一次 read 里有好几条消息）和**半包**（一次 read 里只有一条的一部分）。名字听起来像是 TCP 出了毛病，其实不是。TCP 是一条字节流，本来就没有“包”的边界，也从没承诺过 write 和 read 一一对应。把边界弄丢的是应用自己：发的时候没有写下边界，或者写了，读的时候没有照着切。（UDP 每个数据报单独收，保留边界，所以没有这个说法。）

手机网络上，这两种情况每天都在发生：

- **粘**：丢了一个包，TCP 要重传，它后面已经到的字节只能在内核里等着，重传的包一到，几条消息一起交上来；App 刚从后台醒来、主线程卡了一下，也会让几条攒在一起。
- **半**：一条消息比一个 TCP 包能装的数据（大约 1.4 KB）还大，或者丢的、晚到的包正好落在一条消息中间，它就分几次到。

## 2. 三种切法

**分隔符**：每条消息后面跟一个特殊字节，比如换行，一行一条（JSON Lines 就是这样）。好处是简单，用 telnet 都能看懂。代价有三个：

- 正文里不能出现这个分隔符，出现了就要转义。JSON 本身会把字符串里的换行写成 `\n`，所以“一行一个 JSON”能行；二进制数据就得转义，或者先转成 base64，多出 33% 的字节。
- 接收方要一个字节一个字节地找分隔符。
- 一条连接如果一直不发换行，缓冲就一直涨，所以还得规定一行最长多少。Go 的 [`bufio.Scanner`](https://pkg.go.dev/bufio) 默认一行最多 64 KB，超了就报 “token too long” 并停下。

Redis 的协议 [RESP](https://redis.io/docs/latest/develop/reference/protocol-spec/) 两种都用：短的“简单字符串”以 `\r\n` 结尾，规定里面不能有 `\r` 和 `\n`；任意内容的“bulk string”前面写上长度，比如 `$5\r\nhello\r\n`，正文不用扫描、也不用转义。

**长度前缀**：就是上面“长度前缀 + 缓冲”那个接收方。每条消息前面放一个固定大小的长度，接收方这样切：

1. 把 read 到的字节追加到这条连接的缓冲后面；
2. 缓冲里不到 2 个字节（长度本身）：等下一次 read；
3. 读出长度 n；缓冲里不到 2 + n 个字节：等下一次 read；
4. 取出这 n 个字节，就是一条完整的消息，交出去；回到第 2 步。

一次 read 可能取出零条、一条，也可能好几条，剩下不完整的留在缓冲里。正文里可以是任何字节，不用转义，也不用扫描。注意长度数的是字节，不是字：“好”是一个字，UTF-8 里是 3 个字节，模拟器里它的长度写的就是 `00 03`。

**定长**：每条消息都是固定的 N 个字节，连长度都不用写。可聊天消息长短差得很远：按最长的定，短消息全是填充；按短的定，长的放不下。它只适合本来就定长的东西。

这里选长度前缀。

## 3. 估算：长度写几个字节，最长允许多长

头部的开销可以不管：全书按一条消息 200 字节算，2 字节的长度是 2 ÷ 200 = **1%**，4 字节是 **2%**，都比每个 TCP 包自带的 40 字节头小。

**真正要选的是上限**：

- 2 字节最大是 65,535 字节。一条 4,000 个汉字的长文本，UTF-8 是 12,000 字节，放得下；图片和文件本来就不走这条连接（第 8 章：图片与文件走单独的上传和下载）。
- 可长度字段一旦定下，以后很难再改（第 30 章：新服务端要和两年前的 App 一起工作）。所以常见的选择是 4 字节，最大约 4 GB，再自己设一个上限。

为什么上限要紧？接收方是按别人写的长度来收的。一个写错长度的旧版本 App，或者一个故意捣乱的客户端，发来 `7f ff ff ff`，说“后面有 2 GB”。如果服务器照着这个长度先分配缓冲，一条连接就能把它拖垮。就算不预先分配、来多少存多少，一条连接也可以慢慢地发、一直不发完。按上限算一下最坏的情况，每条连接都卡着一条收到一半的消息：

| 每条连接最多缓冲 | v1：1 万人在线（一台服务器） | v3：一台网关 10 万条连接（内存 16 GB） |
|---|---|---|
| 64 KiB | 10,000 × 64 KiB ≈ 0.66 GB | 100,000 × 64 KiB ≈ 6.6 GB |
| 1 MiB | 10,000 × 1 MiB ≈ 10.5 GB | 100,000 × 1 MiB ≈ 105 GB |

所以：

- 上限按协议真正需要的定。手机发上来的是单条消息，64 KB 已经很宽；服务器发下去的一批补发（第 6 章：重连后按页补回错过的消息）由服务器自己分页，不必放大上限。
- 不按声明的长度预先分配，字节来了再长；再给每一帧一个**帧内超时**：一帧的第一个字节到了以后，整帧必须在 T 秒内收完，否则断开。只要还有字节在来就不算超时的话，一条慢慢滴字节的连接永远不会被发现。连接空闲、一帧都没有的时候，靠第 22 章的心跳（定时发一个小包，证明连接还活着）来发现它断了。
- 长度超过上限怎么办？如果长度是真的，只是太大，可以把这么多字节读完扔掉，接着读下一条：Netty 的解码器就是这样，丢掉超长帧的字节，抛出 `TooLongFrameException`，然后接着解下一条。可接收方分不清“真的太大”和“长度本身是坏的”；如果是坏的，就再也找不到下一条从哪开始。而且就算要跳，也得把那么多字节读完。所以 IM 服务端通常**直接断开连接**。手机重连以后，没收到确认的消息会重发（第 3 章：没收到确认就重发）。

## 4. WebSocket 已经替你切好了

第 2 章说过，网页里只能用 WebSocket，而 WebSocket 本身就是一种组帧协议。按 [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455#section-5.2)，每一帧有一个 2 字节的头，里面 7 位是长度：0–125 直接就是长度；写 126，后面再跟 2 字节的长度；写 127，后面跟 8 字节。从客户端发往服务器的帧还要多 4 字节的掩码。一条 200 字节的消息，服务器发给手机是 2 + 2 = 4 字节的头；手机发给服务器是 8 字节，4%。

浏览器的 `onmessage` 交给你的永远是一整条消息，服务端的 WebSocket 库也会替你把帧拼好。一条消息还可以拆成好几帧发，库会拼回去。所以网页这边看不到粘包和半包。

上限却还是要你自己设。RFC 6455 的 [10.4 节](https://www.rfc-editor.org/rfc/rfc6455#section-10.4)专门提醒：恶意的一端可以发一个声称有 2^60 字节的帧，或者把一条消息拆成无穷多个小帧，实现必须保护自己，应该限制单帧和拼好后整条消息的大小。用 WebSocket 库时，要找到它限制帧和消息大小的设置，把它设上。

App 走原生 TCP 时，第 2 节那个循环就要自己写，或者用现成的：Go 里是 [`bufio.Reader`](https://pkg.go.dev/bufio) 加 [`io.ReadFull`](https://pkg.go.dev/io#ReadFull)；Netty 里是 [`LengthFieldBasedFrameDecoder`](https://netty.io/4.1/api/io/netty/handler/codec/LengthFieldBasedFrameDecoder.html)，构造时必须给一个 `maxFrameLength`，长度超过它就抛 `TooLongFrameException`。

## 5. 其他答案

- **Telegram 的 MTProto** 跑在 TCP 上时有[几种组帧方式](https://core.telegram.org/mtproto/mtproto-transports)：1 字节长度（以 4 字节为单位，长包改用 4 字节）、固定 4 字节、或 12 字节（长度、序号、CRC32 校验）；连接开头的 1 个或 4 个特定字节说明用哪一种，什么都不带就是 12 字节那种。
- **HTTP/2** 也是一条 TCP 连接上的帧：[每帧一个 9 字节的头](https://www.rfc-editor.org/rfc/rfc9113#section-4.1)，长度占 24 位；接收方默认只收 16,384 字节以内的帧，可以声明放大，最多到 2^24 − 1。跑在它上面的 gRPC 又给每条消息加了自己的 [5 字节前缀](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)（1 字节压缩标志加 4 字节长度），因为一条 gRPC 消息可能跨好几个 HTTP/2 帧。两层组帧，各管各的边界。
- **Redis 的 RESP**：上面说过，分隔符和长度前缀混用；bulk string 默认最大 512 MB（`proto-max-bulk-len`），也是一个上限。

## 6. 这一篇的决定

**决策卡**

- 问题：TCP 只交出字节流，一次 read 拿到多少由网络决定；“读完长度、正文只 read 一次”在本机测得过，在手机网络上会显示半截消息，之后从消息中间读长度，后面的消息变成乱码。
- 选择：网页走 WebSocket，帧由协议和库切好；App 的原生 TCP 协议在每条消息前写上 4 字节长度，接收方用缓冲按长度切，读够了才交出；长度设上限，超过就断开连接；每一帧开始后要在限定时间内收完。
- 代价：每条消息多 4 字节（200 字节的消息约 2%）；每条连接一块读缓冲；长度一旦读错无法恢复，只能断开重连。
- 什么时候重新考虑：一条连接上要同时传大块数据和小消息时（第 8 章，图片与文件走另一条路）；连接多到读缓冲占内存时（第 21 章，百万连接）；头部还要放类型、版本、压缩标志时（旁支 W2 编码，第 30 章新老版本兼容）。
- 其他答案：分隔符（JSON Lines、RESP 的简单字符串）；定长；MTProto 的 1、4 或 12 字节头；HTTP/2 的帧和 gRPC 的 5 字节前缀。

长度切出来的是一段一段的字节，里面怎么排还没说：同一条消息，用 JSON 还是二进制，差多少字节？这是旁支 W2“编码”。主线从第 3 章“确认与重传”继续。
