目录
一、如何理解 TCP 是面向字节流协议
先来说说为什么 UDP 是面向报文的协议?
如果收到了两个 UDP 报文,操作系统是如何区分开的呢?
再说说为什么 TCP 是面向字节流的协议?
二、如何解决粘包问题?
①、固定消息的长度
②、特殊字符作为边界
③、自定义消息结构
三、SYN 报文什么情况下会被丢弃?
什么是 PAWS 机制?
那么什么是 per-host 的 PAWS 机制呢?
四、已建立连接的 TCP,收到 SYN 会发生什么
①、客户端的 SYN 报文里的端口号与历史连接不同
②、客户端的 SYN 报文里的端口号与历史连接相同
五、如何关闭一个 TCP 连接?
killcx 的工具:
tcpkill 的工具
六、四次挥手中收到乱序的 FIN 包会如何处理?
七、在 TIME_WAIT 状态下 TCP 连接,收到 SYN 后会发生什么?
①、收到合法的 SYN
②、收到非法的 SYN
八、TCP 连接,一端断电和进程崩溃有什么区别?
无数据传输的场景:
①、主机崩溃情况
②、进程崩溃
有数据传输的场景:
①、客户端主机宕机,又迅速重启
②、客户端主机宕机,一直没有重启
九、拔掉网线后,原本的 TCP 连接还存在吗?
①、拔掉网线后,有数据传输
②、拔掉网线后,没有数据传输
十、tcp_tw_reuse 为什么默认是关闭的?
第一个问题:
第二个问题:
十一、HTTPS 中 TLS 和 TCP 能同时握手吗?
TCP Fast Open
TLSv1.3 + TCP Fast Open
十二、TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?
十三、TCP 协议的缺陷
13.1、升级 TCP 的工作很困难
13.2、TCP 建立连接的延迟
13.3、TCP 存在队头阻塞的问题
13.4、网络迁移需要重新建立 TCP 连接
十四、如何基于 UDP 协议实现可靠传输?
14.1、QUIC 是如何实现可靠传输的?
14.2、QUIC 是如何解决 TCP 队头阻塞问题的?
十五、TCP 和 UDP 可以使用同一个端口吗?
TCP 和 UDP 可以同时绑定相同的端口吗?
多个 TCP 服务进程可以绑定同一个端口吗?
客户端的端口可以重复使用吗?
多个客户端可以 bind 同一个端口吗?
十六、没有 listen ,能建立 TCP 连接吗?
那没有 listen ,为什么还能建立连接呢?
那么客户端会有半连接队列吗?
十七、没有 accept ,能建立 TCP 连接吗?
为什么半连接队列要设计成哈希表呢?
十八、用了 TCP 协议,数据一定不会丢失吗?
18.1、建立连接时丢包
18.2、流量控制丢包
18.3、网卡丢包
18.3、接收缓冲区丢包
18.4、两端之间的网络丢包
十九、TCP 四次挥手可以变成三次吗?
为什么需要四次挥手呢?
什么情况会出现三次挥手?
什么是 TCP 延迟确认机制?
二十、TCP 序列号和确认号是如何变化的?
之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作系统对 TCP 和 UDP 协议的发送方的机制不同,也就是问题在发送方。
当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收在接收到 UDP 报文后,读一个 UDP 报文就能够读取到完整的用户消息。
操作系统在收到 UDP 报文后,会将其插入到队列中,队列中的每一个元素就是一个 UDP 报文,这样用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。如图:
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。因此我们不能认为一个用户消息对应一个 TCP 报文,正因为如此,所以 TCP 是面向字节流的协议。
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。一般有三种方式分包的方法:
这种是最简单的方法,即每个用户消息都是固定长度的,比如规定一个消息的长度为 64 字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息,但是这个方法灵活性不高,实际中很少使用
在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。HTTP 就是一个很好的,通过设置回车符、换行符作为 HTTP 报文协议的边界。有一点注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免接收方当作消息的边界点而解析到无效的数据。
我们可以自定义一个消息结构,由包头和数据组成,其中包头,包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大,比如这个消息结构体,首先 4 个字节大小的变量来表示数据的大小,真正的数据则在后面。当数据方接收到包头的大小后,就解析包头的内容,于是就可以直到数据的长度,然后接下来就是继续读取数据,直到读满数据的长度,就可以组装成一个完整的用户消息来处理了。
SYN 报文会被丢弃的两种场景:
损人的 tcp_tw_recycle
对于服务器来说,如果同时开启了 recycle 和 timestamps 选项,则会开启一种称之为【per-host 的 PAWS 机制】。
tcp_timestamps 选项开启后,PAWS 机制会自动开启,它的作用是防止 TCP 包中的序列号发生绕回。正常来说每个 TCP 包都会有自己唯一的 SEQ ,出现 TCP 数据包重传的时候回复用 SEQ 号,这样接收方能够通过 SEQ 号来判断数据包的唯一性,也能在重复收到某个数据包的时候判断数据是不是重传的。但是 TCP 这个 SEQ 号是有限的,一共 32 bit,SEQ 开始递增,溢出之后从 0 开始再次依次递增。所以当 SEQ 号出现溢出后单纯通过 SEQ 号无法标识数据包的唯一性,某个数据包延迟或因重发而延迟时,可能导致连接传递的数据被破坏。
举个:
上图 A 数据包出现了重传,并在 SEQ 号耗尽再次从 A 递增时,第一次发的 A 数据包延迟到达了 Server,这种情况下如果没有别的机制来保证,Server 会认为延迟到达的 A 数据包是正确的而接收,反而是将正常的第三次发的 SEQ 为 A 的数据包丢弃,造成数据传输错误。
PAWS 就是为了避免这个问题而产生的,在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值作比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。对于上图的情况,PAWS 就能做到在收到 Delay 到达时的 A 号数据包时,识别出它是一个过期的数据包而将其丢掉。
开启 net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收,前提是打开时间戳
前面提到,开启了 recycle 和 timestamps 选项,就会开启一种叫做 per-host 的 PAWS 机制,per-host 是对【对端 IP 做 PAWS 检查】,而非对【IP + 端口】四元组做 PAWS 检查。
但是如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像是在跟一个客户端打交道一样,无法区分出来。
Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。
当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME_WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包。
因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对【相同的 IP 做 PAWS 检查】,那么就不会存在这个问题了。
accept队列满了
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
服务端收到客户端发起的 SYN 请求之后,内核会把该连接存储到半连接队列,并向客户端响应 SYN + ACK ,接着客户端会返回 ACK,服务端在收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
半连接队列满了,如果开启了 syncookies 功能,即使半连接队列满了,也不会丢弃 syn 包。
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN + ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。
全连接队列满了,在服务端并发处理大量信息时, 如果 TCP accept 队列过小,或者应用程序调用 accept() 不及时,就会造成 accept 队列满了,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。
解决这个问题,我们可以使用:
大概意思就是,一个建立的 TCP 连接,客户端如果中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?
我们都知道 TCP 连接是由【四元组】唯一确认的,在这个场景中,客户端的 IP,服务端 IP,目的端口并没有变化,所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。
如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一致,此时服务端会认为是一个新的连接要建立,于是就会通过三次握手来建立新的连接。
那旧连接里处于 Established 状态的服务端最后会怎么样呢?
如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭,此时客户端的内核就会回 RST 报文,服务端收到后就会释放连接。
如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 的保活机制就会启动,检测客户端没有存货后,接着服务端就会释放掉该连接。
如果客户端恢复后,发送的 SYN 报文中的源端口号跟上一次连接的源端口号一样,也就是处于Established 状态的服务端收到了这个 SYN 报文会怎么办呢?如图:
处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。接着客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放该连接。
可能大家第一反应就是【杀掉进程】不就行了吗?
这个是最粗暴的方式,杀掉客户端进程和服务端进程影响的范围会有所不同:
所以,关闭进程的方式不可取,最好的方式要精细到关闭某一条 TCP 连接。
有个同学可能就会说,伪造一个四元组相同的 RST 报文不就好了嘛?
这个思路很好,但是不要忘了还有个序列号的问题,伪造的 RST 报文的序列号一定能被对方接受嘛?如果 RST 报文的序列号不是对方期望收到的序列号,这个 RST 报文会被对方丢弃的,就达不到关闭的连接的效果。所以,要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足【四元组相同】和【序列号是对方期望的】这两个条件。直接伪造符合预期的序列号是比较困难的,因为如果一个正在传输数据的 TCP 连接,序列号都是时刻都在变化,很难伪造一个正确序列号的 RST 报文
我们可以伪造一个四元组相同的 SYN 报文,来拿到“合法”的序列号。
在最开始的时候,如果处于 Established 状态的服务端,收到四元组相同的 SYN 报文后,会回复一个 Challenge ACK,这个 ACK 报文里的【确认号】,正是服务端下次想要接收的序列号,就是通过这个拿到服务端下一次预期接收的序列号。然后用这个确认号作为 RST 报文的序列号,发送给客户端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接。
在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,他会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号伪造两个 RST 报文分别发送给客户端和服务端,这样双方的 TCP 连接都会被释放,这种方式活跃和非活跃的 TCP 连接都可以杀掉。
killcx 的工具使用方式也很简单,如果在服务端执行 killcx 工具,只需指明客户端的 IP 和端口号,如果在客户端执行 killcx 工具,则就指明服务端的 IP 和端口号。
它伪造客户端发送 SYN 报文,服务端收到后就会回复一个携带了正确【序列号和确认号】的 ACK 报文(Challenge ACK),然后就可以利用这个 ACK 报文里的消息,伪造两个 RST 报文:
除了 killcx 工具能关闭 TCP 连接,还有 tcpkill 工具也可以做到
这两个工具都是通过伪造 RST 报文来关闭指定的 TCP 连接,但是它们拿到正确的序列号的实现方式是不同的。
二者在获取对方下一次期望收到的序列号的方式是不同的。
tcpkill 工具属于被动连接,就是在双方进行 tcp 通信的时候,才能获取到正确的序列号,很显然这种方式无法关闭非活跃的 TCP 连接,只能用于关闭活跃连接。因为如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。
killcx 工具则是主动获取,它是一个主动发送一个 SYN 报文,通过双方回复的 Challenge ACK 来获取正确的序列号,所以这种方式无论 TCP 连接是否活跃,都可以关闭。
问题再现:假如服务端在二三次挥手之间发的数据,或者是四次挥手之间前的数据包,因为网络阻塞导致第三次挥手的 FIN 包比数据包先到主动关闭方,那么主动关闭方收到 FIN 就会进入 timewait 状态,这个时候那个延迟的数据包到了还能正常接收并处理嘛?
这个表述其实有点问题,因为如果 FIN 报文比数据包先抵达客户端,此时 FIN 报文其实是一个乱序的报文,此时客户端的 TCP 连接并不会从 FIN_WAIT_2 转移到 TIME_WAIT 状态。
因此我们需要关注【在 FIN_WAIT_2 状态下,是如何处理收到的乱序 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?】
结论:
在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就会被加入到【乱序队列】,并不会进入到 TIME_WAIT 状态。等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这是才会进入 TIME_WAIT 状态。
如图:
如图:
这个问题的关键是要看 SYN 的【序列号和时间抽】是否合法,因为处于 TIME_WAIT 状态的连接收到 SYN 后。会判断 SYN 的【序列号和时间戳】是否合法,然后根据判断结果的不同做出不同的处理。
什么是合法的 SYN ?
如果出于 TIME_WAIT 状态的连接收到【合法的 SYN】后,就会重用此四元组的连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。(双方都开启了时间戳,TSval 是发送报文时的时间戳):
上述过程:处于 TIME_WAIT 状态的连接收到 SYN 后,因为 SYN 的 seq(400)大于 rcv_nxt(301),并且 SYN 的TSval(30)大于 ts_recent(21),所以是一个【合法的 SYN】,于是就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
如果处于 TIME_WAIT 状态的连接收到【非法的 SYN】后,就会再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号(ack num),就会回 RST 报文给服务端:
处于 TIME_WAIT 状态的连接收到 SYN 后,因为 SYN 的 seq(200) 小于 rcv_nxt(301),所以是一个【非法的SYN】,就会再回复一个与第四次挥手一样的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端
这里有个疑问,在 TIME_WAIT 状态,收到 RST 会断开连接吗?
会不会断开主要看 net.ipv4.tcp_rfc1337 这个内核参数(默认情况下是 0):
这里有几个关键词:
如果没有开启 TCP keepalive ,且双方一直没有数据交互的情况下,如果客户端的【主机崩溃】了,会发生什么?
客户端主机崩溃了,服务端是无法感知到的,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。所以,我们得知一个点,在没有使用 TCP 保活机制且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态,并不代表另一方的连接还一定正常。
TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核就会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核中完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。所以即使没有开启 TCP keepalive ,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。
在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的相应,在一定时长后,服务端就会触发超时重传机制,重传未得到响应的报文。
服务端重传报文的过程中,客户端主机重启完成后,客户端的内核就会接受重传的报文,然后根据报文的信息传递给对应的进程:
这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开
拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗?
实际上,TCP 连接在Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构的任何内容,所以 TCP 连接的状态也不会发生改变。
接下来我们分场景讨论:
在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一段时候后,服务端就会触发超时重传机制,重传未得到响应的数据报文。
如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。
此时,客户端和服务端的 TCP 连接依然存在,就感觉什么事情都没有发生。
但是如果在服务端重传报文后,客户端也一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元组的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。
针对这种情况,还得看是否开启了 TCP keepalive 机制
如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在
而如果开启了,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:
如果对端是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,TCP 就会发送探测报文:
如果对端是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 的保活机制会被重置,等待下一个 TCP 保活时间的到来
如果对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,没有响应,连续几次后,达到保活探测次数后,TCP 会报告该 TCP 连接已死亡
其这题就是变相在问【如果 TIME_WAIT 状态持续时间过短或者没有,会有什么问题?】
可能有同学会说,使用 tcp_tw_reuse 快速复用处于 TIME_WAIT 状态的 TCP 连接时,是需要保证 net.ipv4.tcp_timestamps 参数是开启的(默认就是开启),而 tcp_timestamps 参数可以避免旧连接延迟报文,这不是解决了没有 TIME_WAIT 状态时的问题了吗?解决了一点,但是没完全解决。
我们知道开启 tcp_tw_reuse 的同时,也需要开启 tcp_timestamps,意味着可以用时间戳的方式有效的判断回绕序列号的历史报文。
但是,当查看源码后,发现对于 RST 报文的时间戳即使过期了,只要 RST 报文的序列号在对方的接收窗口内,也是能被接受的。当判断的函数返回 true 就代表报文是一个历史报文,于是就要丢掉这个报文。但是在丢掉这个报文时,会先判断是不是 RST 报文,如果不是 RST 报文,才会将报文丢弃,也就是说,即使 RST 报文是一个历史报文,并不会被丢弃。如下图:
过程如下:
上述的场景就是开启了 tcp_tw_reuse 风险,因为快速复用 TIME_WAIT 状态的端口,导致新连接可能会被回绕序列号的 RST 报文断开了,而如果不跳过 TIME_WAIT 状态,而是停留 2MSL 时长,那么这个 RST 报文就不会出现下一个新的连接。
开启 tcp_tw_reuse 来快速复用 TIME_WAIT 状态的连接,如果第四次挥手的 ACK 报文丢失了,服务端就会触发超时重传,重传第三次挥手报文,处于 syn_sent 状态的客户端收到服务端重传第三次挥手报文,则会回 RST 给服务端。如下图:
这时候有同学就会问,如果 TIME_WAIT 状态被快速复用之后,刚好第四次挥手的 ACK 报文丢失了,那客户端复用 TIME_WAIT 状态后发送的 SYN 报文被处于 last_ack 状态的服务端收到了会发生什么呢?
处于 last_ack 状态的服务端收到了 SYN 报文后,会回复确认号与服务端上一次发送 ACK 报文一样的 ACK 报文,这个 ACK 报文被称为 Challenge ACK,并不是确认收到 SYN 报文
处于 last_sent 状态的客户端收到服务端的 Challenged ACK 后,发现不是自己期望收到的确认号,于是就会回复 RST 报文,服务端收到后,就会断开连接。
总的来说就是会导致被动连接的一方不能被正常关闭
一般情况下,不管是 TLS 握手次数如何,都得先经过 TCP 三次握手后才能进行,因为 HTTPS 是基于 TCP 传输协议实现的,得先建立完可靠的 TCP 连接才能做 TLS 握手的事情。
在 HTTPS 中的 TLS 握手过程可以同时进行三次握手对不对呢?
这个场景可能发生,但是需要在特殊的条件下才能生效,如果说没有任何前提条件,说这句话就是耍流氓。需要同时满足的条件如下:
TCP Fast Open 是为了绕过 TCP 三次握手发送数据,在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能是为了减少 TCP 连接建立的时延。如果想要使用这个功能,客户端和服务端要都支持才会生效,不过开启了 TCP Fast Open 功能,想要绕过 TCP 三次握手发送数据,得建立第二次以后的通信过程。
具体过程:
所以第一次客户端和服务端通信的时候,还是需要正常的三次握手流程。随后,客户端有了 Cookie 这个东西,它可以用来向服务器 TCP 证明先前与客户端 IP 地址的三向握手已成功完成。
对于客户端与服务端的后续通信,客户端可以在第一次握手的时候携带应用数据,从而达到绕过三次握手发送数据的效果,过程如下:
具体过程如下:
所以,如果客户端和服务端同时支持 TCP Fast Open 功能,那么在完成首次通信后,后续客户端与服务端的通信则可以绕过三次握手发送数据,这就减少了握手带来的 1 个 RTT 的时间消耗
在前面我们知道,客户端与服务端同时支持 TCP Fast Open 功能的情况下,在第二次以后到的通信过程中,客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据。如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。因此如果【TCP Fast Open + TLSv1.3】情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的,如果基于 TCP Fast Open 场景下的 TLSv 1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。
这两个完全是两样不同的东西,实现的层面也不同:
HTTP 协议采用的【请求-应答】的模式,也就是客户端发起了请求,服务端这边才会响应,一来一回的样子,在进行 HTTP 请求时,进行 TCP 的三次握手连接,请求完毕时,需要进行四次挥手,一次连接只能请求一个资源,效率太慢。所以引入了 HTTP 长连接,通过一个 TCP 连接实现多次响应,并且只要任意一端没有明确提出断开连接,则保持 TCP 的连接状态。从 HTTP1.1 开始就是默认开启的了,并且给 HTTP 流水线技术提供了可实现的基础,一次性发送多个请求,服务器还是按照顺序响应。web 服务软件会提供一个参数来设置 HTTP 长连接的超时时间。
TCP Keepalive 其实就是 TCP 的保活机制
如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文:
如果对端程序是正常工作的,当 TCP 保活的探测报文发送给对端,对端也会正常响应,这样 TCP 保活机制会被重置,等待下一次到来(以下省略.............................)
由于 TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果想要升级 TCP 协议,那么只能升级内核。
而升级内核这个工作是很麻烦的,麻烦的事情不是说升级内核这个操作是很麻烦,而是由于内核升级涉及到底层软件和运行库的更新,我们的服务程序就需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。
很多 TCP 协议的新特性,都是需要客户端和服务端同时支持才能生效,比如 TCP Fast Open 这个特性,虽然在 2013 年就被提出了,但是 Windows 很多系统版本依然不支持,这是因为 PC 端的系统升级后之后很严重,Windows XP 现在还在有很多用户在使用,尽管它已经存在很久了。
TCP 三次握手的延迟被 TCP Fast Open(快速打开)这个特性解决了,这个特性可以在【第二次建立连接】时减少 TCP 连接建立的时延
过程如下:
针对 HTTPS 来说,TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,这两个握手过程是无法结合到一起去的,总得先完成 TCP 握手,才能进行 TLS 握手。也正是 TCP 是在内核实现的,所以 TLS 是无法对 TCP 头部加密的,这意味着 TCP 的序列号都是明文传输,存在安全问题
TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据,如下图:
图中发送方发送了很多 packet ,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet #3 在网络中丢失了,即使 packet #4-6 被接受方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 Packet #3 重传后,接收方的应用层才可以从内核中读取到数据。
这就是 TCP 队头阻塞问题,但这也不能怪它,因为只有这样才能保证数据的有序性。例如 HTTP2中多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,整个 TCP 都要等待重传,那么就会阻塞该 TCP 连接中的所有请求,所以 HTTP2 队头阻塞问题就是因为 TCP 协议导致的
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组确定一条 TCP 连接,那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接
很多同学可能第一反应就会说把 TCP 可靠传输的特性在应用层实现一遍,想一想为什么 TCP 天然支持可靠传输,为什么还需要基于 UDP 实现可靠传输呢?这不是重复造轮子吗?所以我们就应该先知道 TCP 协议的痛点,而这些痛点是否能基于 UDP 协议实现的可靠传输协议中得到改进?
TCP 协议的四个缺陷:
要基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,也就是设计好协议的头部字段
拿 HTTP/3 举例,在 UDP 头部与 HTTP 消息之间,共有三层头部:
总体上看是这样的:
Packet Header
Packet Header 首次建立连接时和日常传输数据时使用的 Header 是不同的(下面只画出了重要字段):
Packet Header 分为两种:
QUIC 也是需要三次握手来建立连接的,主要目的是为了协商连接 ID,协商出连接 ID 之后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。所以,你可以看到日常传输数据的 Short Packet Header 不需要传输 Source Connection ID 字段了,只需要传输 Destination Connection ID。Short Packet Header 中的 Packet Number 是每个报文独一无二的编号,它是严格递增的,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。
为什么要这样设计呢?
我们先来看看 TCP 的问题,TCP 在重传报文时的序列号和原始报文的序列号是一样的,也正是这个特性,引入了 TCP 重传的歧义问题。
如上图,当 TCP 发生超时重传后,客户端发起重传,然后接收到了服务端确认的 ACK,由于客户端原始报文和重传报文序列号都是一致的,那么服务端针对这两个报文回复的都是相同的 ACK。这样的话,客户端就无法判断出是【原始报文的响应】还是【重传报文的响应】,这样在计算 RTT(往返时间)时应该选择从发送原始报文开始计算,还是重传原始报文开始计算呢?
RTO(超时时间)是基于 RTT 来计算的,那么如果 RTT 计算不精确,那么 RTO(超时时间)也会不精确,这样可能导致重传的概率事件增大。
QUIC 报文中的 Packet Number 是严格递增的,即使是重传报文,它的 Packet Number 也是递增的,这样就能更加精确计算出报文的 RTT。
如果 ACK 的 Packet Number 是 N + M,就根据重传报文计算采样 RTT。如果 ACK 的 Packet Number 是 N ,就根据原始报文的时间计算采样 RTT,没有歧义性的问题。
另外,还有一个好处,QUIC 使用的 Packet Number 单调递增的设计,可以让数据包不再像 TCP 那样必须有序确认,QUIC 支持乱序确认,当数据包 Packet N 丢失后,只要有新的已接受数据包确认,当前窗口就会继续向右滑动。
待发送段获知数据包 Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包 Packet N + M 后重新发送给接收端,对重传数据包的处理跟新发送的数据包类似,这样就不会因为丢包重传将当前窗口阻塞在原地,从而解决了队头阻塞问题。
所以 Packet Number 单调递增的两个好处:
QUIC Frame Header
一个 Packet 报文中可以存放多个 QUIC Frame。如图:
每一个 Frame 都有明确的类型,针对类型的不同,功能也不同,自然格式也不同。
这里举例 Stream 类型的 Frame 格式,Stream 可以认为就是一条 HTTP 请求,长这样:
在前面介绍 Packet Header 时,说到 Packet Number 是严格递增,即使重传报文的 Packet Number 也是递增的,既然重传数据包的 Packet N+M 与丢失数据包的 Packet + N 编号并不一致,怎么确认这两个数据包的内容一样呢?
所以引入了 Frame Header 这一层,通过 Stream ID + Offset 字段信息实现数据的有序性,通过比较数据包的 Stream ID 与 Stream Offset,如果那都是一致,就说明这两个数据包的内容一致。
举个:下图中,数据包 Packet N 丢失了,后面重传该数据包的编号为 Packet N + 2,丢失的数据包和重传的数据包 Stream ID 与 Offset 都一致,说明这两个数据包的内容一致。这些数据包传输到接收端后,接收端能根据 Stream ID 与 Offset 字段信息将 Stream x 和 Stream x + y 按照顺序组织起来,然后交给应用程序处理。
总的来说,QUIC 通过单向递增的 Packet Number,配合 Stream ID 与 Offset 字段信息,可以支持乱序确认而不影响数据包的正确组装,摆脱了 TCP 必须按照顺序确认应答 ACK 的限制,解决了 TCP 因某个数据包重传而阻塞后续所有待发送数据包的问题。
什么是 TCP 队头阻塞问题?
TCP 队头阻塞的问题,其实就是接收窗口的队头阻塞问题。
接收方收到的数据范围必须在接收窗口范围呢,如果收到超过接收窗口范围的数据,就会丢弃该数据,比如下图接收窗口的范围是 32~51 字节,如果收到第 52 字节以上的数据都会被丢弃。
接收窗口什么时候才能滑动?当接收窗口收到有序数据时,接收窗口才能往前滑动,然后那些已经接收并且被确认的【有序】数据就可以被应用层读取。但是,当接收窗口收到的数据不是有序的,比如收到第 33~40 字节的数据,这些数据也无法被应用层读取。只有当发送方重传了第 32 字节数据并且被接受方收到后,接收窗口才会往前滑动,然后应用层才能从内核读取第 32~40 字节的数据。导致接收窗口的队头阻塞问题,是因为 TCP 必须按需处理数据,也就是 TCP 层为了保证数据的有序性,只有在处理完有序数据后,滑动窗口才能往前滑动,否则就停留,停留【接收窗口】会使得应用层无法读取新的数据。其实也不能怪 TCP 它本来设计的就是为了保证数据的有序性。
HTTP/2 的队头阻塞
HTTP/2 抽象出了 Stream 的概念,实现了 HTTP 并发传输,一个 Stream 就代表 HTTP/1.1 里的请求和响应。
在 HTTP/2 的连接上,不同的 Stream 帧是可以乱序发送的(因此可以并发不同的 Stream),因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同一 Stream 内部的帧必须是严格有序的。
但是 HTTP/2 多个 Stream 都是在一条 TCP 链接上的传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,那么当发生数据丢失,滑动窗口是无法往前移动的,此时就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
没有队头阻塞的 QUIC
QUIC 也借鉴 HTTP/2 里面的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求(Stream)。但是 QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,否是相互独立的,各自控制的滑动窗口。
QUIC 是如何做流量控制的?
TCP 流量控制是通过让【接收方】告诉【发送方】,它(接收方)的接收窗口有多大,从而让【发送方】根据【接收方】的实际接收能力控制发送的数据量
QUIC 实现流量控制的方式:
前面说到,TCP 的接收窗口在收到有序的数据后,接收窗口才能往前滑动,否则停止滑动。
QUIC 是基于 UDP 传输的,而 UDP 没有流量控制,因此 QUIC 实现了自己的流量控制机制,QUIC 的滑动窗口滑动的条件跟 TCP 有一点区别,但是同一个 Stream 也是要保证顺序的,不然无法实现可靠的传输,因此同一个 Stream 的数据包丢失了,也会造成窗口无法滑动。
QUIC 的每个 Stream 都有各自的滑动窗口,不同 Stream 互相独立,队头的 Stream A 被阻塞后,不妨碍 Stream B,C 的读取。而对于 HTTP/2 而言,所有的 Stream 都跑在一条 TCP 连接中,而这些 Stream 共享一个滑动窗口,因此对于同一个 Connection 内,Stream A 被阻塞后,Stream B、C 必须等待。QUIC 实现了两种级别的流量控制,分别是 Stream 和 Connection 两种级别:
QUIC 对拥塞控制改进
TCP 拥塞控制算法迭代速度是很慢的,而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度,由于 QUIC 在应用层,所以就可以针对不同的应用设置不同的拥塞控制算法
QUIC 更快的连接建立
对于 HTTP/1和2,TCP 和 TLS 是分层的,分别属于内核实现的传输层分别属于内核实现的传输层和表示层,因此它们很难合并在一起,需要分批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2 RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话复用,也需要至少 2 个 RTT。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的【连接 ID】,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层的,而是 QUIC 内部包含了 TLS ,它在自己的帧会携带 TLS 里的记录,再加上 QUIC 使用的是 TLS 1.3,因此仅需一个 RTT 就可以同时完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS信息)一起发送,达到 0-RTT 的效果。
QUIC 是如何进行迁移连接的?
QUIC 协议没有用四元组的方式来绑定连接,而是通过连接 ID 来标记通信的两个端点,客户端与服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(如连接 ID、TLS 密钥等),就可以无缝复用连接,消除重连的成本,没有丝毫卡顿,达到了连接迁移的功能
这里牵扯到几个问题:
可以的,在数据链路层中,通过 MAC 地址来寻找局域网中的的主机。在网际层中,通过 IP 地址来寻找网络中互联的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。所以,传输层中的【端口号】的作用,是为了区分同一个主机上不同应用程序的数据包。传输层有两个传输协议,可以在 IP 包头的【协议号】字段知道该数据包是 TCP/UDP ,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据【端口号】确定送给哪个应用程序处理。因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 端口,UDP 也有一个 80 端口,二者并不冲突。
在默认情况下,针对【多个 TCP 服务进程可以绑定同一个端口吗?】这个问题的答案是:如果两个 TCP 服务进程同时绑定的 IP 地址和端口号都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。
注意:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888 ,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址和端口 8888,那么执行 bind() 的时候也会出错。这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了。
Tip:如果想多个进程绑定相同的 IP 地址和端口,也是有办法的,就是对 socket 设置 SO_REUSEPORT 属性
重启 TCP 服务进程时,为什么会有 “Address in use” 的报错信息?
TCP 服务进程需要绑定一个 IP 地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。然后在实际中,我们会经常碰到一个问题,当 TCP 服务重启之后,总会遇到“Address in use”的报错信息,TCP 服务进程不能很快重启,而是要过一会才能重启成功。
为什么呢?
当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL 当TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP + PORT 仍然被认为是一个有效的 IP + PORT 组合,相同机器上不能够被在该 IP + PORT 组合上进行绑定,那么执行 bind() 函数时,就会返回 Address already in use 的错误
那要如何避免“Address in use” 的报错信息?
我们可以在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性,可以解决这个问题。
int on = 1;
setsocket(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
因为 SO_REUSEADDR 作用是:如果当前启动进程绑定的 IP + PORT 与处于 TIME_WAIT 状态的连接占用的 IP + PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。
前面也提到过这个问题:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.167.1.100 地址和端口 8888 ,那么执行 bind() 时候也会出错。这个时候也可以由 SO_REUSEADDR 解决,因为它的另外一个作用:绑定的 IP 地址 + 端口时,只要 IP 地址不是正好相同,那么允许绑定。
TCP 连接是由四元组唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就代表不同的 TCP 连接,所以如果客户端已使用端口与另一服务器建立了连接,那么客户端与服务端建立连接,还是可以使用已经被使用过的端口,因为内核是通过四元组信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。
要看多个客户端绑定的 IP + PORT 是否都相同了,如果都相同,那么在执行 bind() 的时候就会出错,错误是 “Address already in use”,一般而言,客户端不建议用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常没什么意义
客户端的 TCP 连接 TIME_WAIT 状态过多,会导致端口资源消耗尽而无法建立新的连接吗?
针对这个问题要看,客户端是否都是与同一台服务器(目标地址和目标端口一样)建立连接。如果客户端都是与同一个服务器建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。但是,因为只要客户端连接的服务器不同,端口资源可以重复使用的。所以如果客户端都是与不同的服务器建立连接,即使客户端端口资源只有几万个,客户端发起百万级连接也是没问题的。
如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一服务器建立连接的问题?
可以打开 net.ipv4.tcp_tw_reuse 这个内核参数,因为开启这个内核参数后,客户端调用 connect 函数时,如果选择的端口,已经被相同四元组的连接占用时,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1s,那么就会重用这个连接,然后就可以正常使用该端口了。
在调用 connect 函数时,内核刚好选择了某一端口,接着发现已经被相同的四元组连接占用了:
如果没有开启那个参数的话,内核就会选择下一端口,然后继续判断,直到找到一个没有被相同四元组的连接使用的端口,如果端口资源耗尽还是没找到,那么 connect 函数就会返回错误
如果开启了那个参数,就会判断该四元组的连接状态是否处于 TIME_WAIT 状态,如果连接处于 TIME_WAIT 状态并且该状态的持续时间超过了 1s,那么就会重用该连接,这时 connect 就会返回成功
是可以的,客户端可以自己连自己的形成链连接(TCP 自连接),也可以两个客户端同时向对方发出请求建立连接(TCP 同时打开),这两个情况都有个共同点,就是没有服务器参与,也就是没有 listen 就能建立连接。
我们知道在执行 listen 方法时,会创建半连接队列和全连接队列,三次握手的过程会在这两个队列中暂存连接的信息,所以形成连接,前提是有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket
显然没有,因为客户端没有执行 listen ,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。但内核中还有个全局 hash 表,可以用于存放 sock 连接信息,在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局的 hash 表中, 然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出消息。于是握手包一来一回,最后成功建立连接。
建立连接的过程中根本不需要 accept 参与,执行 accept 只是为了从全连接队列里取出一条连接。
全连接队列和半连接队列,虽然都叫队列,但其实全连接队列是个链表,半连接队列是个哈希表
先与全连接队列进行对比,本质上是个链表,因为也是线性结构,说它是个队列也没毛病,它里面放的都是已经建立完成的连接,这个连接正等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就好,所以直接从队列头中取就行,这个过程时间复杂度为O(1)
而半连接队列则不一样,因为队列里的都是不完整的连接,等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列中把相应 IP 端口的连接取出,如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度是O(n)了,而如果将其设计成哈希表,那么查找半连接的算法时间复杂度就是O(1)了。
还记得开启 tcp_syncookies 防止泛洪攻击吗?
我们其实可以反过来想一下,如果有 cookies 队列,那它会跟半连接队列一样,到头来,还是会被 SYN 泛洪攻击打满,,实际上 cookies 并不会有一个专门的队列保存,它是通过通信双方的IP地址,时间戳,MSS 等信息进行实时计算的,保存在 TCP 报头的 seq 里。当服务端收到客户端发来的第三次握手时,会通过 seq 还原出通信双方的IP地址,时间戳,MSS,验证通过则建立连接。
目前看来,syn_cookies 方案省下了半连接队列所需要的队列内存,还能解决 SYN 泛洪攻击,那为什么不直接取代半连接队列呢?
cookies 虽然能够防 SYN 泛洪攻击,但是也有一些问题。因为服务端并不会保存连接信息,所以如果传输过程中数据包丢了,也不会重发第二次握手信息。另外,编码解码 cookies ,都是比较耗 CPU 的,利用这一点,如果此时攻击者构造大量的第三次握手包(ACK包),同时带上各种瞎编的 cookies 信息,服务端在收到 ACK 包后,以为是正经 cookies,跑去解码,最后发现不是正经数据包才丢弃。这种通过构造大量的 ACK 包去消耗服务端的攻击叫 ACK 攻击,收到的攻击的服务器可能会因为 CPU 资源耗尽而没能正常响应正经请求。
在服务端,第一次握手之后,会先建立个半连接,然后再发出第二次握手,这时候需要有个地方可以暂存这个半连接队列,这个队列叫半连接队列。如果之后第三次握手来了,半连接队列就会升级为全连接,然后暂存到另外一个叫全连接队列的地方,坐等程序执行 accept 方法将其取走使用。是队列就有长度,有长度就有可能会满,如果他们满了,那新来的包就会被丢弃。
应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,那怎么办?让数据按一定的规律排个队,依次处理,也就是所谓的 qdisc(Queueing Discplines,排队规则),这也就是我们常说的流量控制机制。排队就首先有个队列,而队列有个长度。我们可以通过 ifconfig 命令看到,里面涉及的 txqueuelen 后面的数字 1000 ,其实就是流控队列的长度。当发送数据过快,流控队列长度又不够大时,就容易出现丢包的现象。
网卡和驱动程序丢包的场景也经常出现,原因有很多,比如网线质量差,接触不良.....
①、RingBuffer 过小导致丢包
在接收数据时,会将数据暂存到 RingBuffer 接收缓冲区中,然后等着内核触发软件中断慢慢收走,如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包。
②、网卡性能不足
网卡作为硬件,传输速度是有上限的。当网络传输速度过大时,达到网卡的上限,就会出现丢包
我们一般使用 TCP socket 进行网络编程时,内核都会分配一个发送缓冲区和一个接收缓冲区。当我们想要发一个数据包,会在代码里执行 send() ,这时候数据包并不是一把梭直接就走网卡飞出去的,而是将数据拷贝到内核发送缓冲区就完事返回了,至于是什么时候发送,发多少,这个后续由内核决定。而接收缓冲区作用类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。如果缓冲区设置过小,缓冲区已满,如果还有数据发来就会丢包
两端之间那么长的一条链路都属于外部网络,这中间由各种路由器和交换机还有光缆什么的,丢包也是经常发生。
TCP 只保证传输层的消息可靠,并不保证应用层的消息可靠,如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证
在某些情况下可以。
TCP 的四次挥手过程如下:
具体过程:
可以看到,每个方向都需要一个 FIN 和一个 ACK ,因此通常被称为四次挥手
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序:
从上面过程可知,是否要发送第三次挥手的控制权不在内核,而是在被动关闭方的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,所以服务端的 ACK 和 FIN 一般都会分开发送。
FIN 报文一定得调用关闭连接的函数才会这样吗?
不一定,如果进程退出了,不管是不是正常退出,还是异常退出。内核都会发送 FIN 报文,与对方完成四次挥手。
当被动关闭方在 TCP 回收过程中,【没有数据要发送】并且【开启了 TCP 延迟确认机制】,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见第三次挥手的次数比四次挥手还多。
当发送没有携带数据的 ACK ,它的网络效率是很低的,因为它也有 40 个字节的 IP 头和 TCP 头,但却也没有携带数据报文。为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。TCP 延迟确认的策略:
万能公式:
发送的 TCP 报文:
序列号:在建立连接时由内核生成的随机数作为其初始值,通过 SYN 报文传给接收端主机,每发送一次数据,就【累加】一次该【数据字节数】的大小,用来解决网络包乱序的问题。
确认号:指下一次【期望】收到的数据序列号,发送端收到接收方发来的 ACK 确认报文后,就可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:用来表示 TCP 报文是什么类型的报文,例如 ACK报文,数据报文,SYN报文......
为什么第二次和第三次握手报文中的确认号是将对方的序列号 + 1 作为确认号呢?
SYN 报文是特殊的 TCP 报文,用于建立连接时使用,虽然 SYN 报文不携带用户数据,但是 TCP 将 SYN 报文视为 1 字节的数据,当对方收到了 SYN 报文后,在回复 ACK 报文时,就需要将 ACK 报文中的确认号设置为 SYN 的序列号 + 1,这样做的目的有两个: