Skip to content

发了三条消息,对方一次全收到了 ​

自己写了一个基于 TCP 的小协议:一端 write 一个 JSON,另一端 read 一次再解析。联调时一切正常。压测时开始出现解析失败:有时一次读到两个半 JSON,有时只读到半个。再后来,一个请求超时之后,下一个请求拿到了上一个请求的结果。

这些现象的原因是同一个:TCP 提供的是字节流,不是消息。发送方调用了几次写、每次写了多少字节,在传输过程中不保留;接收方的每一次读,拿到的是「此刻已经到达的若干字节」。消息从哪里开始、到哪里结束,哪个响应对应哪个请求,都要由应用自己约定。

用现成的 HTTP 或 RPC 框架时这些事情有人替你做了。自己写协议、对接设备或老系统、排查框架报出的奇怪长度时,就要直接面对它。本文在回环地址上用真实的 TCP 连接把这些情况逐个构造出来。实验在 JDK 25.0.4.1 上运行,见文末。

一、一次写不等于一次读 ​

发送方:三次 write{"id":1}{"id":2}{"id":3}字节流{"id":1}{"id":2}{"id":3}接收方:一次 read24 字节,三条连在一起或者:两次 read前 14 字节后 10 字节加上长度前缀8{"id":1}8{"id":2}8{"id":3}长度字段必须有上限:把 HTTP 请求开头的 GET 加空格这 4 个字节当成长度,得到 1195725856响应可能不按请求顺序回来,超时的请求其响应可能稍后才到:帧里要带请求编号,不能按先后配对
图 1 · TCP 交给应用的是一串连续的字节。发送方调用了几次写、每次写了多少,接收方无从知道:三条消息可能被一次读到,一条消息也可能分几次才读全。消息的边界要由应用自己写进字节流里。最常用的做法是在每条消息前面放一个定长的长度字段,接收方先读满长度,再按长度读满内容;同一条连接上有多个请求在途时,再加一个请求编号用来配对。

多次写被一次读到。 发送方分三次写出三条消息,接收方稍后读一次:

text
read 返回 24 字节:{"id":1}{"id":2}{"id":3}

一条消息分几次才读全。 一条 34 字节的消息分两段到达,接收方的第一次读:

text
read 返回 20 字节:{"orderId":"A-1001",

这两种情况常被叫作「粘包」和「拆包」。这两个词容易让人以为是网络出了毛病,其实从 TCP 的角度看什么也没发生:它保证的是字节按顺序、不丢不重地到达,除此之外没有别的承诺。拆在哪里取决于报文段大小、发送与接收缓冲区、拥塞状况,以及双方读写的时机。联调时消息小、发得慢,一次写恰好对应一次读;流量一大,对应关系就没了。

所以下面这种代码在任何语言里都是错的,只是错得时有时无:

java
int n = in.read(buf);                 // 以为读到的正好是一条完整的消息
handle(new String(buf, 0, n));

InputStream.read(byte[]) 的约定是「至少读 1 个字节,至多读满」,返回多少都合法。

二、把边界写进字节流 ​

两种常用的办法。

长度前缀。 每条消息前面放一个定长的长度字段,接收方先读满长度,再按长度读满内容:

java
static byte[] readFrame(DataInputStream in, int maxLength) throws IOException {
    int n = in.readInt();                                   // 读满 4 个字节
    if (n < 0 || n > maxLength) throw new IOException("frame length " + n);
    byte[] payload = new byte[n];
    in.readFully(payload);                                  // 读满 n 个字节,不够就继续等
    return payload;
}

关键在「读满」:readFully(或 readNBytes)内部循环调用 read,直到凑够指定的字节数。实验里用它在上面两种到达方式下都正确地读出了 4 条完整的消息。

分隔符。 用一个特殊的字节序列标记消息结束,最常见的是换行。实现简单,人眼可读,但消息内容里不能出现分隔符。实验里发出 2 条消息,第 2 条的内容里有一个换行:

text
接收方读到 3 行:{"note":"first"} ‖ {"note":"line one ‖ line two"}

一条消息被拆成了两条残缺的。用分隔符就必须同时规定转义规则(JSON 序列化时把换行写成 \n 就是一种),并且保证所有的发送方都遵守。

现成的协议用的也是这两种办法:HTTP/1.1 用 Content-Length 或分块编码给消息体定界,头部用空行结束;HTTP/2、gRPC、Kafka、大多数 RPC 协议都是长度前缀的帧。

三、长度字段要有上限 ​

长度前缀有一个必须处理的问题:长度是对方说的。对方发错了、发的根本不是这个协议、或者是恶意的,接收方就会按一个荒唐的长度去分配内存。

最常见的事故不是攻击,而是连错了端口。一个 HTTP 客户端连到二进制协议的端口上,发来的头 4 个字节是 GET 和一个空格:

text
把 "GET " 当作 4 字节长度            = 1195725856   (约 1.1 GB)
把 TLS 握手的前 4 个字节当作长度     = 369295618    (约 352 MB)

在各种中间件的日志里见到「消息长度 1195725856 超出上限」这一类报错,原因几乎都是有人用 HTTP(浏览器、健康检查、curl)访问了一个非 HTTP 的端口;见到 3 亿多的数字,是对方在说 TLS 而这边没开。记住这两个数字能省不少排查时间。

实验里给接收方设了 1 MB 的上限,收到 HTTP 请求时的结果:

text
拒绝并关闭连接(frame length 1195725856 exceeds limit 1048576)

没有上限的接收方会直接尝试分配 1.1 GB 的数组。上限按协议里合理的最大消息来定;超过上限时除了拒绝,还要关闭这条连接,因为字节流的后续内容已经无法正确解析。

四、响应要能对上请求 ​

一条连接上同时有多个请求在途时,还需要第二个约定:哪个响应是哪个请求的。

响应不按请求的顺序回来。 客户端在同一条连接上先发一个慢请求,再发一个快请求;服务端并行处理,快的先完成:

配对方式慢请求拿到快请求拿到
按到达的先后快请求的结果慢请求的结果
按帧里的请求编号慢请求的结果快请求的结果

超时之后继续用这条连接。 这是更隐蔽的一种。客户端每次只发一个请求、等到响应再发下一个,看起来不需要编号。第一个请求读超时了,客户端放弃等待,接着在同一条连接上发第二个请求,然后读响应:

text
第一个请求(查 alice):读超时
第二个请求(查 bob):读到的响应是 answer-to-balance-of-alice

第一个请求的响应只是迟到,并没有消失。它还在字节流里排着,被当成了第二个请求的响应;从此这条连接上的每一个响应都错位一格,每个调用方拿到的都是上一个人的数据。在连接池里,这条连接会被归还,继续服务别的请求。

同一条连接,没有请求编号客户端字节流请求 1:查 alice读超时,放弃请求 2:查 bob读到 alice 的结果answer-to-alice(迟到)answer-to-bob被当成请求 2 的响应两种处理帧里带请求编号按编号投递,编号已放弃的直接丢弃没有编号:出错即关闭连接重建连接,不还回连接池
图 2 · 实测(JDK 25.0.4.1,回环地址上的真实 TCP 连接):查 alice 的请求读超时,客户端在同一条连接上接着查 bob,读到的是 answer-to-balance-of-alice。迟到的响应没有消失,只是排在字节流里。帧里带请求编号时按编号投递、丢弃已放弃的;没有编号的协议出错后必须关闭连接,不能还回连接池

两种处理办法:

  • 帧里带请求编号。客户端维护一张「编号到等待者」的登记表,读到响应时按编号投递;编号对不上的(已经超时放弃的)直接丢弃。登记表的清理与超时之间的竞争,见 超时了、取消了,任务其实还在跑。
  • 没有编号的协议,超时或出现任何读写异常之后,这条连接不能再用,必须关闭并重建,不能还回连接池。

响应虽然被丢弃了,请求却可能已经在对端执行,见 一次 HTTP 调用超时了,对方到底执行了没有。

五、写也可能只写了一部分 ​

阻塞模式下,OutputStream.write 会一直等到全部字节都交给内核才返回。非阻塞模式(NIO 的 SocketChannel、各种事件循环框架)下,写只把当前发送缓冲区放得下的部分写进去,然后返回实际写出的字节数。实验里对端一直不读,一次写 64 MB:

text
返回值小于 64 MB = true,缓冲区里还有数据没写出 = true

忽略返回值,后面没写出去的部分就丢了,对端收到的是一条被截断的消息,并且之后的帧全部错位。正确的做法是保留剩余的数据,等通道再次可写时接着写。这同时也是背压的信号:对端读得慢,这边的待发送数据就会堆积,需要有上限。

六、自己设计帧格式时 ​

  1. 帧头定长,至少包含:内容长度、请求编号、消息类型。需要演进时再加协议版本。
  2. 读用「读满」的接口,不假设一次 read 的返回量。
  3. 长度有上限,超限即断开;空闲连接有超时。
  4. 响应按编号配对;没有编号的协议,出错后连接不复用。
  5. 非阻塞写要处理部分写入,并限制待发送数据的总量。
  6. 测试时故意把数据切碎再发(每次 1 个字节)、把多条消息拼成一次写、在帧的中间断开连接。这三种输入能发现绝大部分分帧缺陷。

能用现成的协议和框架时,优先用它们:分帧、配对、背压这些事情,成熟的实现都已经处理过了。请求在更早的阶段(解析、建连、握手)失败时的表现,见 请求失败时,先看它停在了哪一步。


配套实验

  • codesphere-labs/engineering/tcp-message-framing:JDK 25.0.4.1 上用回环地址的真实连接构造八个场景:多次写被一次读到、一条消息分段到达、长度前缀与分隔符、把其他协议的前 4 个字节当作长度、乱序响应的配对、超时后复用连接、非阻塞写的部分写入(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权