”TCP 拆包粘包“网络上的争论挺大的,原因在于这一概念在 RFC 中并未提到过,(* ̄︶ ̄)有点民科的赶脚。但实际上它是挺常见的一个问题,比较准确的描述应该是应用层分包问题。那么为什么提到这一概念总是会带上 TCP 而不是 UDP 呢?因为应用层分包问题本质是接收方在某个时刻接收到的数据不构成一条完整的消息
对于 UDP 来说,它并未在 IP 协议外作出额外的可靠性特性,所以无论发送的数据荷载是多少都会完整的交由 IP 层处理,而 IP 层虽然会根据 MTU 进行分包,但是接收方会确保在 IP 层重组完成才会交由 UDP 处理,否则会直接丢弃。所以基于 UDP 协议实现的应用层协议每次接收到的数据都是完整的消息,无需应用层做额外的分包和重组
对于 TCP 来说,它是无边界的字节流协议,为了解决可靠性的问题以及各种性能优化会导致接收方收到的数据不是完整的消息,比如:
- 若数据荷载过小时,TCP 根据 Nagle 算法整合多个小数据包统一发送
- 若数据荷载过大时,TCP 会根据 MSS 大小对数据进行分段发送
这也就是为啥”拆包粘包“总是碰瓷 TCP 的原因了
对于 HTTP1.0 时代,每个 HTTP 请求对应一个 TCP 连接,所以实际上每个 Socket 只会存在一个消息,所以接收方只需要一次性读到对端关闭写即可,无需考虑分包问题。而从 HTTP1.1 开始支持了 TCP 长连接,一个 TCP 连接可能顺序传过来多个请求,因此需要考虑分包策略。常见的分包策略有:
- 固定的消息长度,比如每 100 个字节代表一个消息,若不足则补位对齐。解码器处理时只需要判断当前可读的字节数,每次读取到指定长度的字节后进行解码处理即可
- 消息头中设置长度字段,比如每个消息的前 4 个字节用于标志本次消息 Body 的大小。解码器处理时需要先判断 当前可读的字节数是否大于 Header 的大小(这里就是 4 个字节),然后解码出本次消息 Body 的大小,最后根据长度读取到消息 Body 进行解码处理
- 使用特殊的字符作为消息边界,比如把
"\r\n"
当作消息分隔符,当解码器处理到"\r\n"
时就可以将先前接收到的字节作为一个消息解码
在消息头设置 Body 的长度应该是最常使用的方式,因为它具有一定的灵活性的同时也比较好实现。而 HTTP 协议是同时基于 消息头中设置长度字段(Content-length
) + 使用特殊的字符作为消息边界(\r\n
) 实现的:
以 Netty 的实现为例,epoll 的 LT 模式(水平模式)下 Socket buffer 只要存在数据就会交由解码器进行处理,因此解码时可读取消息可能为半包状态,以下为相应的解码逻辑:
解码请求行:由于 HTTP 协议的 Request Line 和 Header 使用
ascii
码字符集,所以解码器将每个字节转为字符判断是否为\r\n
,若发现分隔符则将本行认为是请求行,接下去就会解析请求头若是此时读取的字节没有
\r\n
会怎么办呢?答案是会将已读取到字节放入char[]
,然后等待后续的字节到来拼接为完整的请求行解码请求头:在解码出请求行后当前解析状态就会进入到解析请求头的过程,具体和请求行类似。稍微有些特殊的是只有当读取到某一行只有
\r\n
时才代表请求头解析完毕。请求头中有一个非常重要的字段叫做Content-length
,它代表后续的请求头的长度以便后续解码请求头解码请求体:当请求头中解析出来的
Content-length > 0
就会进入到解码请求体的过程,因为Content-length
已经标记了字节长度,所以只需要读取对应字节数交由应用层处理即可
在 HTTP1.1 时代多路复用存在线头阻塞,因此虽然使用 TCP 长连接,但一个请求被响应前是无法发送下一个请求的,所以接收方接收到的同个请求的字节流自然是连续的,使用上述解码没有问题
其实这种方案应该可以适用于大部分多路复用场景,即使是并发发送,只要发送方控制好多个消息的发送正确性,确保同个消息的字节流总是连续的即可,像 Dubbo,RocketMQ 都是类似的方案
但 HTTP2.0 的支持并发发送的多路复用实现方案是允许同个消息的字节流非连续的,比如 A 消息还未发送完全就可以发送 B 消息了,这是怎么做到的呢?原因在于 HTTP2.0 将一个消息拆分为了多个二进制帧,每个二进制帧都会维护一个消息标记(Stream Identifier),发送方并发传输多个帧,接收方则根据消息标记进行重组。相较于之前的消息维度提高了并发度。下图是乱序发送的一个示例: