本文围绕 TCP 协议展开,先来回顾下 TCP 协议的特点:
虽然应用程序和 TCP 的交互是一次一个数据块(大小不等),但 TCP 把应用程序交下来的数据看成仅仅是一连串的无结构的字节流。
粘包警察 ,一词首次看到是在 v2。粘包警察认为 “粘包” 这词侮辱了 TCP,在 TCP 下讨论 “粘包” 是伪命题。相反,粘包学家认为 “粘包” 就是 TCP 问题。遂粘包警察频频现身『TCP粘包』帖子下,试图改正这偏见,提醒各位: TCP 是面向字节流的。
粘包由来小故事:
据说以前有一群基础不扎实的程序员经常使用 VC 写各种 Windows 客户端程序,喜欢使用 UDP 编程(VC 的 UDP 编程,代码简单,收发逻辑简单明)。 因为通讯应用的复杂性以及需求需要,他们尝试将多条数据放在一个 UDP 数据包里进行发送,遂碰到『粘包问题』。同时他们开始接触并使用 TCP,惯性思维套用之前 UDP 编程方式来使用 TCP,非常容易遇到所谓的 『粘包问题』。随着硬件升级,多物理核的 CPU 普及,多线程与并行编程开始流程,对程序员基本功提出更高的要求,这群人仍在并行程序使用串行思维进行编程,必定遇到『粘包问题』。 于是这群人把这个问题总结出来,称之为 『粘包问题』。
所谓粘包: 就是几个数据包粘在一起了,如果要处理得先拆包。
所谓拆包: 就是收到一批数据包碎片,要把这些碎片粘起来才能合成一个完整的数据包。
举个栗子:客户端发送数据给服务端,可能会出现以下五种情况:
小结: 由于拆包/粘包问题的存在,如何识别一个完整的数据包就成了问题?难点在于如何定义一个数据包的边界。
先来看下应用程序使用 TCP 套接字的流程: 对应 TCP/IP 4层协议:
这里解释下 MSS 和 MTU:
MTU 和 MSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部。
『粘包学家』认为 TCP 粘包/拆包发生原因有三:
说白了,『粘包学家』认为我怎么给你的,你就该怎么还给我。
『粘包警察』认为这根本不是 TCP 的锅:
说白了,『粘包警察』认为怎么解析数据是你应用层的问题,TCP 只管传输并提供可靠的交付服务。
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 对应用层交下来的报文,在添加首部后就向下交付给 IP 层,既不合并,也不拆分,而是保留这些报文的边界; 接收方 UDP 对 IP 层交上来 UDP 用户数据报,在去除首部后就原封不动地交付给上层应用进程,一次交付一个完整的报文。因此报文不可分割,是 UDP 数据报处理的最小单位。
再看 UDP 数据报格式:
可知一个 UDP 数据报可携带最大用户数据长度为:2^16 - 8 = 65535 - 8 = 65527 (B)
小结下 UDP 为什么不分段?
当 DNS 查询超过 512字节 时,协议的 TC 标志出现删除标志,这时则使用 TCP 发送。通常传统的 UDP 报文一般不会大于512字节。
由上文可知我们需要一种定义来数据包的边界,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议。
主流协议解决方案有:
Netty 对三种常用封帧方式的支持:
方式 |
解码 |
编码 |
固定长度 |
|
简单 |
分隔符 |
|
简单 |
固定长度字段存内容长度 |
|
|
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
优缺点:
既然接收方无法区分消息的边界,那么可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。
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
比较推荐的做法是:将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符。
特定分隔符法在消息协议足够简单的场景下比较高效,Redis 在通信过程中采用的就是换行分隔符。
消息长度 + 消息内容是项目开发中最常用的一种协议,如下展示了该协议的基本格式。
+--------|----------+
|消息头 |消息体 |
+--------|----------+
| Length | Content |
+--------|----------+
消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。
接收方在解析数据时:
依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:
+-----|-------|-------|----|-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----|-------|-------|----|-----+
消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷。
当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段: