深入理解 IM 系统

旁支 W1

拆包与组帧

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

这一章是初稿,会在写完前八章后再修订。

第 2 章选了长连接长连接long-lived connection设备和服务端之间一直开着的一条 TCP 或 WebSocket 连接。有了它,服务端可以随时主动把消息推下去,不用等客户端来问。在术语表里查看:网页里用 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),或者把没读够的部分留到下一次。下面的第一种接收方就少了这一步。

先看开头那个例子:00 03 h i ! 00 02 o k 分两段到达,00 03 h 和 i ! 00 02 o k。接收方读完 2 字节的长度,正文只 read 一次,拿到多少算多少。Ben 会看到什么?

00长度(2 字节)ab正文(相邻消息深浅交替)

正文只 read 一次:对的
–
错的气泡
–
没显示出来的
–
长度前缀 + 缓冲:对的
–

先切到“本机测试”:服务器往 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 默认一行最多 64 KB,超了就报 “token too long” 并停下。

Redis 的协议 RESP 两种都用:短的“简单字符串”以 \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,每一帧有一个 2 字节的头,里面 7 位是长度:0–125 直接就是长度;写 126,后面再跟 2 字节的长度;写 127,后面跟 8 字节。从客户端发往服务器的帧还要多 4 字节的掩码。一条 200 字节的消息,服务器发给手机是 2 + 2 = 4 字节的头;手机发给服务器是 8 字节,4%。

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

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

App 走原生 TCP 时,第 2 节那个循环就要自己写,或者用现成的:Go 里是 bufio.Reader 加 io.ReadFull;Netty 里是 LengthFieldBasedFrameDecoder,构造时必须给一个 maxFrameLength,长度超过它就抛 TooLongFrameException。

5. 其他答案

  • Telegram 的 MTProto 跑在 TCP 上时有几种组帧方式:1 字节长度(以 4 字节为单位,长包改用 4 字节)、固定 4 字节、或 12 字节(长度、序号、CRC32 校验);连接开头的 1 个或 4 个特定字节说明用哪一种,什么都不带就是 12 字节那种。
  • HTTP/2 也是一条 TCP 连接上的帧:每帧一个 9 字节的头,长度占 24 位;接收方默认只收 16,384 字节以内的帧,可以声明放大,最多到 2^24 − 1。跑在它上面的 gRPC 又给每条消息加了自己的 5 字节前缀(1 字节压缩标志加 4 字节长度),因为一条 gRPC 消息可能跨好几个 HTTP/2 帧。两层组帧,各管各的边界。
  • Redis 的 RESP:上面说过,分隔符和长度前缀混用;bulk string 默认最大 512 MB(proto-max-bulk-len),也是一个上限。

6. 这一篇的决定

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