蛮荆

为什么 TCP 粘包 是正常现象

2018-12-16

TCP 粘包现象

TCP 粘包是指发送方发送了若干数据包,但是到达接收方之后,数据包的内容全部 “粘连” 在了一起,每个数据包的数据结尾直接和下个数据包的数据连在了一起,无法正常区分。

下面的图片展示了一个 TCP 粘包现象,正常的 3 个数据包因为粘包,被连在了一起,看起来像是一个数据包,结果就很感人了 …

TCP 粘包/拆包 原因

TCP 粘包 并不是因为协议本身有 “问题”,而是一种 “正常现象”, 因为 TCP 是面向字节流的协议,数据之间没有所谓 “边界”,所以数据粘包之后的 “拆包” 工作应该 (也必须) 由应用层完成。

TCP 采用 “异步” 方式发送应用数据,也就是说,当应用程序中调用 Send(packet) 发送数据时,虽然 Send 函数会立即返回,但是数据并不一定已经到达通信对方了。应用数据具体什么时候发,由应用层下面的传输层 TCP 说了算,TCP 使用了 3 个主要机制 (确认与重传、滑动窗口流量控制、拥塞控制) 来实现可靠性传输,保证应用数据的可靠传输和 应用层数据发送顺序和到达顺序一致的语义保证

下面来展开说一下可能导致 粘包/拆包 问题的原因。

1. 面向字节流的工作特性

TCP 作为传输层,并不了解 (也不关心) 应用层数据的上下文含义,它只会根据通信双方约定的 MSS 对发送缓冲区的数据包进行拆分。

所以在应用层的视角来看,一个完整的数据包 (可能是一段聊天文字、一个图片、一个视频) 可能会经历不同的发送过程:

  • 如果 MSS 较小,一个完整的数据包会被 TCP 拆分成多个更小的 数据包进行发送,产生拆包现象
  • 如果 MSS 较大,多个完整的数据包会被 TCP 合并为一个更大的 数据包进行发送,产生粘包现象

2. 缓冲机制

TCP 在发送数据时,会将数据放入发送缓冲区;在接收数据时,会将数据放入接收缓冲区。TCP 会尽可能地将发送缓冲区中的数据打包成一个或多个数据包发送出去,而接收方在读取数据时,也会尽量将接收缓冲区中的数据全部读取出来。这种机制可能导致发送方一次发送的多个数据包被接收方一次性读取,从而引发粘包问题。

  • TCP 发送方会将数据放入 发送缓冲区
  • TCP 接收方会将数据放入 接收缓冲区

为了尽可能提升发送数据和接受处理数据的性能,作为发送方来讲:

  • 如果要发送的数据小于发送缓冲区大小,TCP 会将多次要发送的数据,全部写入发送缓冲区,然后一起发送,产生粘包现象
  • 如果要发送的数据大于发送缓冲区大小,TCP 会将要发送的数据进行切分,满足写入发送缓冲区的条件,然后发送,产生拆包现象

作为接收方来讲:

  • 如果应用层没有及时处理接收缓冲区的数据,产生粘包现象

3. Nagle 算法

Nagle 算法原理: 发送方已经发送数据还未被接收方确认之前,期间如果又有小数据生成,先把小数据收集起来,凑满一个 MSS (最大报文段大小) 或者收到接收方 Ack 后再一起发送。通过将小数据包积累成较大的数据包后再发送,从而提高网络效率。

很显然,根据 Nagle 算法的工作机制,在频繁发送小的数据包时 (例如 Telnet, SSH 终端),会产生粘包现象。

下面是在 Go 语言中关闭 TCP Nagle 算法的示例代码。

package main

func main() {
    conn, _ := net.Dial("tcp", "dbwu.tech:443")

    fd := conn.(*net.TCPConn).File()
    // 关闭 Nagle 算法
    syscall.SetsockoptInt(int(fd.Fd()), syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1)
}

❓ 单纯关闭 Nagle 算法并不能解决粘包问题,读者思考一下为什么?

💡 综上所述,因为应用层和传输层工作方式上的差异,所以就导致了所以的 “TCP 粘包/拆包” 问题。


解决方案

既然拆包必须由应用层来完成,那么按照数据处理的思路,只要应用程序对 TCP 的字节流数据能够区分单个消息的标志和边界,那么应用程序就可以将粘包后的数据分割为正常的单个消息,粘包问题自然迎刃而解。

目前业界主要采用的解决方案:

  1. 设置消息固定长度: 发送方可以将每个消息设置为固定的长度 (对于长度不够的消息可以使用 0 进行填充),接收方从缓冲区读取数据时,每次都读取固定的长度,这样很自然就把单个消息拆分出来

  1. 设置消息分隔符: 发送方在将单个消息末尾追加分隔符,用来分区单个消息,接收方从缓冲区读取数据后,根据分隔符将读取到的数据切割成一个个单条消息

当然,分隔符很容易出现在要发送的应用数据中,这样就产生了冲突,无法进行正常拆包,所以实际项目中很少使用这种方式。

  1. 设置消息头部格式: 将消息分为消息头部和消息体,消息头中包含表示消息总长度(或者消息体长度,例如 HTTP 中的 Content-Length)字段,接收方首先读取消息头部,然后根据消息长度字段 读取具体的消息体内容,这也是最常用的方式

  1. 特定消息格式: 例如将单个消息固定为 JSON 格式,接收方从缓冲区读取数据后,根据读取到的数据能否被解析成合法的 JSON 来判断消息是否结束,当然,实际项目中很少使用这种方式

💡 注意,以上提到的解决方案通常由网络编程框架 (例如 Java 的 Netty, Golang 的 gnet) 来实现,而不是由应用程序中的业务代码来实现。

不同应用场景的下的应对方案

完全禁止 粘包/拆包 的场景

  1. 低延迟要求: 在线游戏、股票交易
  2. 小数据频繁传输,例如 Telnet, SSH 终端

可以忽视 粘包/拆包 的场景

  • 小数据传输,且两次传输之间的时间间隔很大,例如 5 秒/次 的心跳
  • 使用已经处理了粘包问题的应用层协议,例如 HTTP 使用响应头中的 Content-Length 作为消息分隔符
  • 传输数据只需要保证顺序即可,无需区分边界,例如大文件传输,每个数据包都是文件的一小部分而已,因为 TCP 保证了传输顺序性,所以接收方只需要读取数据,然后追加到已有数据的后面即可

UDP 有粘包/拆包 问题吗?

UDP 是无连接的协议,每个数据报都是独立传输的,接收方收到的数据报和发送方发送的数据报是一一对应的 (但是报文到达时间上可能会出现乱序),不需要建立连接,也不需要维护连接的状态,换句话说,选择 UDP 协议时,应用层必须完成数据的拆包工作,所以自然也就不存在 粘包/拆包 问题了。

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。