TCP 协议的设计目标是 高效传输字节流,而非保证消息边界。以下机制是导致问题的核心原因:
每个数据包都必须加上 TCP 头 和 IP 头,如果要传递的数据很少,那么这个数据包中大部分都是头信息。如果将多个微小数据包合并成一个大数据包,那么网络利用率就会提高。于是,为了减少网络中 微小数据包 的数量,TCP 会将多个小数据包合并成一个大包发送,这就是 Nagle 算法。
接收方为提高吞吐量,会采取以下两个措施:
链路层对一次能够发送的最大数据有限制,这个限制称之为 MTU (Maximum Transmission Unit),不同的链路设备的 MTU 值也有所不同,例如:
MSS 是最大段长度 (Maximum Segment Size),它是 MTU 去除 TCP 头和 IP 头后剩余能够作为数据传输的字节数。IPv4 TCP 头占用 20 字节,IP 头占用 20 字节,因此以太网 MSS 的值为 1500 - 40 = 1460
字节。TCP 在传递大量数据时,会按照 MSS 大小将数据进行分割发送。
TCP 层无法感知消息边界,因此需要应用层通过来解决,解决方案如下:
思想:每条消息的长度固定,接收方按固定长度读取。
在 Netty 中的实现:将 FixedLengthFrameDecoder
作为 ChannelPipeline
的第一个处理器,如下所示:
// 添加一个 消息长度固定为 512 字节的解码器
ch.pipeline().addLast(new FixedLengthFrameDecoder(512));
缺点:消息长度不好把握,太短可能无法容纳比较长的消息,太长可能会导致浪费。
思想:在消息末尾添加特殊分隔符(如 \n
),接收方通过解析分隔符分割消息。
在 Netty 中的实现:将 LineBasedFrameDecoder
或 DelimiterBasedFrameDecoder
作为 ChannelPipeline
的第一个处理器,如下所示:
// 添加一个解码器,它以 \n 或 \r\n 为分隔符分割消息
// 但消息长度不能超过 1024 字节,如果超过,会抛出异常
ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
// 指定分隔符为 "EOM"
ByteBuf delimiter = Unpooled.copiedBuffer("EOM".getBytes());
// 添加一个解码器,它以 "EOM" 为分隔符分割消息
// 但消息长度不能超过 1024 字节,如果超过,会抛出异常
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, delimiter));
缺点:分隔符不好确定,如果内容本身包含了分隔符,那么就会解析错误。
思想:在消息前添加固定长度的字段,表示消息总长度。
在 Netty 中的实现:将 LengthFieldBasedFrameDecoder
作为 ChannelPipeline
的第一个处理器,如下所示:
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(
1024, // 最大帧(消息)长度
0, // 长度字段偏移量
4, // 长度字段长度
0, // 长度调整值
4 // 初始跳过字节数
));
LengthFieldBasedFrameDecoder
的重要参数:
maxFrameLength
:允许的最大帧长度。若接收到的消息长度超出这个值,解码器会抛出 TooLongFrameException
异常,避免内存溢出。lengthFieldOffset
:长度字段在消息中的偏移量,即从消息的哪个位置开始是长度字段。lengthFieldLength
:长度字段本身的字节数。lengthAdjustment
:长度字段的值与实际消息长度之间的调整值。比较复杂,一般不使用。initialBytesToStrip
:解码后需要跳过的初始字节数。以下举出几个例子帮助理解这几个参数(参考了 LengthFieldBasedFrameDecoder
的 JavaDoc,Magic
表示校验消息的魔数,Length
代表消息长度,Actual Content
代表消息内容):
参数配置:
// 长度字段的长度为 2,长度字段代表消息内容的长度
lengthFieldOffset = 0;
lengthFieldLength = 2;
initialBytesToStrip = 0;
解码过程:
解码前 (14 字节) 解码后 (14 字节)
+--------+----------------+ +--------+----------------+
| Length | Actual Content |---->| Length | Actual Content |
| 0x000C | "Hello, Netty" | | 0x000C | "Hello, Netty" |
+--------+----------------+ +--------+----------------+
参数配置:
lengthFieldOffset = 0;
lengthFieldLength = 2; // 长度字段的长度为 2
initialBytesToStrip = 2; // 解码后跳过长度字段
解码过程:
解码前 (14 字节) 解码后 (12 字节)
+--------+----------------+ +----------------+
| Length | Actual Content |---->| Actual Content |
| 0x000C | "Hello, Netty" | | "Hello, Netty" |
+--------+----------------+ +----------------+
参数配置:
// 魔数字段的长度为 2
lengthFieldOffset = 2; // 长度字段位于魔数字段的右边,需要偏移 2 字节
lengthFieldLength = 2; // 长度字段的长度为 2
initialBytesToStrip = 4; // 解码后跳过长度和魔数字段
解码过程:
解码前 (16 字节) 解码后 (12 字节)
+--------+--------+----------------+ +----------------+
| Magic | Length | Actual Content |------->| Actual Content |
| 0x0013 | 0x0010 | "Hello, Netty" | | "Hello, Netty" |
+--------+--------+----------------+ +----------------+
TCP 协议的设计目标是 高效传递字节流,所以没有考虑到消息的边界。由于 Nagle 算法、滑动窗口、MSS 限制 的因素,可能会导致 TCP 传输出现 粘包/拆包 的问题,这时就需要通过应用层来解决了。
应用层一般有三种解决方案:根据固定的消息长度分割消息、根据固定的分隔符分割消息 和 通过传输的消息长度分割消息。最常用的第三种方案,前两种方案有一定的缺陷。