标题:TCP 粘包?粘包警察是什么梗?
时间:2022/09/07
分享

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

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

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

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

粘包由来小故事:

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

什么是粘包/拆包?

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

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

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

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

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

为什么会有人说 TCP 粘包?

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

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

这里解释下 MSS​ 和 MTU:

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

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

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

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

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

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

拓展:Nagle 算法

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

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

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

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

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

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

拓展:UDP 为什么不分段?

先来回顾下 UDP 的特点:

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

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

再看 UDP 数据报格式:

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

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

小结下 UDP 为什么不分段?

当 DNS​ 查询超过 512字节 时,协议的 TC​ 标志出现删除标志,这时则使用 TCP​ 发送。通常传统的 UDP​ 报文一般不会大于512字节。

拆包/粘包解决方案

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

主流协议解决方案有:

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

方式

解码

编码

固定长度

​FixedLengthFrameDecoder​

简单

分隔符

​DelimiterBasedFrameDecoder​

简单

固定长度字段存内容长度

​LengthFieldBasedFrameDecoder​

​LengthFieldPrepender​

固定消息长度

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

优缺点:

特殊分隔符

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

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 在通信过程中采用的就是换行分隔符。

消息长度 + 消息内容

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

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

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

接收方在解析数据时:

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

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

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

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

本文仅代表文章作者的个人观点,请读者仅作参考,并自行核实相关内容。如有侵权请与我们联系,我们将及时删除。
推荐资讯
进入资讯频道查看更多新闻