TCP是什么
首先看一下OSI七层模型:
然后数据从应用层发下来,会在每一层都加上头部信息进行封装,然后再发送到数据接收端,这个基本的流程中每个数据都会经过数据的封装和解封的过程,流程如下图所示:
在OSI七层模型中,每一层的作用和对应的协议如下图所示:
说回TCP,简单说TCP(Transmission Control Protocol)即传输控制协议,是一种面向连接的、可靠的、基于Ip的传输层协议。
TCP协议头部格式
要学习TCP协议,首先得知道TCP协议头部的格式,我在网上找了一张觉得画得比较好的TCP协议头部格式的图片:
这张图把TCP协议头部格式的每部分都描述得比较清楚:
- Source Port与Destination Port表示源端口与目标端口,各占据2个字节
- Sequence Number表示顺序号,占4个字节,每一个字节都有一个序号,连接建立时发送方将初始序号填写到第一个发送的TCP段序号中
- Acknowledgment Number表示应答号,占4个字节,是期望收到对方下次发送的数据的第一个字节的序号,也就是期望收到的下一个报文段的首部中的序号
- Offset表示数据偏移量,占4位,表示数据开始的地方离TCP段的起始处有多远,实际上就是TCP段首部的长度
- Reserved表示保留位,占4位,全为0,为了将来定义新的用途保留
- C表示CWR,占1位,拥塞窗口减少标识,发送方设置,用于表明它收到了ECE标识的TCP包,发送端通过降低发送窗口的大小来降低速率
- E表示ECN,占1位,用于TCP3次握手时表示一个TCP端是具备ECN功能的
- U表示URG,占1位,该标志位表示紧急标识有效
- A表示ACK,占1位,表示Acknowledgment Number字段有效,这是一个确认的TCP包,0表示不是确认包
- P表示PSH,占1位,该标志位设置时一般表示发送端缓存中已经没有待发送的数据,接收端不将该数据进行队列处理
- R表示RST,占1位,用于复位相应的TCP链接
- S表示SYN,占1位,该标志仅在三次握手建立TCP连接时有效
- F表示FIN,占1位,带有该标志位的数据包用来结束一个TCP会话,但对应端口仍处于开放状态,准备接收后续数据
- Window表示窗口,占2个字节,表示报文段发送方期望收到的字节数,换句话说用于表示接收端还有多少空间剩余,用于控制TCP流量
- Checksum表示校验和,占2个字节,发送端基于数据内容计算一个数值,接收端要与发送端数值结果完全一样,才能证明数据的有效性,接收端校验失败会直接丢掉这个数据包
- Urgent Pointer表示紧急指针,占2个字节,指向后面优先数据的字节,只有在URG标识设置了才有效
- TCP Options表示TCP选项,长度不定,但必须是32bits的整数倍,常见的选项包括MSS、SACK、Timestamp等
从图上我们可以看到,TCP头部的固定大小为20个字节,不过由于有可选字段,实际上TCP头部的大小有可能超过20字节。
TCP三次握手
TCP三次握手是TCP一个比较重点的内容,来学习一下。
TCP三次握手其实就是TCP连接建立的过程,三次握手的目的是同步连接双方的序列号和确认号并交换TCP窗口大小信息。下面是TCP三次握手的流程图:
画得很清晰,可惜不是我画的。整个流程为:
- 客户端主动打开,发送连接请求报文段,将SYN标识位置为1,Sequence Number置为x(TCP规定SYN=1时不能携带数据,x为随机产生的一个值),然后进入SYN_SEND状态
- 服务器收到SYN报文段进行确认,将SYN标识位置为1,ACK置为1,Sequence Number置为y,Acknowledgment Number置为x+1,然后进入SYN_RECV状态,这个状态被称为半连接状态
- 客户端再进行一次确认,将ACK置为1(此时不用SYN),Sequence Number置为x+1,Acknowledgment Number置为y+1发向服务器,最后客户端与服务器都进入ESTABLISHED状态
为什么在第3步中客户端还要再进行一次确认呢?这主要是为了防止已经失效的连接请求报文段突然又传回到服务端而产生错误的场景:
所谓"已失效的连接请求报文段"是这样产生的。正常来说,客户端发出连接请求,但因为连接请求报文丢失而未收到确认。于是客户端再次发出一次连接请求,后来收到了确认,建立了连接。数据传输完毕后,释放了连接,客户端一共发送了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,没有"已失效的连接请求报文段"。 现在假定一种异常情况,即客户端发出的第一个连接请求报文段并没有丢失,只是在某些网络节点长时间滞留了,以至于延误到连接释放以后的某个时间点才到达服务端。本来这个连接请求已经失效了,但是服务端收到此失效的连接请求报文段后,就误认为这是客户端又发出了一次新的连接请求。于是服务端又向客户端发出请求报文段,同意建立连接。假定不采用三次握手,那么只要服务端发出确认,连接就建立了。 由于现在客户端并没有发出连接建立的请求,因此不会理会服务端的确认,也不会向服务端发送数据,但是服务端却以为新的传输连接已经建立了,并一直等待客户端发来数据,这样服务端的许多资源就这样白白浪费了。 采用三次握手的办法可以防止上述现象的发生。比如在上述的场景下,客户端不向服务端的发出确认请求,服务端由于收不到确认,就知道客户端并没有要求建立连接。
TCP四次握手
TCP三次握手是TCP连接建立的过程,TCP四次握手则是TCP连接释放的过程。下面是TCP四次握手的流程图:
当客户端没有数据再需要发送给服务端时,就需要释放客户端的连接,这整个过程为:
- 客户端发送一个报文给服务端(没有数据),其中FIN设置为1,Sequence Number置为u,客户端进入FIN_WAIT_1状态
- 服务端收到来自客户端的请求,发送一个ACK给客户端,Acknowledge置为u+1,同时发送Sequence Number为v,服务端年进入CLOSE_WAIT状态
- 服务端发送一个FIN给客户端,ACK置为1,Sequence置为w,Acknowledge置为u+1,用来关闭服务端到客户端的数据传送,服务端进入LAST_ACK状态
- 客户端收到FIN后,进入TIME_WAIT状态,接着发送一个ACK给服务端,Acknowledge置为w+1,Sequence Number置为u+1,最后客户端和服务端都进入CLOSED状态
这里的一个问题是,为什么TCP连接的建立只需要三次握手而TCP连接的释放需要四次握手呢:
因为服务端在LISTEN状态下,收到建立请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。而连接关闭时,当收到对方的FIN报文时,仅仅表示对方没有需要发送的数据了,但是还能接收数据,己方未必数据已经全部发送给对方了,所以己方可以立即关闭,也可以将应该发送的数据全部发送完毕后再发送FIN报文给客户端来表示同意现在关闭连接。 从这个角度而言,服务端的ACK和FIN一般都会分开发送。
使用Wireshark抓包验证TCP三次握手过程
为了加深对TCP三次握手的理解,抓包看一下TCP三次握手的过程。我这里访问的是我们公司自己的网站,不打广告,访问的具体什么页面、哪个ip就不透露了。
抓包下来的内容为:
这里多说一句,由于wireshark抓包针对的是网卡,因此只要某张网卡上有网络访问,就会有数据包,这会导致Wireshark的抓包结果里面会有大量数据包,而大多数都不是想要的,这种情况可以使用Wireshark的过滤规则。我这里由于知道目标ip,因此使用的是”ip.src == xxx.xxx.xxx.xxx or ip.dst == xxx.xxx.xxx.xxx“这条规则只过滤特定的ip。
从抓包结果看来,整个过程符合TCP三次握手的预期:
- 客户端发送SYN给服务端
- 服务端返回SYN+ACK给客户端
- 客户端确认,返回ACK给服务端
至于Sequence Number和Acknowledge Number就不看了,但是注意,前面说了Sequence Number是随机产生的一个值,但是这里确是0,不光这里是0,抓其他的任何包这个值都是0。但其实这里并不是真的0,而是Wireshark为了显示更好阅读,使用了relative sequence number相对序号,Sequence Number具体值我们也是可以看到的:
第一个红框就是上面说的relative sequence number,第二个红框就是Sequence Number的真实值0xc978aa7e,转换为十进制为3380128382,就是随机产生的Sequence Number。
顺便能看到,下一个数据包就是HTTP的数据包,因为TCP三次握手已完成,连接建立,正式传输应用层数据,传输的HTTP内容大小为704字节。
TCP的backlog
在学习TCP的时候发现的一个比较重要的知识点。
在TCP连接建立的过程中有如下的流程和队列:
如图所示,这里面有两个队列,分别为syns queue(半连接队列)与accept queue(全连接队列)。整个流程总结用文字如下:
- 服务端绑定某个端口并监听
- 客户端发送SYN给服务端发起第一次握手,此时服务端将此请求信息放在半连接队列中并回复SYN+ACK给客户端
- 客户端收到SYN+ACK,发起应答,回复一个ACK给服务端,假设此时全连接队列未满,那么从半连接队列中拿出此请求信息放入全连接队列中。如果全连接队列满了,那么客户端继续向服务端发送ACK,服务端的处理方式和系统参数tcp_abort_on_overflow有关,Linux环境下可以通过执行”cat /proc/sys/net/ipv4/tcp_abort_on_overflow“来查看此参数:
- 0表示字节丢弃该ACK
- 1表示发送一个RST给客户端,直接废掉这个握手过程与连接
- 服务端accept处理此请求,从全连接队列中将此请求信息拿出
backlog的定义是已连接但未进行accept处理的socket队列大小,如果这个队列满了,将会发送一个ECONNREFUSED错误信息给到客户端,即 linux 头文件 /usr/include/asm-generic/errno.h中定义的“Connection refused”。
Java支持原生的Socket,我们可以写一段代码来验证一下。首先是一个普通的客户端Socket,模拟向本地的8888端口发起连接:
1 public class ClientSocketClass { 2 3 private static Socket clients = new Socket[30]; 4 5 public static void main(String[] args) throws Exception { 6 for (int i = 0; i < 10; i++) { 7 clients[i] = new Socket("127.0.0.1", 8888); 8 System.out.println("Client:" + i); 9 } 10 } 11 12 }
接着是服务端Socket,监听8888端口,ServerSocket构造函数的第二个参数就是backlog的大小,如果backlog小于1或者不传会给一个默认值50,代码很简单:
1 public class ServerSocketClass { 2 3 public static void main(String[] args) throws Exception { 4 ServerSocket server = new ServerSocket(8888, 5); 5 6 while (true) { 7 // server.accept; 8 } 9 } 10 11 }
先把注释关闭,运行ServerSocketClass,先发起监听,再运行ClientSocketClass,运行结果为:
1 Client:0 2 Client:1 3 Client:2 4 Client:3 5 Client:4 6 Exception in thread "main" java.net.ConnectException: Connection refused: connect 7 at java.net.DualStackPlainSocketImpl.connect0(Native Method) 8 at java.net.DualStackPlainSocketImpl.socketConnect(DualStackPlainSocketImpl.java:79) 9 at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:339) 10 at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:200) 11 at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:182) 12 at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:172) 13 at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392) 14 at java.net.Socket.connect(Socket.java:579) 15 at java.net.Socket.connect(Socket.java:528) 16 at java.net.Socket.<init>(Socket.java:425) 17 at java.net.Socket.<init>(Socket.java:208) 18 at org.xrq.test.socket.ClientSocketClass.main(ClientSocketClass.java:11)
看到Client只发起了五个请求,第六个请求发起被拒绝了,因为三次握手建立后,前五个请求占据了全连接队列并没有被处理,于是第六个请求进来,全连接队列中没有它的位置了,因此请求被拒绝。
如果注释打开,又是不一样的效果:
1 Client:0 2 Client:1 3 Client:2 4 Client:3 5 Client:4 6 Client:5 7 Client:6 8 Client:7 9 Client:8 10 Client:9
这里所有的十个客户端请求全部被接受,因为accept方法从全连接队列中取出了连接请求进行处理。看得出来,backlog提供了容量限制功能,避免过多的客户端Socket占据大量的服务端资源。
全连接队列大小的问题
接着说说全连接队列大小的问题。首先上面提到了backlog,不同的应用对backlog的默认值定义不同,比如:
- Java的Socket默认backlog为50
- Tomcat默认的backlog为100
- 阿里改造的Ali-Tomcat默认的backlog为200
- Nginx默认的backlog为511
Tomcat可以通过server.xml配置文件中<Connector />节点中的acceptCount来修改backlog。如果请求量不是很大,使用Tomcat默认的100也可以,但如果访问量比较大,建议这个值设置得大一些,比如1024或者更大。如果Tomcat前一层对SYC FLOOD攻击的防御没有把握的话,最好将SYN COOKIE防御也开启。
但是,全连接队列的大小未必是backlog的值,它是backlog与somaxconn(一个os级别的系统参数)的较小值。Linux环境下可以通过执行”cat /proc/sys/net/core/somaxconn”来查看:
这个值系统默认的是128,假如传入的backlog是10,取128和10的较小值,那么最终的全连接队列大小就是10。同样,如果要修改Linux系统默认的全连接队列大小的话,可以通过修改/proc/sys/net/core路径下的somaxconn。
半连接队列大小的问题
说完了全连接队列大小的问题,接着说一下半连接队列大小的问题,它是64与tcp_max_syn_backlog的较大值。
可以通过”cat /proc/sys/net/ipv4/tcp_max_syn_backlog”命令或者”cat /etc/sysctl.conf”命令来查看半连接队列的大小。以后者为例,其实就是打开了/ect/sysctl.conf这个文件:
标红的即tcp_max_syn_backlog默认值,默认值为1024,可以通过修改这个值来修改系统默认的半连接队列大小。
通过ss查看Socket统计状态
前面说了这么多全连接队列,那么如何查看全连接队列大小?
在Linux环境下可以通过ss命令查看,ss命令全称为Socket Statistics,顾名思义它用于统计Socket。netstat命令其实也可以显示类似内容,但是ss命令相比netstat命令能够显示更多更详细的有关TCP和连接状态的信息,而且比netstat更快速更高效。
ss命令的参数就不列举了,可以自己上网查看,这里使用ss -lnt,即查看处于LISTEN状态的TCP套接字,且不解析服务名称:
Send-Q表示当前端口的全连接队列大小,Recv-Q表示全连接队列当前使用了多少。
从Send-Q可以看到,它的值只有三种:128、50、1。这也印证了我们的结论,全连接队列的大小为传入的backlog与somaxconn的较小值。