TCP 粘包?粘包警察是什么梗?

19,091 阅读11分钟

一、前言

友好提示:粘包警察密切关注此文🐶。

粘包-2022-08-2611-42-21.png

本文围绕 TCP 协议展开,先来回顾下 TCP 协议的特点:

  1. TCP 是面向连接的传输层协议。
  2. 每一条 TCP 连接只有两个端点,每一条 TCP 连接只能是点对点的(一对一)。
  3. TCP 提供可靠的交付服务,保证传输的数据无差错、不丢失、不重复且有序。
  4. TCP 提供全双工通信,TCP 允许通信双方的应用进程在任何时候都能发送数据,为此 TCP 连接的两端都设有发送缓存接收缓存,用来临时存放双向通信的数据。
  5. TCP 是面向字节流的。(本文重点)

    虽然应用程序和 TCP 的交互是一次一个数据块(大小不等),但 TCP 把应用程序交下来的数据看成仅仅是一连串的无结构的字节流


粘包警察由来?粘包由来?

粘包警察 ,一词首次看到是在 v2。粘包警察认为 “粘包” 这词侮辱了 TCP,在 TCP 下讨论 “粘包” 是伪命题。相反,粘包学家认为 “粘包” 就是 TCP 问题。遂粘包警察频频现身『TCP粘包』帖子下,试图改正这偏见,提醒各位: TCP 是面向字节流的。

粘包由来小故事链接

据说以前有一群基础不扎实的程序员经常使用 VC 写各种 Windows 客户端程序,喜欢使用 UDP 编程(VCUDP 编程,代码简单,收发逻辑简单明)。 因为通讯应用的复杂性以及需求需要,他们尝试将多条数据放在一个 UDP 数据包里进行发送,遂碰到『粘包问题』。同时他们开始接触并使用 TCP,惯性思维套用之前 UDP 编程方式来使用 TCP,非常容易遇到所谓的 『粘包问题』。随着硬件升级,多物理核的 CPU 普及,多线程与并行编程开始流程,对程序员基本功提出更高的要求,这群人仍在并行程序使用串行思维进行编程,必定遇到『粘包问题』。 于是这群人把这个问题总结出来,称之为 『粘包问题』。

书籍《Netty 权威指南》中第四章标题就是 “TCP粘包/拆包问题的解决之道”,影响了一批批使用 JavaNetty 的编程者。


什么是粘包/拆包?

所谓粘包: 就是几个数据包粘在一起了,如果要处理得先拆包。

所谓拆包: 就是收到一批数据包碎片,要把这些碎片粘起来才能合成一个完整的数据包。

举个栗子:客户端发送数据给服务端,可能会出现以下五种情况:

粘包-2022-08-2611-42-23.png

  1. 栗子一: 客户端分别发送完整的数据包 A 和 B,服务端先接收了完整数据包 A,没有出现拆包/粘包问题。
  2. 栗子二: 客户端一次一口次发送 A 和 B 粘在一起的数据包,服务端接收到这个数据包,服务端需要解析出 A 和 B,出现粘包问题。
  3. 栗子三: 客户端发送 A|B-1数据包和B-2数据包,服务端先接收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包,出现粘包/拆包问题。
  4. 栗子四: 客户端发送 A-1数据包和B|A-2数据包,服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包,出现拆包问题。
  5. 栗子五: 数据包 A 较大,客户端分段发送数据包A,服务端需要多次才可以接收完数据包 A,出现拆包问题。

小结: 由于拆包/粘包问题的存在,如何识别一个完整的数据包就成了问题?难点在于如何定义一个数据包的边界。


为什么会有人说 TCP 粘包?

先来看下应用程序使用 TCP 套接字的流程: 对应 TCP/IP 4层协议

粘包-2022-08-2611-42-24.png

  1. 应用进程调用 write 时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。
  2. 本端 TCPMSS 大小的或更小的块把数据传递给 IP
  3. TCP分段加上 IP 首部构成 IP 数据包,并按照其目的 IP 地址查找路由表项以确定外出接口,然后把数据报传递给相应的数据链路。

这里解释下 MSSMTU

粘包-2022-08-2611-42-25.png

  • MTUMaxitum Transmission Unit) 是链路层一次最大传输数据的大小。一般来说大小为 1500 byte
  • MSSMaximum Segement Size) 是指 TCP 最大报文段长度,它是传输层一次发送最大数据的大小。

MTUMSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部。


『粘包学家』认为 TCP 粘包/拆包发生原因有三:

  1. 应用程序 write 写入的字节大小大于套接字发送缓冲区大小。
  2. MSS + TCP 首部 + IP 首部 > MTU,就要 TCP 分段
  3. 以太网帧的 payload 大于 MTU 就要进行 IP 分片

说白了,『粘包学家』认为我怎么给你的,你就该怎么还给我。

『粘包警察』认为这根本不是 TCP 的锅:

  1. TCP 是面向字节流: 根本没有包这个概念,谈何粘包/拆包。
  2. 『粘包/拆包』本质问题在于: 如何从二进制流中提取数据,如何定义数据的边界。

说白了,『粘包警察』认为怎么解析数据是你应用层的问题,TCP 只管传输并提供可靠的交付服务。


拓展:Nagle 算法

Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法,这使福特经营的最早的专用 TCP/IP 网络减少拥塞控制,从那以后这一方法得到了广泛应用。

优势:为了尽可能发送大块数据,避免网络中充斥着许多小数据块

如果每次需要发送的数据只有 1 字节,加上 20 个字节的 IP首部 和 20 个字节的 TCP首部,每次发送的数据包大小为 41 字节,但是只有 1 字节是有效信息,这就造成了非常大的浪费。

Nagle 算法的规则(可参考tcp_output.c 文件里 tcp_nagle_check 函数注释):

  1. 如果包长度达到 MSS,则允许发送;
  2. 如果该包含有 FIN,则允许发送;
  3. 设置了 TCP_NODELAY 选项,则允许发送;
  4. 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
  5. 上述条件都未满足,但发生了超时(一般为 200ms),则立即发送。

Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。

  • 可以通过 Linux 提供的 TCP_NODELAY 参数禁用 Nagle 算法。
  • Netty 中为了使数据传输延迟最小化,就默认禁用了 Nagle 算法。

Tips: 还有一个延迟 ACKDelay ACK),TCP 何时发送 ACK 有如下规定:

  1. 当有响应数据发送的时候,ACK 会随着数据一块发送。
  2. .如果没有响应数据,ACK 就会有一个延迟,以等待是否有响应数据一块发送,但是这个延迟一般在40ms~500ms之间,一般情况下在40ms左右
  3. 如果在等待发送 ACK 期间,第二个数据又到了,这时候就要立即发送 ACK

拓展:UDP 为什么不分段?

先来回顾下 UDP 的特点:

  1. UDP 无需建立连接。
  2. 无连接状态。
  3. 分组首部开销小。(首部 8字节)
  4. UDP 是面向报文的。(重点)

    发送方 UDP 对应用层交下来的报文,在添加首部后就向下交付给 IP 层,既不合并,也不拆分,而是保留这些报文的边界; 接收方 UDPIP 层交上来 UDP 用户数据报,在去除首部后就原封不动地交付给上层应用进程,一次交付一个完整的报文。因此报文不可分割,是 UDP 数据报处理的最小单位。

粘包-2022-08-2611-42-28.png

再看 UDP 数据报格式:

粘包-2022-08-2611-42-22.png

可知一个 UDP 数据报可携带最大用户数据长度为:2^16 - 8 = 65535 - 8 = 65527 (B)

小结下 UDP 为什么不分段?

  1. UDP 协议特性: 面向报文。16位UDP 长度。
    • 没有分段的能力:标记分段先后顺序的能力,即编号(ID)、尾部编号的标识 (Flag)
  2. UDP 应用特性: 常用于一次性传输比较少量数据的网络应用,如 DNSSNMP 等。

    DNS 查询超过 512字节 时,协议的 TC 标志出现删除标志,这时则使用 TCP 发送。

    通常传统的 UDP 报文一般不会大于512字节。



二、拆包/粘包解决方案

由上文可知我们需要一种定义来数据包的边界,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议

主流协议解决方案有:

  1. 消息长度固定
  2. 特定分隔符
  3. 消息长度 + 消息内容

Netty 对三种常用封帧方式的支持:

方式解码编码
固定长度FixedLengthFrameDecoder简单
分隔符DelimiterBasedFrameDecoder简单
固定长度字段存内容长度LengthFieldBasedFrameDecoderLengthFieldPrepender

(1)固定消息长度

Netty 中提供了类 FixedLengthFrameDecoder

  • 每个数据报文都需要一个固定的长度。
  • 当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。
  • 当发送方的数据小于固定长度时,则需要空位补齐。
# 举个栗子:假定固定消息长度是 3字节,当你收到如下报文:
+---+----+------+----+
| A | BC | DEFG | HI |
+---+----+------+----+

# 将它们解码成以下 3个固定长度的数据包:
+-----+-----+-----+
| ABC | DEF | GHI |
+-----+-----+-----+

项目地址:对应代码

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel ch) {
            ch.pipeline().addLast(new FixedLengthFrameDecoder(3));
            //... ...
        }
    });

通过 telnet 去访问:telnet localhost 8088

粘包-2022-08-2611-42-26.png

优缺点:

  1. 优点:消息定长法使用非常简单

  2. 缺点:无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。


(2)特殊分隔符

既然接收方无法区分消息的边界,那么可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。

DelimiterBasedFrameDecoder 自动完成以分隔符做结束标志的消息的解码:

# 举个栗子:以下报文根据特定分隔符 `\n` 按行解析
+--------------+
| ABC\nDEF\r\n |
+--------------+

# 解析后得到:
+-----+-----+
| ABC | DEF |
+-----+-----+

项目地址:代码

ServerBootstrap b = new ServerBootstrap();

b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<SocketChannel>() {

        @Override
        public void initChannel(SocketChannel ch) {
            // 以 & 为分隔符
            ByteBuf delimiter = Unpooled.copiedBuffer("&".getBytes());
            // 10 表示单条消息的最大长度,当达到该长度后扔没有查找到分隔符,就抛出异常
            // TooLongFrameException,防止由于异常码流失分隔符导致的内存溢出
            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(10, delimiter));
            // ... ...
        }
    });

通过 telnet 去访问:telnet localhost 8088

粘包-2022-08-2611-42-27.png

比较推荐的做法是:将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符。

特定分隔符法在消息协议足够简单的场景下比较高效,Redis 在通信过程中采用的就是换行分隔符。

  • Redis 2.0 以后的通信统一为 RESP 协议(Redis Serialization Protocol)
  • RESP 是一个二进制安全的文本协议,工作于 TCP 协议上。RESP 以行作为单位,客户端和服务器发送的命令或数据一律以 \r\nCRLF)作为换行符。

(3)消息长度 + 消息内容

消息长度 + 消息内容是项目开发中最常用的一种协议,如下展示了该协议的基本格式。

+--------|----------+
|消息头    |消息体    |
+--------|----------+
| Length | Content  |
+--------|----------+

消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。

接收方在解析数据时:

  1. 首先读取消息头的长度字段 Len
  2. 然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文

依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:

+-----|-------|-------|----|-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----|-------|-------|----|-----+

消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷。

当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段:

  • 消息版本
  • 算法类型
  • 等等