副标题#e#

在TCP编程中,我们使用协议(protocol)来解决粘包和拆包问题。本文将详解TCP粘包和半包产生的原因,以及如何通过协议来解决粘包、拆包问题。让你知其然,知其所以然。

TCP粘包、拆包与通信协议详解

1 TCP粘包、拆包图解

由于TCP传输协议面向流的,没有消息保护边界。一方发送的多个报文可能会被合并成一个大的报文进行传输,这就是粘包;也可能发送的一个报文,可能会被拆分成多个小报文,这就是拆包。

下图演示了粘包、拆包的过程,client分别发送了两个数据包D1和D2给server,server端一次读取到字节数是不确定的,因此可能可能存在以下几种情况:

TCP粘包、拆包与通信协议详解

关于这几种情况说明如下:

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连接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是SO_RCVBUF指定的值。之后在发送数据的时,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。

每次发送数据后,发送方将自己维护的对方的window size减小,表示对方的SO_RCVBUF可用空间变小。

当接收方处理开始处理SO_RCVBUF 中的数据时,会将数据从socket 在内核中的接受缓冲区读出,此时接收方的SO_RCVBUF可用空间变大,即window size变大,接受方会以ack消息的方式将自己最新的window size返回给发送方,此时发送方将自己的维护的接受的方的window size设置为ack消息返回的window size。

此外,发送方可以连续的给接受方发送消息,只要保证对方的SO_RCVBUF空间可以缓存数据即可,即window size>0。当接收方的SO_RCVBUF被填充满时,此时window size=0,发送方不能再继续发送数据,要等待接收方ack消息,以获得最新可用的window size。

2.2 MSS/MTU分片

MTU (Maxitum Transmission Unit,最大传输单元)是链路层对一次可以发送的最大数据的限制。MSS(Maxitum Segment Size,最大分段大小)是TCP报文中data部分的最大长度,是传输层对一次可以发送的最大数据的限制。

要了解MSS/MTU,首先需要回顾一下TCP/IP五层网络模型模型。

TCP粘包、拆包与通信协议详解

数据在传输过程中,每经过一层,都会加上一些额外的信息:

  • 应用层:只关心发送的数据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协议一次可以发送的实际应用数据的最大大小。

  1. MSS = MTU(1500) -IP Header(20 or 40)-TCP Header(20) 

由于IPV4和IPV6的长度不同,在IPV4中,以太网MSS可以达到1460byte;在IPV6中,以太网MSS可以达到1440byte。

#p#分页标题#e#

发送方发送数据时,当SO_SNDBUF中的数据量大于MSS时,操作系统会将数据进行拆分,使得每一部分都小于MSS,也形成了拆包,然后每一部分都加上TCP Header,构成多个完整的TCP报文进行发送,当然经过网络层和数据链路层的时候,还会分别加上相应的内容。

#p#副标题#e#

将消息区分为消息头和消息体,在消息头中,我们使用一个整形数字,例如一个int,来表示消息体的长度。而消息体实际实际要发送的二进制数据字节。以下是一个基本格式:

  1.  header    body 
  2. +--------+----------+ 
  3. | Length |  Content | 
  4. +--------+----------+ 

在变长协议中:

  • 发送方,发送数据之前,需要先获取需要发送内容的二进制字节大小,然后在需要发送的内容前面添加一个整数,表示消息体二进制字节的长度。
  • 接收方,在解析时,先读取内容长度Length,其值为实际消息体内容(Content)占用的字节数,之后必须读取到这么多字节的内容,才认为是一个完整的数据报文。

提示:Netty中提供了LengthFieldPrepender给实际内容Content进行编码添加Length字段,接受方使用LengthFieldBasedFrameDecoder解码。

3.4 序列化

序列化本质上已经不是为了解决粘包和拆包问题,而是为了在网络开发中可以更加的便捷。在变长协议中,我们看到可以在实际要发送的数据之前加上一个length字段,表示实际要发送的数据的长度。这实际上给我们了一个很好的思路,我们完全可以将一个对象转换成二进制字节,来进行通信,例如使用一个Request对象表示请求,使用一个Response对象表示响应。

序列化框架有很多种,我们在选择时,主要考虑序列化/反序列化的速度,序列化占用的体积,多语言支持等。下面列出了业界流行的序列化框架:

TCP粘包、拆包与通信协议详解

提示:xml、json也属于序列化框架的范畴,上面的表格中并没有列出。

一些网络通信的RPC框架通常会支持多种序列化方式,例如dubbo支持hessian、json、kyro、fst等。在支持多种序列化框架的情况下,在协议中通常需要有一个字段来表示序列化的类型,例如,我们可以将上述变长协议的格式改造为:

  1. +--------+-------------+------------+ 
  2. | Length |  serializer |   Content  | 
  3. +--------+-------------+------------+ 

这里使用1个字节表示Serializer的值,使用不同的值代表不同的框架。

发送方,选择好序列化框架后编码后,需要指定Serializer字段的值。

接收方,在解码时,根据Serializer的值选择对应的框架进行反序列化;

3.5 压缩

通常,为了节省网络开销,在网络通信时,可以考虑对数据进行压缩。常见的压缩算法有lz4、snappy、gzip等。在选择压缩算法时,我们主要考虑压缩比以及解压缩的效率。

我们可以在网络通信协议中,添加一个compress字段,表示采用的压缩算法:

  1. +--------+-----------+------------+------------+ 
  2. | Length | serializer|  compress  |   Content  | 
  3. +--------+-----------+------------+------------+ 

通常,我们没有必要使用一个字节,来表示采用的压缩算法,1个字节可以标识256种可能情况,而常用压缩算法也就那么几种,因此通常只需要使用2~3个bit来表示采用的压缩算法即可。

另外,由于数据量比较小的时候,压缩比并不会太高,没有必要对所有发送的数据都进行压缩,只有再超过一定大小的情况下,才考虑进行压缩。如rocketmq,producer在发送消息时,默认消息大小超过4k,才会进行压缩。因此,compress字段,应该有一个值,表示没有使用任何压缩算法,例如使用0。

3.6 查错校验码

一些通信协议传输的数据中,还包含了查错校验码。典型的算法如CRC32、Adler32等。java对这两种校验方式都提供了支持,java.util.zip.Adler32、java.util.zip.CRC32。

  1. +--------+-----------+------------+------------+---------+ 
  2. | Length | serializer|  compress  |   Content  |  CRC32  | 
  3. +--------+-----------+------------+------------+---------+ 

这里并不对CRC32、Adler32进行详细说明,主要是考虑,为什么需要进行校验?

有人说是因为考虑到安全,这个理由似乎并不充分,因为我们已经有了TLS层的加密,CRC32、Adler32的作用不应该是为了考虑安全。

一位同事的观点,我非常赞同:二进制数据在传输的过程中,可能因为电磁干扰,导致一个高电平变成低电平,或者低电平变成高电平。这种情况下,数据相当于受到了污染,此时通过CRC32等校验值,则可以验证数据的正确性。

另外,通常校验机制在通信协议中,是可选的配置的,并不需要强制开启,其虽然可以保证数据的正确,但是计算校验值也会带来一些额外的性能损失。如Mysql主从同步,虽然高版本默认开启CRC32校验,但是也可以通过配置禁用。

3.7 小结

本节通过一些基本的案例,讲解了在TCP编程中,如何通过协议来解决粘包、拆包问题。在实际开发中,通常我们的协议会更加复杂。例如,一些RPC框架,会在协议中添加唯一标识一个请求的ID,一些支持双向通信的RPC框架,如sofa-bolt,还会添加一个方向信息等。当然,所谓复杂,无非是在协议中添加了某个字段用于某个用途,只要弄清楚这些字段的含义,也就不复杂了。

dawei

【声明】:嘉兴站长网内容转载自互联网,其相关言论仅代表作者个人观点绝非权威,不代表本站立场。如您发现内容存在版权问题,请提交相关链接至邮箱:bqsm@foxmail.com,我们将及时予以处理。