在 TCP 编程中, 我们使用协议 (protocol) 来解决粘包和拆包问题. 本文将详解 TCP 粘包和半包产生的原因, 以及如何通过协议来解决粘包, 拆包问题. 让你知其然, 知其所以然.
1 TCP 粘包, 拆包图解
由于 TCP 传输协议面向流的, 没有消息保护边界. 一方发送的多个报文可能会被合并成一个大的报文进行传输, 这就是粘包; 也可能发送的一个报文, 可能会被拆分成多个小报文, 这就是拆包.
下图演示了粘包, 拆包的过程, client 分别发送了两个数据包 D1 和 D2 给 server,server 端一次读取到字节数是不确定的, 因此可能可能存在以下几种情况:
关于这几种情况说明如下:
server 端分两次读取到了两个独立的数据包, 分别是 D1 和 D2, 没有粘包和拆包
server 一次接受到了两个数据包, D1 和 D2 粘合在一起, 称之为 TCP 粘包
server 分两次读取到了数据包, 第一次读取到了完整的 D1 包和 D2 包的部分内容, 第二次读取到了 D2 包的剩余内容, 这称之为 TCP 拆包
Server 分两次读取到了数据包, 第一次读取到了 D1 包的部分内容 D1_1, 第二次读取到了 D1 包的剩余部分内容 D1_2 和完整的 D2 包.
由于发送方发送的数据, 可能会发生粘包, 拆包的情况. 这样, 对于接收端就难于分辨出来了, 因此必须提供科学的机制来解决粘包, 拆包问题, 这就是协议的作用.
在介绍协议之前, 我们先了解一下粘包, 拆包产生的原因.
2 粘包, 拆包产生的原因
粘包, 拆包问题的产生原因笔者归纳为以下 3 种:
socket 缓冲区与滑动窗口
MSS/MTU 限制
Nagle 算法
2.1 socket 缓冲区与滑动窗口
每个 TCP socket 在内核中都有一个发送缓冲区 (SO_SNDBUF ) 和一个接收缓冲区(SO_RCVBUF),TCP 的全双工的工作模式以及 TCP 的滑动窗口便是依赖于这两个独立的 buffer 的填充状态.
SO_SNDBUF:
进程发送的数据的时候假设调用了一个 send 方法, 最简单情况(也是一般情况), 将数据拷贝进入 socket 的内核发送缓冲区之中, 然后 send 便会在上层返回. 换句话说, send 返回之时, 数据不一定会发送到对端去(和 write 写文件有点类似),send 仅仅是把应用层 buffer 的数据拷贝进 socket 的内核发送 buffer 中.
SO_RCVBUF:
把接受到的数据缓存入内核, 应用进程一直没有调用 read 进行读取的话, 此数据会一直缓存在相应 socket 的接收缓冲区内. 再啰嗦一点, 不管进程是否读取 socket, 对端发来的数据都会经由内核接收并且缓存到 socket 的内核接收缓冲区之中. read 所做的工作, 就是把内核缓冲区中的数据拷贝到应用层用户的 buffer 里面, 仅此而已.
滑动窗口:
TCP 连接在三次握手的时候, 会将自己的窗口大小 (Windows size) 发送给对方, 其实就是 SO_RCVBUF 指定的值. 之后在发送数据的时, 发送方必须要先确认接收方的窗口没有被填充满, 如果没有填满, 则可以发送.
每次发送数据后, 发送方将自己维护的对方的 Windows size 减小, 表示对方的 SO_RCVBUF 可用空间变小.
当接收方处理开始处理 SO_RCVBUF 中的数据时, 会将数据从 socket 在内核中的接受缓冲区读出, 此时接收方的 SO_RCVBUF 可用空间变大, 即 Windows size 变大, 接受方会以 ack 消息的方式将自己最新的 Windows size 返回给发送方, 此时发送方将自己的维护的接受的方的 Windows size 设置为 ack 消息返回的 Windows size.
此外, 发送方可以连续的给接受方发送消息, 只要保证对方的 SO_RCVBUF 空间可以缓存数据即可, 即 Windows size>0. 当接收方的 SO_RCVBUF 被填充满时, 此时 Windows size=0, 发送方不能再继续发送数据, 要等待接收方 ack 消息, 以获得最新可用的 Windows size.
2.2 MSS/MTU 分片
MTU (Maxitum Transmission Unit, 最大传输单元)是链路层对一次可以发送的最大数据的限制. MSS(Maxitum Segment Size, 最大分段大小)是 TCP 报文中 data 部分的最大长度, 是传输层对一次可以发送的最大数据的限制.
要了解 MSS/MTU, 首先需要回顾一下 TCP/IP 五层网络模型模型.
数据在传输过程中, 每经过一层, 都会加上一些额外的信息:
应用层: 只关心发送的数据 DATA, 将数据写入 socket 在内核中的缓冲区 SO_SNDBUF 即返回, 操作系统会将 SO_SNDBUF 中的数据取出来进行发送.
传输层: 会在 DATA 前面加上 TCP Header(20 字节)
网络层: 会在 TCP 报文的基础上再添加一个 IP Header, 也就是将自己的网络地址加入到报文中. IPv4 中 IP Header 长度是 20 字节, IPV6 中 IP Header 长度是 40 字节.
链路层: 加上 Datalink Header 和 CRC. 会将 SMAC(Source Machine, 数据发送方的 Mac 地址),DMAC(Destination Machine, 数据接受方的 Mac 地址 )和 Type 域加入. SMAC+DMAC+Type+CRC 总长度为 18 字节.
物理层: 进行传输
在回顾这个基本内容之后, 再来看 MTU 和 MSS.MTU 是以太网传输数据方面的限制, 每个以太网帧最大不能超过 1518bytes. 刨去以太网帧的帧头(DMAC+SMAC+Type 域)14Bytes 和帧尾(CRC 校验)4Bytes, 那么剩下承载上层协议的地方也就是 Data 域最大就只能有 1500Bytes 这个值 我们就把它称之为 MTU.
MSS 是在 MTU 的基础上减去网络层的 IP Header 和传输层的 TCP Header 的部分, 这就是 TCP 协议一次可以发送的实际应用数据的最大大小.
MSS = MTU(1500) -IP Header(20 or 40)-TCP Header(20)
由于 IPV4 和 IPV6 的长度不同, 在 IPV4 中, 以太网 MSS 可以达到 1460byte; 在 IPV6 中, 以太网 MSS 可以达到 1440byte.
发送方发送数据时, 当 SO_SNDBUF 中的数据量大于 MSS 时, 操作系统会将数据进行拆分, 使得每一部分都小于 MSS, 也形成了拆包, 然后每一部分都加上 TCP Header, 构成多个完整的 TCP 报文进行发送, 当然经过网络层和数据链路层的时候, 还会分别加上相应的内容.
另外需要注意的是: 对于本地回环地址 (lookback) 不需要走以太网, 所以不受到以太网 MTU=1500 的限制. Linux 服务器上输入 ifconfig 命令, 可以查看不同网卡的 MTU 大小, 如下:
上图显示了 2 个网卡信息:
eth0 需要走以太网, 所以 MTU 是 1500;
lo 是本地回环, 不需要走以太网, 所以不受 1500 的限制.
2.3 Nagle 算法
TCP/IP 协议中, 无论发送多少数据, 总是要在数据 (DATA) 前面加上协议头(TCP Header+IP Header), 同时, 对方接收到数据, 也需要发送 ACK 表示确认.
即使从键盘输入的一个字符, 占用一个字节, 可能在传输上造成 41 字节的包, 其中包括 1 字节的有用信息和 40 字节的首部数据. 这种情况转变成了 4000% 的消耗, 这样的情况对于重负载的网络来是无法接受的. 称之为 "糊涂窗口综合征".
为了尽可能的利用网络带宽, TCP 总是希望尽可能的发送足够大的数据.(一个连接会设置 MSS 参数, 因此, TCP/IP 希望每次都能够以 MSS 尺寸的数据块来发送数据).Nagle 算法就是为了尽可能发送大块数据, 避免网络中充斥着许多小数据块.
Nagle 算法的基本定义是任意时刻, 最多只能有一个未被确认的小段. 所谓 "小段", 指的是小于 MSS 尺寸的数据块, 所谓 "未被确认", 是指一个数据块发送出去后, 没有收到对方发送的 ACK 确认该数据已收到.
Nagle 算法的规则:
如果 SO_SNDBUF 中的数据长度达到 MSS, 则允许发送;
如果该 SO_SNDBUF 中含有 FIN, 表示请求关闭连接, 则先将 SO_SNDBUF 中的剩余数据发送, 再关闭;
设置了 TCP_NODELAY=true 选项, 则允许发送. TCP_NODELAY 是取消 TCP 的确认延迟机制, 相当于禁用了 Negale 算法. 正常情况下, 当 Server 端收到数据之后, 它并不会马上向 client 端发送 ACK, 而是会将 ACK 的发送延迟一段时间(假一般是 40ms), 它希望在 t 时间内 server 端会向 client 端发送应答数据, 这样 ACK 就能够和应答数据一起发送, 就像是应答数据捎带着 ACK 过去. 当然, TCP 确认延迟 40ms 并不是一直不变的, TCP 连接的延迟确认时间一般初始化为最小值 40ms, 随后根据连接的重传超时时间(RTO), 上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整. 另外可以通过设置 TCP_QUICKACK 选项来取消确认延迟.
未设置 TCP_CORK 选项时, 若所有发出去的小数据包 (包长度小于 MSS) 均被确认, 则允许发送;
上述条件都未满足, 但发生了超时(一般为 200ms), 则立即发送.
3 通信协议
在了解了粘包, 拆包产生的原因之后, 现在来分析接收方如何对此进行区分. 道理很简单, 如果存在不完整的数据(拆包), 则需要继续等待数据, 直至可以构成一条完整的请求或者响应.
通过定义通信协议(protocol), 可以解决粘包, 拆包问题. 协议的作用就定义传输数据的格式. 这样在接受到的数据的时候:
如果粘包了, 就可以根据这个格式来区分不同的包
如果拆包了, 就等待数据可以构成一个完整的消息来处理.
3.1 定长协议
定长协议: 顾名思义, 就是指定一个报文的必须具有固定的长度. 例如, 我们规定每 3 个字节, 表示一个有效报文, 如果我们分 4 次总共发送以下 9 个字节:
- +---+----+------+----+
- | A | BC | DEFG | HI |
- +---+----+------+----+
那么根据协议, 我们可以判断出来, 这里包含了 3 个有效的请求报文, 如下:
- +-----+-----+-----+
- | ABC | DEF | GHI |
- +-----+-----+-----+
在定长协议中:
发送方, 必须保证发送报文长度是固定的. 如果报文字节长度不能满足条件, 如规定长度是 1024 字节, 但是实际需要发送的内容只有 900 个字节, 那么不足的部分可以补充 0. 因此定长协议可能会浪费带宽.
接收方, 每读取到固定长度的内容时, 则认为读取到了一个完整的报文.
提示: Netty 中提供了 FixedLengthFrameDecoder, 支持把固定的长度的字节数当做一个完整的消息进行解码
3.2 特殊字符分隔符协议
在包尾部增加回车或者空格符等特殊字符进行分割 . 例如, 按行解析, 遇到字符 \ n,\r\n 的时候, 就认为是一个完整的数据包. 对于以下二进制字节流:
- +--------------+
- | ABC\nDEF\r\n |
- +--------------+
那么根据协议, 我们可以判断出来, 这里包含了 2 个有效的请求报文
- +-----+-----+
- | ABC | DEF |
- +-----+-----+
在特殊字符分隔符协议中:
发送方, 需要在发送一个报文时, 需要在报文尾部添加特殊分割符号;
接收方, 在接收到报文时, 需要对特殊分隔符进行检测, 直到检测到一个完整的报文时, 才能进行处理.
在使用特殊字符分隔符协议的时候, 需要注意的是, 我们选择的特殊字符, 一定不能在消息体中出现, 否则可能会出现错误的拆包. 例如, 发送方希望把 "12\r\n34", 当成一个完整的报文, 如果是按行拆分, 那么就会错误的拆分为 2 个报文. 一种解决策略是, 发送方对需要发送的内容预先进行 base64 编码, 由于 base64 编码只包含 64 个字符: 0-9,a-z,A-Z,+,/, 我们可以选择这 64 个字符之外的特殊字符作为分隔符.
提示: netty 中提供了 DelimiterBasedFrameDecoder 根据特殊字符进行解码. 事实上, 我们熟悉的的缓存服务器 Redis, 也是通过换行符来区分一个完整的报文.
3.3 变长协议
将消息区分为消息头和消息体, 在消息头中, 我们使用一个整形数字, 例如一个 int, 来表示消息体的长度. 而消息体实际实际要发送的二进制数据字节. 以下是一个基本格式:
- header body
- +--------+----------+
- | Length | Content |
- +--------+----------+
在变长协议中:
发送方, 发送数据之前, 需要先获取需要发送内容的二进制字节大小, 然后在需要发送的内容前面添加一个整数, 表示消息体二进制字节的长度.
接收方, 在解析时, 先读取内容长度 Length, 其值为实际消息体内容 (Content) 占用的字节数, 之后必须读取到这么多字节的内容, 才认为是一个完整的数据报文.
提示: Netty 中提供了 LengthFieldPrepender 给实际内容 Content 进行编码添加 Length 字段, 接受方使用 LengthFieldBasedFrameDecoder 解码.
3.4 序列化
序列化本质上已经不是为了解决粘包和拆包问题, 而是为了在网络开发中可以更加的便捷. 在变长协议中, 我们看到可以在实际要发送的数据之前加上一个 length 字段, 表示实际要发送的数据的长度. 这实际上给我们了一个很好的思路, 我们完全可以将一个对象转换成二进制字节, 来进行通信, 例如使用一个 Request 对象表示请求, 使用一个 Response 对象表示响应.
序列化框架有很多种, 我们在选择时, 主要考虑序列化 / 反序列化的速度, 序列化占用的体积, 多语言支持等. 下面列出了业界流行的序列化框架:
提示: xml,JSON 也属于序列化框架的范畴, 上面的表格中并没有列出.
一些网络通信的 RPC 框架通常会支持多种序列化方式, 例如 dubbo 支持 hessian,JSON,kyro,fst 等. 在支持多种序列化框架的情况下, 在协议中通常需要有一个字段来表示序列化的类型, 例如, 我们可以将上述变长协议的格式改造为:
- +--------+-------------+------------+
- | Length | serializer | Content |
- +--------+-------------+------------+
这里使用 1 个字节表示 Serializer 的值, 使用不同的值代表不同的框架.
发送方, 选择好序列化框架后编码后, 需要指定 Serializer 字段的值.
接收方, 在解码时, 根据 Serializer 的值选择对应的框架进行反序列化;
3.5 压缩
通常, 为了节省网络开销, 在网络通信时, 可以考虑对数据进行压缩. 常见的压缩算法有 lz4,snappy,gzip 等. 在选择压缩算法时, 我们主要考虑压缩比以及解压缩的效率.
我们可以在网络通信协议中, 添加一个 compress 字段, 表示采用的压缩算法:
- +--------+-----------+------------+------------+
- | Length | serializer| compress | Content |
- +--------+-----------+------------+------------+
通常, 我们没有必要使用一个字节, 来表示采用的压缩算法, 1 个字节可以标识 256 种可能情况, 而常用压缩算法也就那么几种, 因此通常只需要使用 2~3 个 bit 来表示采用的压缩算法即可.
另外, 由于数据量比较小的时候, 压缩比并不会太高, 没有必要对所有发送的数据都进行压缩, 只有再超过一定大小的情况下, 才考虑进行压缩. 如 rocketmq,producer 在发送消息时, 默认消息大小超过 4k, 才会进行压缩. 因此, compress 字段, 应该有一个值, 表示没有使用任何压缩算法, 例如使用 0.
3.6 查错校验码
一些通信协议传输的数据中, 还包含了查错校验码. 典型的算法如 CRC32,Adler32 等. java 对这两种校验方式都提供了支持, java.util.zip.Adler32,java.util.zip.CRC32.
- +--------+-----------+------------+------------+---------+
- | Length | serializer| compress | Content | CRC32 |
- +--------+-----------+------------+------------+---------+
这里并不对 CRC32,Adler32 进行详细说明, 主要是考虑, 为什么需要进行校验?
有人说是因为考虑到安全, 这个理由似乎并不充分, 因为我们已经有了 TLS 层的加密, CRC32,Adler32 的作用不应该是为了考虑安全.
一位同事的观点, 我非常赞同: 二进制数据在传输的过程中, 可能因为电磁干扰, 导致一个高电平变成低电平, 或者低电平变成高电平. 这种情况下, 数据相当于受到了污染, 此时通过 CRC32 等校验值, 则可以验证数据的正确性.
另外, 通常校验机制在通信协议中, 是可选的配置的, 并不需要强制开启, 其虽然可以保证数据的正确, 但是计算校验值也会带来一些额外的性能损失. 如 MySQL 主从同步, 虽然高版本默认开启 CRC32 校验, 但是也可以通过配置禁用.
3.7 小结
本节通过一些基本的案例, 讲解了在 TCP 编程中, 如何通过协议来解决粘包, 拆包问题. 在实际开发中, 通常我们的协议会更加复杂. 例如, 一些 RPC 框架, 会在协议中添加唯一标识一个请求的 ID, 一些支持双向通信的 RPC 框架, 如 sofa-bolt, 还会添加一个方向信息等. 当然, 所谓复杂, 无非是在协议中添加了某个字段用于某个用途, 只要弄清楚这些字段的含义, 也就不复杂了.
来源: http://network.51cto.com/art/201910/604438.htm