发了三条消息,对方一次全收到了
自己写了一个基于 TCP 的小协议:一端
write一个 JSON,另一端read一次再解析。联调时一切正常。压测时开始出现解析失败:有时一次读到两个半 JSON,有时只读到半个。再后来,一个请求超时之后,下一个请求拿到了上一个请求的结果。
这些现象的原因是同一个:TCP 提供的是字节流,不是消息。发送方调用了几次写、每次写了多少字节,在传输过程中不保留;接收方的每一次读,拿到的是「此刻已经到达的若干字节」。消息从哪里开始、到哪里结束,哪个响应对应哪个请求,都要由应用自己约定。
用现成的 HTTP 或 RPC 框架时这些事情有人替你做了。自己写协议、对接设备或老系统、排查框架报出的奇怪长度时,就要直接面对它。本文在回环地址上用真实的 TCP 连接把这些情况逐个构造出来。实验在 JDK 25.0.4.1 上运行,见文末。
一、一次写不等于一次读
多次写被一次读到。 发送方分三次写出三条消息,接收方稍后读一次:
read 返回 24 字节:{"id":1}{"id":2}{"id":3}一条消息分几次才读全。 一条 34 字节的消息分两段到达,接收方的第一次读:
read 返回 20 字节:{"orderId":"A-1001",这两种情况常被叫作「粘包」和「拆包」。这两个词容易让人以为是网络出了毛病,其实从 TCP 的角度看什么也没发生:它保证的是字节按顺序、不丢不重地到达,除此之外没有别的承诺。拆在哪里取决于报文段大小、发送与接收缓冲区、拥塞状况,以及双方读写的时机。联调时消息小、发得慢,一次写恰好对应一次读;流量一大,对应关系就没了。
所以下面这种代码在任何语言里都是错的,只是错得时有时无:
int n = in.read(buf); // 以为读到的正好是一条完整的消息
handle(new String(buf, 0, n));InputStream.read(byte[]) 的约定是「至少读 1 个字节,至多读满」,返回多少都合法。
二、把边界写进字节流
两种常用的办法。
长度前缀。 每条消息前面放一个定长的长度字段,接收方先读满长度,再按长度读满内容:
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 条的内容里有一个换行:
接收方读到 3 行:{"note":"first"} ‖ {"note":"line one ‖ line two"}一条消息被拆成了两条残缺的。用分隔符就必须同时规定转义规则(JSON 序列化时把换行写成 \n 就是一种),并且保证所有的发送方都遵守。
现成的协议用的也是这两种办法:HTTP/1.1 用 Content-Length 或分块编码给消息体定界,头部用空行结束;HTTP/2、gRPC、Kafka、大多数 RPC 协议都是长度前缀的帧。
三、长度字段要有上限
长度前缀有一个必须处理的问题:长度是对方说的。对方发错了、发的根本不是这个协议、或者是恶意的,接收方就会按一个荒唐的长度去分配内存。
最常见的事故不是攻击,而是连错了端口。一个 HTTP 客户端连到二进制协议的端口上,发来的头 4 个字节是 GET 和一个空格:
把 "GET " 当作 4 字节长度 = 1195725856 (约 1.1 GB)
把 TLS 握手的前 4 个字节当作长度 = 369295618 (约 352 MB)在各种中间件的日志里见到「消息长度 1195725856 超出上限」这一类报错,原因几乎都是有人用 HTTP(浏览器、健康检查、curl)访问了一个非 HTTP 的端口;见到 3 亿多的数字,是对方在说 TLS 而这边没开。记住这两个数字能省不少排查时间。
实验里给接收方设了 1 MB 的上限,收到 HTTP 请求时的结果:
拒绝并关闭连接(frame length 1195725856 exceeds limit 1048576)没有上限的接收方会直接尝试分配 1.1 GB 的数组。上限按协议里合理的最大消息来定;超过上限时除了拒绝,还要关闭这条连接,因为字节流的后续内容已经无法正确解析。
四、响应要能对上请求
一条连接上同时有多个请求在途时,还需要第二个约定:哪个响应是哪个请求的。
响应不按请求的顺序回来。 客户端在同一条连接上先发一个慢请求,再发一个快请求;服务端并行处理,快的先完成:
| 配对方式 | 慢请求拿到 | 快请求拿到 |
|---|---|---|
| 按到达的先后 | 快请求的结果 | 慢请求的结果 |
| 按帧里的请求编号 | 慢请求的结果 | 快请求的结果 |
超时之后继续用这条连接。 这是更隐蔽的一种。客户端每次只发一个请求、等到响应再发下一个,看起来不需要编号。第一个请求读超时了,客户端放弃等待,接着在同一条连接上发第二个请求,然后读响应:
第一个请求(查 alice):读超时
第二个请求(查 bob):读到的响应是 answer-to-balance-of-alice第一个请求的响应只是迟到,并没有消失。它还在字节流里排着,被当成了第二个请求的响应;从此这条连接上的每一个响应都错位一格,每个调用方拿到的都是上一个人的数据。在连接池里,这条连接会被归还,继续服务别的请求。
两种处理办法:
- 帧里带请求编号。客户端维护一张「编号到等待者」的登记表,读到响应时按编号投递;编号对不上的(已经超时放弃的)直接丢弃。登记表的清理与超时之间的竞争,见 超时了、取消了,任务其实还在跑。
- 没有编号的协议,超时或出现任何读写异常之后,这条连接不能再用,必须关闭并重建,不能还回连接池。
响应虽然被丢弃了,请求却可能已经在对端执行,见 一次 HTTP 调用超时了,对方到底执行了没有。
五、写也可能只写了一部分
阻塞模式下,OutputStream.write 会一直等到全部字节都交给内核才返回。非阻塞模式(NIO 的 SocketChannel、各种事件循环框架)下,写只把当前发送缓冲区放得下的部分写进去,然后返回实际写出的字节数。实验里对端一直不读,一次写 64 MB:
返回值小于 64 MB = true,缓冲区里还有数据没写出 = true忽略返回值,后面没写出去的部分就丢了,对端收到的是一条被截断的消息,并且之后的帧全部错位。正确的做法是保留剩余的数据,等通道再次可写时接着写。这同时也是背压的信号:对端读得慢,这边的待发送数据就会堆积,需要有上限。
六、自己设计帧格式时
- 帧头定长,至少包含:内容长度、请求编号、消息类型。需要演进时再加协议版本。
- 读用「读满」的接口,不假设一次
read的返回量。 - 长度有上限,超限即断开;空闲连接有超时。
- 响应按编号配对;没有编号的协议,出错后连接不复用。
- 非阻塞写要处理部分写入,并限制待发送数据的总量。
- 测试时故意把数据切碎再发(每次 1 个字节)、把多条消息拼成一次写、在帧的中间断开连接。这三种输入能发现绝大部分分帧缺陷。
能用现成的协议和框架时,优先用它们:分帧、配对、背压这些事情,成熟的实现都已经处理过了。请求在更早的阶段(解析、建连、握手)失败时的表现,见 请求失败时,先看它停在了哪一步。
配套实验
- codesphere-labs/engineering/tcp-message-framing:JDK 25.0.4.1 上用回环地址的真实连接构造八个场景:多次写被一次读到、一条消息分段到达、长度前缀与分隔符、把其他协议的前 4 个字节当作长度、乱序响应的配对、超时后复用连接、非阻塞写的部分写入(验证记录)
参考资料