TCP 粘包和半包 介绍及解决

在网络传输中,粘包和半包应该是最常出现的问题,作为 JAVA 中最常使用的 NIO 网络框架 Netty,它又是如何解决的呢?今天就让我们来看看 。
定义
TCP 传输中,客户端发送数据,实际是把数据写入到了 TCP 的缓存中,粘包和半包也就会在此时产生 。
客户端给服务端发送了两条消息ABC和DEF,服务端这边的接收会有多少种情况呢?有可能是一次性收到了所有的消息ABCDEF,有可能是收到了三条消息AB、CD、EF 。
上面所说的一次性收到了所有的消息ABCDEF,类似于粘包 。如果客户端发送的包的大小比 TCP 的缓存容量小,并且 TCP 缓存可以存放多个包,那么客户端和服务端的一次通信就可能传递了多个包,这时候服务端从 TCP 缓存就可能一下读取了多个包,这种现象就叫粘包 。
上面说的后面那种收到了三条消息AB、CD、EF,类似于半包 。如果客户端发送的包的大小比 TCP 的缓存容量大,那么这个数据包就会被分成多个包,通过 Socket 多次发送到服务端,服务端第一次从接受缓存里面获取的数据,实际是整个包的一部分,这时候就产生了半包(半包不是说只收到了全包的一半,是说收到了全包的一部分) 。
产生原因
其实从上面的定义,我们就可以大概知道产生的原因了 。
粘包的主要原因:

  1. 发送方每次写入数据 < 套接字(Socket)缓冲区大小
  2. 接收方读取套接字(Socket)缓冲区数据不够及时
半包的主要原因:
  1. 发送方每次写入数据 > 套接字(Socket)缓冲区大小
  2. 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),因此必须拆包
其实我们可以换个角度看待问题:
  1. 从收发的角度看,便是一个发送可能被多次接收,多个发送可能被一次接收 。
  2. 从传输的角度看,便是一个发送可能占用多个传输包,多个发送可能共用一个传输包 。
根本原因,其实是
TCP 是流式协议,消息无边界 。
(PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因此不会有粘包和半包问题 。)
解决方法
就像上面说的,UDP 之所以不会产生粘包和半包问题,主要是因为消息有边界,因此,我们也可以采取类似的思路 。
改成短连接
将 TCP 连接改成短连接,一个请求一个短连接 。这样的话,建立连接到释放连接之间的消息即为传输的信息,消息也就产生了边界 。
【TCP 粘包和半包 介绍及解决】这样的方法就是十分简单,不需要在我们的应用中做过多修改 。但缺点也就很明显了,效率低下,TCP 连接和断开都会涉及三次握手以及四次握手,每个消息都会涉及这些过程,十分浪费性能 。
因此,并不推介这种方式 。
封装成帧
封装成帧(Framing),也就是原本发送消息的单位是缓冲大小,现在换成了帧,这样我们就可以自定义边界了 。一般有4种方式:
固定长度
这种方式下,消息边界也就是固定长度即可 。
优点就是实现很简单,缺点就是空间有极大的浪费,如果传递的消息中大部分都比较短,这样就会有很多空间是浪费的 。
因此,这种方式一般也是不推介的 。
分隔符
这种方式下,消息边界也就是分隔符本身 。
优点是空间不再浪费,实现也比较简单 。缺点是当内容本身出现分割符时需要转义,所以无论是发送还是接受,都需要进行整个内容的扫描 。
因此,这种方式效率也不是很高,但可以尝试使用 。
专门的 length 字段
这种方式,就有点类似 Http 请求中的 Content-Length,有一个专门的字段存储消息的长度 。作为服务端,接受消息时,先解析固定长度的字段(length字段)获取消息总长度,然后读取后续内容 。
优点是精确定位用户数据,内容也不用转义 。缺点是长度理论上有限制,需要提前限制可能的最大长度从而定义长度占用字节数 。
因此,十分推介用这种方式 。
其他方式
其他方式就各不相同了,比如 JSON 可以看成是使用{}是否成对 。这些优缺点就需要大家在各自的场景中进行衡量了 。
Netty 中的实现
Netty 支持上文所讲的封装成帧(Framing)中的前三种方式,简单介绍下:
TCP 粘包和半包 介绍及解决

文章插图
 
总结
今天主要介绍了粘包和半包问题、解决思路和 Netty 中的支持,我会在下一篇文章里重点讲述 Netty 中的具体实现,敬请期待 。


推荐阅读