transport-layer.md
基本概念
什么是 TCP?
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议
面向连接是指一定得是一对一才能连接,一对多是无法做到的
可靠是指无论网络链路中出现了什么变化,TCP 都可以保证一个报文一定能够到达接收端
基于字节流是指当消息通过 TCP 传输时,消息可能会被分成多个 TCP 报文,如果接收方不知道消息边界,是无法读出有效的消息的,并且 TCP 报文是有序的,当前一个报文没有收到时,即使后面的报文先收到了,也不能直接给应用层去处理,另外对于重复的报文 TCP 会自动丢弃
如何唯一确定一个 TCP 连接?
一个 TCP 连接是由唯一的一个四元组所确定的,包括源地址、源端口、目标地址和目标端口
其中源地址和目标地址的字段是在 IP 头部中,是通过 IP 协议发送报文给对方主机
源端口和目标端口则是在 TCP 头部中,是告诉 TCP 协议应该把报文发给哪个进程
有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少?
理论上最大的连接数是客户端的 IP 数 * 客户端的端口数,但服务端最大 TCP 并发连接数是达不到理论上限的
由于每个 TCP 连接都是一个文件,Linux 对可以打开的文件描述符数量分别做了系统级、用户级和进程级的限制,另外每个 TCP 连接也会占用一定内存,而操作系统的内存是有限的
你知道 TCP 和 UDP 吗?它们的区别是什么?分别的应用场景是?
TCP 是面向连接的、可靠的、基于字节流的传输协议,它保证数据完整有序到达,而 UDP 是无连接的不可靠传输协议,它延迟极低,注重传输速度和简单性
区别则在于连接方式、可靠性、传输顺序、传输速度、头部开销、流量控制和拥塞控制
TCP 需要先通过三次握手建立连接(SYN、SYN-ACK、ACK),数据传输完成后还要通过四次挥手释放连接(FIN、ACK、FIN-ACK、ACK),且连接只能一对一,而 UDP 则是直接发送数据包,不需要握手和挥手,还可以一对多或者多对多
TCP 通过确认应答、超时重传和校验和来确保数据可靠,UDP 是没有重传机制的,发送即丢弃,可靠性由应用层处理
TCP 通过序列号来保证接收端能按序重组数据,并且是基于字节流的,传输的数据都是二进制的无边界字节串,UDP 不维护数据顺序,接收端可能乱序接收,且是面向报文的,传输的都是有边界的应用层的报文包
TCP 如果数据的大小太大会在传输层进行分片,接收端也是在传输层组装,而 UDP 则是在 IP 层进行分配和组装
TCP 因连接管理、流量控制和重传等机制,传输延迟是比较高的,UDP 没有相应的开销,所以传输速度极快
TCP 的头部最小 20 字节,包含序列号、确认应答号、窗口大小和控制位(ACK、RST——连接出现异常要强制断开、SYN、FIN)等字段,还有可选字段能扩展,UDP 的头部则是固定 8 个字节,仅含源端口、目标端口、包长度和校验和(防止收到在网络传输中受损的数据包)
TCP 能通过滑动窗口动态调整发送速率,避免接收方缓冲区溢出,UDP 则无流量控制,可能会因为接收方处理不及时而导致丢包
TCP 通过慢启动、拥塞避免和快速重传等算法来避免网络拥堵,UDP 无拥塞控制,反而可能加剧拥塞
TCP 的应用场景主要在于 HTTP、HTTPS、邮件和文件传输,而 UDP 的则是 DNS 查询、视频音频和在线游戏
TCP 和 UDP 可以同时监听相同的端口吗?
其实监听是只在 TCP 中才会有的,UDP 是没有监听这个动作的,只是 UDP 和 TCP 一样都会调用 bind() 来绑定端口,而 TCP 和 UDP 各自的端口号是互相独立的,所以 TCP 和 UDP 是可以绑定相同的端口的
链路层使用 MAC 地址找到局域网的主机后,网络层使用 IP 地址来寻址网络中互联的主机或路由器,传输层是用端口来寻址,识别统一计算机中同时通信的不同应用
而 TCP 和 UDP 在内核中是两个完全独立的软件模块,当主机收到数据包后可以通过 IP 包头的协议号知道该数据包是 TCP 还是 UDP,从而分配给对应的 TCP 和 UDP 模块处理,模块处理完报文再根据端口号确定给哪个 TCP 应用还是给 UDP 应用
什么是网络拥塞?TCP 是如何应对网络拥塞的?
网络拥塞是指在网络中传输的数据量超过了可用带宽或中间设备(如路由器、交换机)的处理能力,导致网络性能下降的现象
TCP 的拥塞控制机制主要是通过慢启动、拥塞避免、快速重传和快速恢复这四个阶段,动态调整发送速率,从而保证网络的稳定和高效
TCP 的 Keep-Alive 和 HTTP 的 Keep-Alive 是一个东西吗?它们之间有什么区别?
是完全不同的两个东西,HTTP Keep-Alive 是**应用层(用户态)实现的,被称为 HTTP 长连接,而 TCP Keep-Alive 是由传输层(内核态)**实现的,被称为 TCP 保活机制
HTTP Keep-Alive 使客户端能在一个 TCP 连接中发送多个请求和响应,避免了多次建立和关闭连接的开销,它是通过请求头中的 connection: keep-alive 实现的,在 HTTP 1.1 中它是默认开启的
TCP 的 Keep-Alive 是 TCP 的保活机制,它是用于在 TCP 连接上检测空闲时的连接状态的机制,当 TCP 建立连接后,如果一段时间内没有任何数据传输,服务端就会发送探测包来看连接状态是否有效,如果达到保活探测次数上限后,这个连接就会被关闭
TCP 的缺点有哪些?
TCP 的升级工作很困难:
TCP 协议是在内核中实现的,应用层只能使用不能修改,而升级内核本身又是一件很麻烦的事情,因为它会涉及到底层软件和运行库的更新,应用层需要测试是否兼容新的内核版本,而操作系统的升级本身就是一件滞后很严重的事情,所以 TCP 的升级和改进是非常困难的
TCP 连接建立的延迟:
TCP 建立连接是需要三次握手的,虽然 TCP Fast Open 这个特性能绕开三次握手,但它目前也并没有被普及,因为升级操作系统是一件很麻烦的事情
而且正因为 TCP 是在内核实现的,应用层的 TLS 无法对 TCP 的头部加密,所以 TCP 的序列号都是明文传输的,会有安全隐患,比如攻击者可以伪造一个符合范围的 RST 报文来关闭一条连接
TCP 队头阻塞:
TCP 是基于字节流的协议,接收端需要按照顺序接收数据包,如果前面的数据包丢失了,后续的数据包即使已经到达了,接收端也无法处理这些数据包,只能等待前面的数据包重传成功后才能继续处理,这就导致了队头阻塞的问题,但也只有这样才能保证数据是有序的
网络迁移:
TCP 连接是绑定在四元组(源 IP、源端口、目标 IP、目标端口)上的,如果网络发生了迁移,比如移动设备从 Wi-Fi 切换到蜂窝网络,或者服务器的 IP 地址发生了变化,那么现有的 TCP 连接就会被中断,必须重新建立连接
多个 TCP 进程可以绑定同一个端口吗?
如果不同 TCP 进程同时绑定的 IP 地址和端口都相同,那是不允许的,会报错 Address already in use,但如果绑定的 IP 地址不同,那是可以的
比如一台服务器有多个网卡,每个网卡有不同的 IP 地址,那么不同的 TCP 进程就可以绑定在同一个端口上,只要它们绑定的 IP 地址不同即可
但如果有一个进程绑定了 0.0.0.0(表示监听所有网卡)的 IP 地址和某个端口,那其他进程就无法再绑定这个端口了,因为它是代表任意地址,就相当于把主机上的所有 IP 地址都绑定了
如果想多个进程绑定相同的 IP 和端口,就要对 socket 设置 SO_REUSEPORT 选项
重启 TCP 服务进程时,为什么会有 Address in use 的报错信息?
重启的那方是主动关闭连接的那方,会产生 TIME_WAIT 状态的连接,而 TIME_WAIT 状态的连接会占用对应的四元组,所以当我们重启 TCP 服务进程时,如果之前的连接还处于 TIME_WAIT 状态,那就会导致绑定端口失败,报 Address already in use 错误,等 TIME_WAIT 超时之后就能正常重启 TCP 进程了
我们可以在调用 bind() 之前设置 SO_REUSEADDR 选项来允许重用处于 TIME_WAIT 状态的连接,从而避免这个问题,相当于只是放宽了 bind() 检查,让我们在服务端重启时能快速重新监听
需要区分的是 SO_REUSEADDR 它并不是复用 TIME_WAIT 状态的连接,而是允许绑定一个已经被 TIME_WAIT 占用的端口,从而让新的连接可以使用这个端口,旧连接此时还是会等待 TIME_WAIT 到期才彻底关闭,所以不会有 net.ipv4.tcp_tw_reuse 导致的提前跳过 TIME_WAIT 的风险
客户端的端口可以重复使用吗?
客户端的端口是可以重复使用的,只要目标 IP 和目标端口不一样,那客户端的端口就可以被重复使用,也就是说只要 TCP 连接的四元组中有一个元素是不一样的,那这个连接就是不一样的,客户端的端口就可以被重复使用
所以即使客户端的端口资源只有几万个,但它发起百万级的连接数也是没问题的,只要其他资源也能跟得上的话
TCP 序列号和确认号是如何变化的?
- 序列号 = 上一次发送的序列号 + 数据长度
- 确认号 = 上一次收到报文中的序列号 + 数据长度
- 上一次发送/接收了 SYN 或 FIN = 上一次发送/接收的报文的序列号 + 1
TCP 连接
TCP 三次握手过程是什么样的?
一开始客户端和服务端都处于 CLOSE 状态,然后服务端会主动监听某个端口,处于 LISTEN 状态
接下来客户端会随机初始化序列号 client-isn,并把 SYN 置于 1,表示 SYN 报文,再把这第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,然后客户端处于 SYN-SENT 状态
服务端在收到客户端的 SYN 报文后,会先随机初始化自己的序列号 server-isn,再把自己的确认应答号填入 client-isn + 1,再把 SYN 和 ACK 都置 1,再把这第二个 SYN-ACK 报文发给客户端,该报文也不含应用层数据,然后服务端处于 SYN-RCVD 状态
客户端在收到服务端的报文之后,会再回应最后一个 ACK 报文,先把 ACK 置 1,再把确认应答号填入 server-isn + 1,再发给服务端,这次报文是可以携带应用层数据的,然后客户端处于 ESTABLISHED 状态,服务端在收到客户端的应答报文后也会进入 ESTABLISHED 状态
一共就是 SYN —— SYN-ACK —— ACK 三次握手,但只有第三次握手可以携带数据,前两次握手是不可以携带数据的,三次握手之后连接就建立完成了,客户端和服务端可以互相发送数据,在 Linux 系统中可以通过 netstat -napt 这个命令来查看 TCP 的连接状态
为什么 TCP 是三次握手,而不是两次或者四次?
因为只有三次握手才能保证双方都具有接收和发送的能力
避免历史连接:
三次握手可以防止旧的重复连接初始化造成混乱,比如客户端先发送了一个 SYN = 90 的报文,但这个报文由于网络阻塞服务端并没有收到,然后客户端又重新向服务端发了一个 SYN = 100 的报文,不是重传 SYN = 90,而是重新发送了一个序列号不同的 SYN 报文
如果是三次握手,当网络拥堵时,旧的 SYN 报文比新的 SYN 报文先到达了服务端,那此时服务端就会返回一个 SYN + ACK 报文给客户端,这个报文的确认号是 90 + 1 = 91,但客户端收到后发现 91 并不是自己期望收到的 101,因为新发了 SYN 请求,所以客户端就会发送 RST 报文终止连接
服务端在收到 RST 报文后就会释放 SYN = 90 的连接,然后等收到最新的 SYN = 100 后重新建立连接,从而完成正常的三次挥手,这里的 SYN = 90 就是历史连接,防止历史连接初始化是使用三次握手最主要的原因
如果服务端在收到 RST 报文之前先收到了客户端第二次发的新 SYN 报文,并且这个报文里的端口号跟历史连接相同(如果不同的话就会直接被服务端认为是新连接要建立,服务端里的旧连接如果发送了数据包就会收到客户端的 RST 报文,如果没发就会触发 TCP 保活机制,然后释放),那服务端会回 Challenge ACK 报文给客户端,这个报文并不是 ACK 新的 SYN 报文的,而是上一次 SYN 的确认号,也就是 ACK 91,客户端发现 ACK 并不是自己期望收到的,所以仍然会触发 RST
如果是两次握手,服务端相当于在收到 SYN 报文之后就会进入 ESTABLISHED 状态,也就是说可以给对方发送数据,但这个时候客户端还没有进入 ESTABLISHED 状态,如果客户端判断当前连接是历史连接,那服务端在收到 RST 之前就白白浪费了资源在这个历史连接上发送数据,因为没有中间状态给客户端来阻止历史连接
如果第三次握手的 ACK 报文丢失了,虽然服务端还是在 SYN-RCVD 状态,但收到客户端的数据之后还是可以建立连接的,因为客户端发送的数据报文中是有 ACK 标识位和确认号的,这个确认号就代表确认收到了,所以可以正常建立连接
同步双方初始序列号:
序列号可以被接收方用来去除重复的数据和根据数据包的序列号按序接收,还可以标识发送出去的数据包中哪些是被对方已经收到的(通过 ACK 报文中的序列号得知)
所以无论是客户端还是服务端,当客户端发送 SYN 时自然需要服务端发送 ACK 来应答,表示 SYN 已经被成功接收,当服务端发送初始序列号给客户端时,也要得到客户端的响应,只有这样一来一回才能确保双方的初始序列号能被可靠地同步
而由于服务端在回复客户端 ACK 时可以顺便发 SYN,所以就只需要三步而不是四步了,如果是两次握手就只能保证一方的初始序列号能被对方成功接收,而不是双方的
避免资源浪费:
如果只有两次握手,当客户端由于网络阻塞重复发送 SYN 报文时,由于没有第三次握手,服务端并不清楚客户端是否收到了自己的 ACK 报文,所以服务端每次收到一个 SYN 就只能先主动建立一个连接,就会导致服务端建立了多个冗余的无效连接,造成不必要的资源浪费
TCP 连接如何确保可靠性?
TCP 的每个数据包都有一个唯一的序列号,确保数据能够按正确的顺序组装,接收方会根据序列号将数据组装成正确的数据,即使数据包的接受顺序和发送顺序不同
每个 TCP 数据包还都会附带一个校验和,用于检查数据在传输过程中的完整性,如果校验和不匹配,接收方就会丢弃该数据包并请求重传,接收方还会对每个收到的数据包发送确认应答,即 ACK
发送方在发送数据时会设置一个定时器,如果定时器超时之前没有收到接收方的 ACK,就会自动重传
TCP 会通过滑动窗口来进行流量控制,确保接收方能处理发送方的数据,还会通过拥塞控制机制来控制数据的发送速率,避免网络拥塞
第一次握手如果丢失了,会发生什么?
客户端发送 SYN 报文后如果一直收不到 SYN-ACK,就会触发超时重传,重传的 SYN 报文的序列号都是一样的
超时时间是写死在内核里的,要更改就需要重新编译内核,比较麻烦,而最大重传次数则是可以很方便地修改,通常第一次重传是在 1 秒后,第二次 2 秒,第三次 4 秒,每次都是上一次时间的两倍,以此类推,一般来说默认是重传五次,也就是一分钟左右,如果已经到达了最大重传次数,那客户端就会断开连接
第二次握手如果丢失了,会发生什么?
如果第二次握手丢失了,首先客户端是没有收到服务端返回的 SYN-ACK 报文的,所以客户端会以为是自己第一次握手的 SYN 报文丢失了,会触发超时重传 SYN
而服务端由于第二次握手丢失,是收不到第三次握手的,所以服务端也会触发超时重传 SYN-ACK,也就是两边都会重传,直到到达了最大重传次数,自动断开连接
第三次握手如果丢失了,会发生什么?
客户端在收到服务端的 SYN-ACK 报文后会给服务端回一个 ACK 报文,如果这个报文丢失了,服务端会触发超时重传 SYN-ACK,直到收到第三次握手或者到达最大重传次数,这里客户端的 ACK 报文是不会有重传的,当这个 ACK 报文丢失了,也是由对方来重传相应的报文
什么是 SYN 攻击?该如何避开?
在 TCP 三次握手时,Linux 内核会维护半连接和**全连接(Accept)**两个队列,服务端在收到客户端的 SYN 报文后会创建一个半连接的对象,并把其加入到内核的 SYN 半连接队列中,然后发送 SYN-ACK 给客户端,收到客户端回应的 ACK 报文后再从 SYN 队列中取出一个半连接对象,并创建一个新的全连接对象放到 Accept 队列里,应用就可以通过调用 accept() socket 接口来从 Accept 队列中取出全连接对象
攻击者短时间内伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就会进入 SYN-RCVD 状态,但服务端发送出去的 SYN-ACK 是无法得到未知 IP 主机的 ACK 应答的,所以就会占满服务端的半连接队列,导致后续再收到 SYN 报文时就会默认丢弃,导致客户端和服务端无法建立连接
避免方式主要有调大网卡接收数据包的缓冲队列最大值、增大 TCP 半连接队列长度、减少 SYN-ACK 的重传次数和开启 TCP 的 SYN-Cookies 功能
如果开启了 TCP 的 SYN-Cookies 功能,当半连接队列满了之后后续服务端收到 SYN 包时不会丢弃,而是会根据通信双方的 IP、时间戳和 MSS 等信息通过算法算出一个 Cookie 值,放到 SYN-ACK 的序列号里,返回给客户端,等服务端收到 ACK 时再检查是否合法,这样即使被攻击也能保证正常的连接成功建立
正是因为 Cookies 没有队列,所以它虽然能解决 SYN 洪水攻击,但由于服务端并不会保存连接信息,所以如果传输过程中数据包丢失了,服务端也是不会重新发送第二次握手的信息的
并且编解码 Cookies 都是很耗费 CPU 资源的,如果攻击者利用这一点来构造大量的第三次握手的 ACK 报文,再附带编造的 Cookies 信息,服务端收到后以为是正常 Cookies,就会进行解码,导致服务端 CPU 被耗尽,从而引发CPU 饥饿攻击,即 ACK 攻击
TCP 四次挥手的过程是什么样的?为什么需要四次?
客户端打算关闭连接,会发送一个 FIN 报文给服务端,然后进入 FIN-WAIT-1 状态,服务端在收到该报文后,会向客户端发送一个 ACK 应答报文,然后服务端进入 CLOSE-WAIT 状态,客户端在收到服务端的 ACK 报文之后,会进入 FIN-WAIT-2 状态
服务端在处理完数据之后会向客户端再发送一个 FIN 报文,之后服务端会进入 LAST-ACK 状态,客户端在收到服务端发送的 FIN 报文后也会回一个 ACK 报文,然后进入 TIME-WAIT 状态,服务端在收到 ACK 之后就会进入 CLOSE 状态,服务端已经完成了连接的关闭,然后客户端在经过 2 MSL 时间之后就会自动进入 CLOSE 状态,也完成了连接的关闭
由于两个方向各自需要一个 FIN 和一个 ACK,因此是四次挥手,要注意的是只有主动关闭连接的才会有 TIME-WAIT 状态
在关闭连接时,客户端向服务端发送 FIN 仅仅代表客户端不再发送数据了,但还能接收数据,服务端在回复 ACK 之后可能还有数据需要处理和发送,只有等服务端不再发送数据时才会发送 FIN 表示现在同意关闭连接,当然在特定情况下,四次挥手是可以变成三次的
如果第一次挥手丢失了,会发生什么?
客户端在发送完 FIN 之后会进入 FIN-WAIT-1 状态,但如果一直收不到 ACK 的话就会触发超时重传机制,如果超过了最大重传次数之后还是没有收到第二次挥手,就会直接进入 CLOSE 状态,直接关闭连接
如果第二次挥手丢失了,会发生什么?
由于 ACK 是不会重传的,所以如果第二次挥手丢失,客户端就会触发超时重传机制,直到收到第二次挥手或者到达最大的重传次数
如果第三次挥手丢失了,会发生什么?
客户端在收到第二次挥手的 ACK 报文后会进入 FIN-WAIT-2状态,在这个状态等待服务端发送第三次挥手,但这个等待是有时间限制的,如果超时了就会直接关闭连接
服务端发送的第三次挥手的 FIN 报文丢失后也会触发超时重传,也是一样如果到达了最大重传次数之后就会直接断开连接,而客户端由于一直没收到服务端的第三次握手所以超时之后也会直接关闭连接
如果第四次挥手丢失了,会发生什么?
也是一样如果超时了服务端是会触发超时重传的,如果到达了最大重传次数还是没有收到就会直接断开连接,客户端在收到第三次挥手后会进入 TIME-WAIT 状态,开启时长为 2 MSL 的定时器,如果中间再次收到第三次挥手的报文就会重置定时器,当超时之后也会直接断开连接
为什么 TIME_WAIT 等待的时间是 2 MSL?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,之所以是 2 MSL 是因为网络中可能存在来自发送方的数据包,当这些包被接收方处理后服务端又会向客户端发送响应,所以一来一回刚好要等待两倍的时间
这相当于至少允许报文丢失一次,比如有一个 ACK 在一个 MSL 内丢失了,这时服务端重发的 FIN 会在第二个 MSL 内到达,所以 TIME-WAIT 状态的连接可以应对
2 MSL 是从客户端接收到 FIN 并发送了 ACK 之后开始计时的,如果在 TIME-WAIT 时间内收到了服务端重发的 FIN,那么 2 MSL 就会重新计时
TIME-WAIT 是什么,这种状态是怎么出现的
TIME-WAIT 是 TCP 连接的主动关闭方在完成四次挥手之后短暂停留的一个状态,是用来确保最后一个 ACK 能被被动关闭方接收并防止历史连接中的数据被错误接收的
TIME-WAIT 只会出现在主动发起关闭 TCP 连接的那一方,常见于客户端
这种状态是在客户端收到服务端发送的第三次挥手的 FIN 报文,并向服务端发送了 ACK 报文之后会进入的,可以用 netstat -anp | grep TIME_WAIT | wc -l 来在实际开发中查看这个状态
还有一种特殊情况是两端几乎同时向对方发送 FIN,此时就要看两端哪一边满足:发出了 FIN、收到了对方的对自己 FIN 的 ACK、给对方的 FIN 回复了 ACK,这三个条件,只要满足了,就会进入 TIME-WAIT 状态
所以可能的链路是 ESTABLISHED —— FIN-WAIT-1 —— CLOSING(发出了 FIN,收到了 FIN,回复了 ACK,但还没有收到对方对自己 FIN 的 ACK)—— TIME-WAIT,或者是 ESTABLISHED —— FIN-WAIT-1 —— FIN-WAIT-2 —— 收到 FIN 并回复 ACK —— TIME-WAIT,也就是说四次挥手完成之后会有一方或两方发送/接收最后的 ACK,再进入 TIME-WAIT
为什么需要 TIME_WAIT 状态?
防止历史连接中的数据被新的连接错误接收:
序列号是用来标识发送的数据流的一个头部字段,它是用来保证消息的顺序性和可靠性的,而初始序列号则是在 TCP 建立连接时客户端和服务端基于时钟各自生成的随机数,用来保证每个连接都有不同的初始序列号
但序列号和初始序列号都不是无限递增的,它们都会发生回绕为初始值的情况,也就是说我们无法根据序列号来判断新老数据
如果 TIME-WAIT 没有等待时间或者等待时间过短,那服务端在连接关闭之前发送的历史数据,由于网络延迟,是在服务端以相同的四元组打开了新连接之后才到达的,而且这个历史数据报文的序列号还刚好在客户端的接收窗口内的话,客户端就会正常接收这个报文,就导致了数据错乱的问题
TIME-WAIT 持续了 2 MSL 这个时间足以让两个方向上的数据包都被完全丢弃,使得历史连接的数据包在网络中都自然消失,这样再出现的数据包就一定是新连接产生的了
保证被动关闭连接的一方能正确关闭:
TIME-WAIT 在等待足够的时间后可以确保最后的 ACK 能让被动关闭方接收,如果没有 TIME-WAIT,客户端在发完最后一次 ACK 后就直接进入 CLOSE 状态的话,如果这个 ACK 报文丢失了,服务端重传 FIN,但此时客户端已经关闭,收到 FIN 之后就会返回 RST 报文
而服务端收到这个 RST 是会将其解释为一个错误的,这对于一个可靠的协议来说并不是一个优雅的终止方式,所以客户端必须等待足够长的时间来保证服务端能收到 FIN
TIME_WAIT 过多有什么危害?
主要是占用系统资源,比如文件描述符、内存、CPU、线程什么的,以及还会占用端口资源,端口资源也是有限的
如果客户端的 TIME-WAIT 状态过多,导致占满了所有的端口,那么就无法向目标 IP 和目标端口都一样的服务端发起连接了,因为自身的端口号数量不够没办法用来组成不同的四元组,TCP 是靠四元组来唯一标识一个连接的
但在这种场景下,只要连接的是不同的服务端,那客户端的端口还是可以被重复使用的,也就是客户端还是可以向其他服务端发起连接的
如果服务端的 TIME-WAIT 过多,并不会导致端口资源受限,因为服务端只监听一个端口,理论上服务端还可以建立很多连接,但 TCP 连接过多也会占用系统资源
服务器出现大量 TIME_WAIT 状态的原因有哪些?
HTTP 没有使用长连接:
大多数 Web 服务的实现,无论是哪一方禁用了 HTTP Keep-Alive,都是由服务端来主动关闭连接的,那么此时服务端自然就会出现 TIME-WAIT 状态的连接
而 Keep-Alive 的初衷是为客户端后续的请求重用连接,如果在某次请求中 header 携带了关闭连接的信息,那不再重用这个连接的时机自然也就只有在服务端了,所以在服务端来关闭连接是比较自然的
如果是服务端禁用了 Keep-Alive,那为了减少一次系统调用,也是服务端主动关闭连接,因为如果是客户端主动的话那服务端后续还要再读一次事件才能知道连接已经关闭
所以当服务端出现大量 TIME-WAIT 时可以检查一下是否客户端和服务端都开启了 HTTP Keep-Alive
HTTP 长连接超时:
如果客户端在完成一个请求后在超时时间内都没有再发起新的请求,那一旦超时服务端就会主动关闭连接,就会出现 TIME-WAIT 状态的连接
所以可以检查是否是因为网络问题导致客户端发送的数据一直没有被服务端接收到,导致了 HTTP 长连接超时
HTTP 长连接请求数量到达上限:
一个长连接能处理的请求数量是有上限的,一旦超过上限服务端就会主动关闭这个长连接,所以一旦遇到一些 QPS 比较高的场景,那这个上限值如果刚好比较低的话,长连接就会很频繁地被关闭,从而导致出现大量的 TIME-WAIT
如果站在 TCP 连接层面来看的话,要解决 TIME_WAIT 连接数很多的问题,最直接的方式是怎么去优化
提升单个连接利用率:
同样的请求量用尽量少的 TCP 连接来承载,就是最直接的方式,也就是在一个 TCP 连接上跑多次请求,比如使用 Keep-Alive、HTTP 2.0 或 gRPC,而不是一次请求开一个连接,然后用完就关
TIME-WAIT 是绑定在一个 TCP 连接上的,也就是一个唯一的四元组上,如果是一万个请求每秒,那每个请求都新建一个连接和只保持一百个长连接,对于服务器的开销和 TIME-WAIT 的数量肯定是完全不一样的
如果使用的是 HTTP 1.1,那就要在客户端和服务端都开启 Keep-Alive 功能,客户端也要有连接池,不要每次都手动创建然后立马关闭,如果是 HTTP 2.0 或者 gRPC 那就能天然复用了,一条 TCP 上有多条 stream,只要复用别被自己关掉就是,其实就是尽量不要频繁创建新的四元组并主动发 FIN
不要主动关闭:
TIME-WAIT 是只会出现在主动关闭连接的那一方的,所以可以让对端成为主动关闭者,自己少发 FIN,那 TIME-WAIT 自然就只会出现在对方那里了
比如客户端不主动关闭,由服务端在长连接空闲超时后关闭连接,或者反过来让网关/中间件来承担 TIME-WAIT,这样这个状态就可以被比如代理吃掉,从而减少业务机器上的 TIME-WAIT 状态
Linux 内核参数:
实在不行的情况下,我们可以在主动发起连接的那方扩大可用的本地端口范围,比如 sysctl net.ipv4.ip_local_port_range 来看当前范围,然后 sysctl -w net.ipv4.ip_local_port_range="10000 65000" 调大一点,允许这台机器在同一个远程端口上同时利用更多不同的本地端口,从而间接允许更多未完全回收的四元组共存
或者也可以复用一部分 TIME-WAIT,通过 sysctl -w net.ipv4.tcp_tw_reuse=1 这个命令来让主动发起连接的一方,允许复用处于 TIME-WAIT 的 socket 给新的连接用,但使用之前要先用 net.ipv4.tcp_timestamps=1 打开 TCP 对时间戳的支持,因为重复的数据包会在时间戳过期之后被自然丢弃,从而避免历史报文污染新连接的问题
也可以调整 net.ipv4.tcp_max_tw_buckets 这个参数,它是 TIME_WAIT socket 的上限,超过会直接丢弃旧的 TIME_WAIT,但有可能会有旧连接的 FIN/ACK 处理不完全,看到奇怪的 RST
服务器出现大量 CLOSE_WAIT 状态的原因有哪些?
CLOSE-WAIT 是被动关闭方才会有的状态,如果被动方没有调用 close() 函数来关闭连接,也就无法发出 FIN 报文,也就无法使 CLOSE-WAIT 变成 LAST-ACK 状态
如果服务端出现了大量的 CLOSE_WAIT 状态,那说明服务端的程序没有调用 close() 函数来关闭连接,也就是说一般是代码的问题
一个普通的 TCP 服务端流程图:

如果没有将服务端 Socket 注册到 epoll,那有新连接到来时服务端是无法感知到这个事件的,也就无法获取到已连接的 Socket,也就无法对 Socket 调用 close() 了
如果在新连接到来时没有调用 accept() 获取这个这个新连接的 Socket,当有大量客户端主动断开连接时,服务端也是没有机会对这些 Socket 调用 close() 的,从而导致服务端出现了大量 CLOSE_WAIT 状态的连接,这种情况可能是服务端在执行 accept() 之前代码就卡在了某一个逻辑或者提前抛出了异常
如果在通过 accept() 获取已经连接的 Socket 之后没有将其注册到 epoll,那后续收到 FIN 报文时服务端也是没办法感知到这个事件的,也就没办法调用 close()
最后一种可能就是在发现客户端关闭连接之后服务端因为代码卡在某个逻辑或者漏处理,导致了死锁什么的情况,所以没有调用 close()
如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP 也有 Keep-Alive 机制,跟 HTTP 的差不多,只是事件间隔会比较久,并且 TCP 是会发送探测报文给对端的,如果相应了那定时器就会被重置
如果对端主机宕机并重启,对端可以响应但由于没有该连接的有效信息,所以会直接回复一个 RST 来让 TCP 快速发现该连接已经被重置
如果是对端主机宕机,或者是其他原因导致报文不可达,那就是走正常的 Keep-Alive,而如果是进程崩溃,那操作系统在回收进程资源时会发送 FIN 报文
如果已经建立了连接,但是服务端的进程崩溃会发生什么?
TCP 的连接信息是由内核维护的,所以进程崩溃之后内核会回收该进程所有的 TCP 连接资源,并完成四次挥手的所有过程,并不需要进程的参与
SYN 报文在什么情况下会被丢弃?
最主要的场景还是 TCP 的半连接或者全连接队列满了,这时后面来的 SYN 包都会被丢弃,除非开启了 SYN Cookies 或者能增大 TCP 全连接队列的长度
另外一种场景是比较古老一些的了,就是在 Linux 某个版本之前是有 tcp_tw_recycle 这个参数的,它允许处于 TIME_WAIT 状态的连接被快速回收,但它在使用了 NAT 的网络环境下是不安全的
因为 TCP 有一个 PAWS 机制,它是用于防止 TCP 包中的序列号发生绕回的,它需要开启 TCP 时间戳,如果它发现收到的数据包中时间戳不是递增的,则表明该数据包是过期的,就会直接丢弃
而如果客户端的网络环境使用了 NAT 网关,那每一台机器在服务端看来其实都是跟同一个客户端的 IP 地址通信,这时由于 tcp_tw_recycle 这个字段和开启了时间戳,就会开启一种叫 pre-host 的 PAWS 机制,它会对对端 IP 做 PAWS 检查,而不是对四元组做 PAWS 检查
这就导致如果有两个在同一 NAT 网关下的客户端跟服务器建立 TCP 连接,而客户端 B 的时间戳比客户端 A 的小,那就会直接丢弃来自客户端 B 发来的 SYN 包,因为它认为是来自同一个 IP 并且已经过期了的包
如何关闭一个 TCP 连接?
不能直接杀掉进程,如果是在客户端,那确实不会影响到其他客户端或者进程,但如果是在服务端那就会直接关闭所有的 TCP 连接,导致服务端无法继续提供访问服务
为了能精细关闭掉某一条 TCP 连接,我们需要使用 killcx 或者 tcpkill 这两个工具,它们都是通过伪造 RST 报文(相同的四元组和对方期望的序列号)来关闭指定的 TCP 连接,但它们拿到正确的序列号的方式是不一样的
tcpkill 是在双方进行 TCP 通信时拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文中并发送给对方,但这种方式是无法关闭非活跃的 TCP 连接的,因为如果没有数据传输就永远拿不到正确的序列号
而 killcx 则是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取到对方下一次期望收到的序列号,再填充到伪造的 RST 报文中,来关闭 TCP 连接,无论这个连接是否活跃都可以关闭
什么是 TCP 队头阻塞问题?
TCP 的队头阻塞是指在同一个 TCP 连接中,如果连续发送了好几个数据段,但中间的某一个丢失了,或者乱序没有补齐,那即使这个段后面的数据段已经都到达了,也需要先等待这个段重传了之后才能一起交给上层应用
这个问题在多路复用同一个 TCP 连接时会比较明显,例如 HTTP 2.0 的并发 Stream,虽然有很多条 Stream,但一旦这个 TCP 连接中的某一个段丢失了,所有 Stream 的后续数据就都需要等待重传,所以后续才有了使用 QUIC 的 HTTP 3.0,它让每个 Stream 都有独立的重传和顺序控制
如果服务端在第二次和第三次挥手之间发送的数据包,或者是四次挥手之前发送的数据包,由于网络阻塞,导致第三次挥手的 FIN 包比数据包先到达客户端,这时客户端会如何处理这个数据包?
第三次挥手的 FIN 包是服务端发给客户端的,在这个场景中客户端是主动关闭连接的那一方,所以只有客户端可能会出现 TIME_WAIT 状态
如果第三次挥手的 FIN 报文比数据包先到客户端,那此时的 FIN 报文其实是一个乱序的报文,客户端会把它加入到乱序队列中,并不会因为收到了这个 FIN 报文就直接进入 TIME_WAIT 状态
只有等收到了前面被网络延迟的数据包时,客户端会判断乱序队列是否有数据,有的话就会从中检测是否有可用数据,如果能在乱序队列中找到与当前报文的序列号保持顺序的报文,才会看该报文是否有 FIN 标志,只有此时发现有 FIN 标志,才会进入 TIME_WAIT 状态
如果一个处于 TIME_WAIT 状态的 TCP 连接收到了 SYN 报文,会发生什么?
如果收到的 SYN 报文的序列号和时间戳是合法的,客户端(主动关闭连接的一方)就会重用这个四元组连接,跳过 2 MSL,直接进入 TCP 三次握手的 SYC_RECV 状态
这里的合法是指,收到的 SYN 的序列号和时间戳(如果开启了)都要比当前端期望下一个收到的序列号和最后收到的报文的时间戳大,如果有一个更小那就是非法的,此时客户端(主动关闭连接的一方)就会重新发一个第四次挥手的 ACK 报文,这样对端在收到后发现并不是它期望收到的 ACK 序列号,就会回一个 RST 报文
而处于 TIME_WAIT 状态的连接收到了 RST 报文时是否会跳过 2 MSL 直接释放连接,主要是看 net.ipv4.tcp_rfc1337 这个参数的设置,默认是设为 0 的,就是会提前释放连接,如果是 1 就会丢弃 RST 报文,当然,跳过 TIME_WAIT 状态的时间是有危害的
一个没有打开 Keep-Alive 也没有数据交互的 TCP 连接,如果有一端突然断电了,和一端的进程 crash 了,这两种情况的区别是什么?如果有数据交互呢?
比如客户端突然断电,相当于是主机崩溃,此时服务端是无法感知到的,所以服务端的 TCP 连接将会一直处于 ESTABLISHED 状态,直到服务端重启进程,所以如果不开启 Keep-Alive 并且也没有传输数据的情况下,一方的 TCP 连接处于 ESTABLISHED 状态,并不代表另一方的连接一定是正常的
而如果是进程崩溃的话,由于 TCP 的连接信息是由内核维护的,所以当进程崩溃后,内核会回收该进程的所有 TCP 连接资源,并进行四次挥手,所以进程的崩溃是可以被感知到的
如果有数据传输:
比如是客户端的主机宕机了之后又迅速重启,那此时服务端可能会触发超时重传机制,客户端的内核收到重传的报文后会看当前是否有进程已经绑定了该 TCP 报文的目标端口号,如果没有的话就会直接回复 RST 报文,如果有的话,由于重启之后之前 TCP 连接的资源已经丢失了,内核是找不到这个 TCP 连接的 Socket 的,所以也会回复 RST 报文,所以只要有一方重启,收到之前连接的报文,都会回复 RST 报文来断开连接
如果是客户端主机宕机并且一直都没有重启的话,服务端超时重传的次数到达阈值后服务端也会断开 TCP 连接
在拔掉网线后,原本的 TCP 连接还存在吗?
TCP 连接在 Linux 内核中是一个叫做 struct_socket 的结构体,这个结构体包含 TCP 连接的状态等信息,而在拔掉网线的时候,操作系统是不会变更该结构体的任何内容的,所以 TCP 连接的状态也不会发生改变
如果在拔掉网线后是该连接是有数据传输的,那服务端会触发超时重传机制,直到达到最大重传次数后服务端才会断开连接,如果在重传报文的过程中客户端刚好把网线插回去了,那此时客户端是可以正常接收的,并会回复 ACK 报文
如果没有数据传输,并且没有开启 Keep-Alive,那就跟某一端主机突然断电是一样的,服务端的 TCP 连接会一直处于 ESTABLISHED 状态,直到服务端重启进程,而如果开启了 Keep-Alive,那服务端会发送探测报文给客户端,如果一直收不到 ACK 报文,达到最大重传次数后服务端也会断开连接
既然打开 net.ipv4.tcp_tw_reuse 可以快速复用处于 TIME_WAIT 状态的 TCP 连接,那为什么这个字段 Linux 是默认关闭的?
其实这个问题相当于是在问,如果 TIME_WAIT 状态持续时间过短或没有,会有什么问题,因为开启了复用就相当于缩短了 TIME_WAIT 的持续时间
如果要开启 net.ipv4.tcp_tw_reuse,那 TCP 的时间戳是必须也要打开的,即 net.ipv4.tcp_timestamps = 1(默认为 1)。从而精确计算 RTT 和防止序列号回绕(PAWS)
这就可能会导致一些问题,比如如果开启了时间戳,即使 RST 报文的时间戳过期了,只要这个报文的序列号是在对端的接收窗口内,这个 RST 报文也是能被接收的
由于开启了 tcp_tw_reuse,一个处于 TIME_WAIT 状态的端口被快速复用了,它与服务端建立了一个跟它上一个连接四元组相同的连接,这时如果前面一个连接被网络阻塞的 RST 报文到达了,并且序列号还刚好在接收窗口内,这个连接就会被断开,因为我们相当于缩短了 TIME_WAIT 状态等待的时间
另外,如果第四次挥手的 ACK 报文丢失了,而对端触发了超时重传,重传了第三次挥手报文,但这时主动关闭连接的那方因为 tcp_tw_reuse,已经进入 SYN_SENT 状态了,就会导致在接收到重传的第三次挥手报文后直接返回一个 RST,从而导致连接的关闭
如果这个场景中处于 LAST_ACK 状态的那端收到了新连接的 SYN 报文,它就会回复一个与上一次发送 ACK 报文一样的 ACK 报文,即 Challenge ACK,而不会确认收到 SYN
TCP Fast Open 是什么?
它是一个为了绕过 TCP 三次握手从而减少建立 TCP 连接的时间的功能
当客户端第一次跟服务端通信建立连接时,还是要走正常的三次握手流程的,只是这时客户端发送的 SYN 报文会包含 Fast Open 选项,并且该选项的 Cookie 为空,表明客户端请求 Fast Open Cookie,服务端就会生成 Cookie 并放在 SYN-ACK 中,客户端收到之后就会在本地缓存这个 Cookie
这样在后续通信建立连接时,客户端可以直接在第一次握手的时候就携带应用数据和之前记录的 Cookie,服务端会对这个 Cookie 进行校验,如果 Cookie 有效就会直接发送 SYN-ACK,确认 SYN 和数据,并把数据给应用层,否则就会丢弃应用数据,并且发出的 SYN-ACK 也只会确认 SYN 对应的序列号
这样服务端就可以在握手完成之前就发送响应数据,减少了握手带来的一个 RTT 的时间消耗,绕开了三次握手
但这个功能必须客户端和服务端都支持才可以开启
如果服务端只调用 bind() 绑定了 IP 和端口,但没有 listen() 让这个 socket 来监听连接,这时客户端请求建立连接,会发生什么?
如果没有调用 listen() 的话,服务端的这个 socket 是无法接收连接请求的,所以当客户端发送 SYN 报文时,服务端内核会直接回复一个 RST 报文,告诉客户端该端口不可达,从而导致连接建立失败
如果不调用 listen(),可以建立 TCP 连接吗?
可以的,客户端(主动连接方)不需要 listen(),它是调用 connect() 来发起连接请求的,而服务端(被动连接方)才需要 listen() 来监听连接请求
另外如果是客户端自连接或者是两端同时调用 connect() 向对方发起连接请求(TCP 同时打开),这两种情况下,内核里会有一个全局的哈希表用来存放 socket 的连接信息,这样就不需要 listen() 被执行时创建的半连接和全连接队列,就能建立 TCP 连接
如果没有 accept(),能建立 TCP 连接吗?
可以,并且如果客户端在服务端执行 accept() 之前发送消息给服务端,服务端也是可以正常回复 ACK 的
因为在服务端调用 listen() 之后,内核会创建半连接队列和全连接队列,当客户端发送 SYN 报文时,服务端内核会回复 SYN-ACK 报文,并将该连接放入半连接队列中,等到客户端回复 ACK 报文后,服务端内核会将该连接从半连接队列移到全连接队列中
而 accept() 的作用只是从全连接队列中取出一个已经建立好的连接,如果不调用 accept(),连接依然会被放在全连接队列中,accept() 本身跟建立连接就是毫无关系的

半连接和全连接队列的底层实现是什么,如何查看它们的大小?
全连接队列本质是一个链表,因为也是线性结构所以说它是个队列也没什么问题,因为它里面存放的都是已经建立的连接,服务端在取走连接的过程中也不会关心具体是哪个,所以直接从队头取即可
而半连接队列本质是一个哈希表,因为队列里都是不完整的连接,当一个第三次握手的 ACK 报文到达时,内核需要根据报文的四元组信息去找到对应的半连接进行升级,所以用哈希表来存放半连接是比较合适的
可以通过 ss -lnt 来查看全连接队列的大小,其中 Send-Q 是全连接队列的最大值,Recv-Q 是当前全连接队列的已经使用值,而半连接队列是没有直接命令可以查到的,但可以通过统计 SYN_RECV 状态的连接来简介获得半连接队列的长度,即 netstat -nt | grep -i 'your-ip:your-port' | grep SYN_RECV | wc -l
TCP 四次挥手中,能不能把第二次的 ACK 报文,放到第三次 FIN 报文一起发送?
如果被动关闭连接的一方在收到 FIN 报文之后,没有数据要继续发送了并且立刻也要关闭,此时就可以把第二次的 ACK 和第三次的 FIN 放到同一个报文中发送,这样可以减少一次报文的发送,从而减少网络开销
而 TCP 延迟确认机制,它是在收到数据包时,不马上直接回复单纯的 ACK(因为这样效率比较低,TCP 头部开销大),而是等一小段时间,如果在这段时间内有数据要发送,就把 ACK 放到数据包里一起发送出去,从而减少报文数量
这时 TCP 延迟确认机制就相当于一个撮合者,让 ACK 等了一小会,等到要发送 FIN 了,就顺便合并了,但这也只是可能性增加,并不是说一定就会这样
因为通常情况下 FIN 的 ACK 是不会被延迟太久的,它通常会被优先发送,并且只要当前端也是立刻要关闭,就也会马上发送 ACK + FIN,并不需要靠延迟确认机制来等一等,相当于它只是给了一个机会
如果没有开启 TCP_QUICKACK,就是在使用延迟确认机制,TCP_QUICKACK 是默认关闭的
Socket 编程
针对 TCP 应该如何 Socket 编程?
服务端和客户端都会先初始化 socket,得到文件描述符 fd,然后服务端会调用 bind() 将 socket 绑定到指定的 IP 和端口,接着调用 listen() 监听连接请求,等待客户端发起连接请求
客户端会调用 connect() 向服务端发起连接请求,服务端调用 accept() 从全连接队列中取出一个已经建立好的连接,双方就可以通过 read()/write() 来读取和写入数据
客户端在断开连接时会调用 close(),当服务端读取数据时读到了 EOF,在处理完数据之后就也会调用 close() 来关闭连接

accept() 发生在三次握手的哪一步?
accept() 是在三次握手完成之后调用的,三次握手完成后连接会被放入全连接队列中,accept() 就是从全连接队列中取出一个已经建立好的连接
客户端调用的 connect() 是在三次握手的第一步,connect() 成功返回则是在第二次握手
TCP 传输
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
MTU 是 IP 层面一个网络包的最大长度,MSS 则是去掉 IP 和 TCP 头部之后,TCP 层面所能容纳的最大网络包数据长度
如果全部都交给 IP 层分片,那在传输过程中如果某一个 IP 分片丢失,那整个 IP 报文的分片都要重传,因为 IP 层本身是没有超时重传机制的,当丢失后接收方的 IP 层就无法组装成完整的 TCP 报文,接收方也就无法发送 ACK 给发送方
发送方收不到 ACK,就会触发超时重传,就会重发整个 TCP 报文(头部 + 数据),而如果有 MSS,TCP 在建立连接时双方就会协商两边的 MSS 值,从而在 TCP 层面直接分片,这样后面如果一个 TCP 分片丢失后进行重发也是以更小的 MSS 为单位来的,而不用重传所有的分片,从而提高重传效率
TCP 流量控制是怎么实现的?
主要是用滑动窗口,接收方会维护一个接收窗口,发送方会维护一个发送窗口
接收窗口的大小不会超过接收缓存区的大小,并且在每次返回的 ACK 里告知发送方,发送窗口的大小也不会超过接收窗口的大小,还不会超过发送缓存区的大小,发送窗口中既有发送了还没确认的消息也有还没发送的消息
TCP 拥塞控制中的慢启动、拥塞避免、快重传和快恢复阶段分别是什么?它们的作用是什么?
慢启动:
TCP 在新连接建立后开始传输数据时的初始阶段,目的是逐步探测网络的可用带宽,防止过快发送数据或在开始时发送过多数据导致网络拥塞
拥塞避免:
当 cwdn 达到慢启动的阈值后,为避免发送速率增长过快进入拥塞,TCP 会改为线性增长的模式,从而通过更谨慎的方式探测网络的最大可用贷款
快速重传:
是 TCP 的一种优化机制,用于在检测到丢包后快速重传丢失的数据包,而无需等待超时,判断的依据是接收到三个重复的 ACK,从而减少丢包导致的中断的等待时间
快速恢复:
是结合快速重传的一个阶段,避免丢包重传后重新进入慢启动,它通过调整 cwnd 和 ssthresh 来快速恢复到拥塞避免状态,从而减少丢包对发送速率的影响
这些拥塞控制的方法在保证了数据可靠传输的同时,充分利用了网络带宽,通过动态调整发送速率避免了发送方过度占用网络资源,减少了丢包后的等待时间,提高了数据传输的连续性
可以详细介绍一下 TCP 的重传机制吗?
一般来说,当发送端的数据到达接收端时,接收端会回复一个确认应答表示已经收到消息,但数据包是有可能丢失的,无论是发送端的还是接收端的,所以 TCP 有了一系列的保障措施
超时重传:
在发送数据时设置一个定时器,如果超时后还没有收到对方的 ACK,就会重发该数据,这就是超时重传,无论是数据包丢失还是 ACK 丢失,都会触发超时重传
RTT:Round Trip Time,往返时延,RTO:Retransmission Timeout,超时重传时间,RTO 不能过长,否则会导致网络的空隙时间增大,也不能过短,否则会导致不必要的重传(在还没有收到 ACK 时就超时了),所以 RTO 应该略大于 RTT
但由于 RTT 是一个经常波动变化的值,所以 RTO 也是一个动态变化的值,每当遇到一次超时重传时,TCP 都会将下一次超时时间间隔设置为先前值的两倍,因为超时过多就说明网络环境差,不适合频繁重复发送,当然这可能会导致超时时间可能过长的问题,所以就会有快速重传这一机制
快速重传:
快速重传的工作方式是在收到三个相同的 ACK 报文时,会在超时之前重传丢失的报文
比如发送端发送了 Seq1 —— Seq5 五个报文,服务端在收到 Seq1 之后会回复 ACK2,假设此时只有 Seq2 没有被服务端收到,那后续服务端在收到 Seq3 —— Seq5 时都会继续发 ACK2,这就会触发快速重传
但快速重传是无法解决重传数量这个问题的,因为发送端无法判断是否是只有 Seq2 这一个包丢失了,还是说可能后面的包都丢失了或者丢失了某几个,这时我们就需要 SACK
选择确认:
SACK:Selective Acknowledgement,需要在 TCP 头部的可选字段中加入这个 SACK 字段,它可以将已收到的数据的信息发送给发送方,让发送方得知哪些数据收到了而哪些数据没有,从而可以只重传丢失的数据
比如发送方先发送了 100 —— 199,接收方返回 ACK 200,但发送方发送的 200 —— 299 丢失了,接收端下一次收到的是 300 —— 399,那此时接收端就会再次回复 ACK 200 的同时返回 SACK 300 —— 400,并且只要发送方还没有重传就一直更新 SACK 的右边界(只要收到了,比如是 300 —— 600),这样在发送方重传了之后就只需要回复 ACK 600 (右边界)了,告诉发送方 100 —— 599 都收到了,下次可以从 600 开始了
开启这个功能的参数是 net.ipv4.tcp_sack
D-SACK:
D 就是 Duplicate,这个字段主要是用 SACK 来告诉发送方哪些数据被重复接收了
当接收方的 ACK 丢失时,比如发送方分别发送了 3000 —— 3499 和 3500 —— 3999 这两次,但这两次的两个 ACK 都丢失了,导致触发了发送方的超时重传,重新发了一遍 3000 —— 3499 这个包,此时接收方就会再次发送 ACK 4000 的同时发送 SACK 3000 —— 3500 来告诉接收方这个数据是重复收到的,这样发送方就知道实际上数据没有丢失,只是 ACK 丢失了
如果是网络延迟的场景,比如发送方以 500 的间隔依次发送了 500 —— 2999 共五次,但其中 1000 —— 1499 由于网络延迟,在最后一个包都到达了之后它都还没有到达接收方,这时由于客户端一直回复的是 ACK 1000 和 SACK 1500 —— 不断更新的右边界,让发送端收到了三个相同的 ACK 触发了快速重传,所以重传了 1000 —— 1499
然后服务端回复了 ACK 3000 之后,前面被网络延迟的数据包才到达接收方,所以接收方就会再回复一个 ACK 3000 和 SACK 1000 —— 1500,来表示这个包被重复接收了,从而让发送方知道是数据包被网络延迟了
所以,D-SACK 可以让发送方知道是发出去的包丢了还是接收方回应的 ACK 丢了,还是说是网络延迟,或者是不是网络中某处把发送方的数据包复制了
开启这个功能的参数是 net.ipv4.tcp_dsack
能说说拥塞控制是怎么实现的吗?
慢启动:
在初始阶段,TCP 发送方会以较小的发送窗口传输数据,随着每次成功收到确认数据就会指数级增大发送窗口的大小,从而确保在网络发送初期能谨慎地逐步增加发送窗口的大小,避免引起网络拥塞
拥塞避免:
到达慢启动的阈值后就会进入拥塞避免阶段,发送窗口的大小不再是指数级增长,而是线性增长,从而避免引起网络拥塞
快速重传:
发送方连续收到相同的确认时会认为出现了数据包丢失,会迅速重新传输未确认的数据包,不用等待超时,从而更快地恢复拥塞导致的数据包丢失
快速恢复:
快速重传之后就会进入快速恢复阶段,发送方不会回到慢启动阶段,而是将慢启动阈值设置为当前窗口一半的大小,并将拥塞窗口大小设置为慢启动阈值加上已经确认但未被快速重传的数据块的数量,这会有助于从拥塞中更快地恢复
TCP 滑动窗口机制是如何工作的?它在流量控制中有什么作用?
TCP 滑动窗口是一种用于流量控制的技术,它可以确保数据的发送不会超出接收方的处理能力,它通过动态调整数据的发送量,来让数据传输更加高效的同时避免网络拥堵
随着接收方对已收到数据的确认,发送方的窗口范围会向前滑动,从而允许发送新的数据,接收方也会动态调整窗口的大小,发送方会根据接收方的接收能力调节数据发送速率
可以详细介绍一下 TCP 流量控制中的滑动窗口机制吗?
滑动窗口是为了解决数据包的往返时间越长,通信效率就越低的问题而诞生的,因为 TCP 是一来一回的应答模式
窗口大小就是指不用等待确认应答就可以继续发送数据的最大值,它的实现是操作系统开辟的一个缓存空间,当发送方连续发送了多个数据包,接收方返回 ACK 时中间的某一个 ACK 丢失了也没关系,因为发送方是可以通过下一个更大的 ACK 来确认的,表示这个 ACK 之前的所有数据都收到了,这就是累计确认
窗口的大小是由接收方来决定的,TCP 头部里的 Window 字段就是接收端告诉发送端自己还有多少缓冲区可以接收数据,发送方就根据这个处理能力来发送数据,从而避免了接收端处理不过来的情况,发送方发送的数据大小是不能超过接收方的窗口大小的
发送方窗口示例如下:

发送方也是要缓存发送的数据的,其发送窗口和可用窗口会随着接收方返回的 ACK 而动态变化,分别会有指针专门记录已发送但未收到 ACK 和未发送但在处理范围内的第一个字节的序列号
接收方窗口示例如下:

同样也是有指针会记录期望从发送方发来的下一个字节的序列号,接收窗口的大小是约等于发送窗口的大小的,因为双方的信息传输是存在时延的,整体都是动态变化的
在信息传输的过程中,发送方和接收方会在一次次的数据和 ACK 的发送接收中不断根据对方提供的指针指向的窗口边界而动态调整自己窗口的大小,这便是流量控制
滑动窗口机制有可能会遇到哪些问题?
应用程序无法及时读取缓存:
当服务端非常繁忙时,在收到客户端的数据之后应用层可能无法及时读取数据,这将导致服务端(接收方)的已收到数据一直占用缓冲区,从而不断减小接收窗口的大小
这会让发送方的发送窗口大小也进一步减小,直到最后接收窗口被收缩到了 0,发送方在收到该通知之后也会把发送窗口减少为 0,最终这个窗口就关闭了
不过当发送方的可用窗口大小变为 0 时,发送方是会定时发送窗口探测报文的,从而及时得知接收方的窗口是否发生了变化
操作系统直接减少接收方的缓冲区大小:
当服务端系统资源非常紧张时,操作系统是有可能直接减少接收窗口的大小的,如果此时应用程序读取缓存也不及时,让数据包一直在缓冲区内的话,接收窗口的值将会突然变成一个比较小的值
而如果此时接收方的 ACK 是在发送方发送下一次数据之后才到达的发送方,就会导致发送方并没有及时更改发送窗口的大小,直接发送了过大的数据,超出了接收窗口的大小,让接收方无法正常接收,从而丢弃数据包
而发送方因为已经发送了过大的数据,因为还没有收到接收方的 ACK,发送之后它会将发送窗口改为一个更小的值,此时发送方如果才收到接收方的告知接收窗口变小了的 ACK,发送方就会再次尝试缩小发送窗口,这样就会导致发送窗口的值有可能直接变成负值
为了防止发送先减少缓存大小再收缩窗口从而导致丢包的情况发生,TCP 是不允许减少缓存和收缩窗口同时进行的,它会先收缩窗口,再减少缓存
窗口关闭导致死锁:
如果窗口大小为 0,接收方是会阻止发送方发送数据的,直到窗口大小不再为 0
但由于发生窗口关闭之后,接收方在处理完数据后是会向发送方发送一个窗口重新非 0 的 ACK 报文来告诉发送方可以继续发送数据了的,如果这个 ACK 报文在网络中丢失了,就会导致死锁现象
发送方是一直在等待接收方的这个非 0 窗口 ACK 通知,接收方在发送完这个 ACK 通知之后则是一直等待发送方发送的数据,如果不做任何措施就会形成双方一直在持续互相等待的状态,即死锁
为了避免死锁,TCP 给每个连接都设有一个持续定时器,只要发送方收到了接收方的 0 窗口通知,就会启动这个持续定时器,如果超时了发送方就会发送一个窗口探测报文给接收方,从而打破死锁,如果三次窗口探测之后接收窗口还是 0,那有些实现就会直接发 RST 报文来中断连接
糊涂窗口综合症:
TCP 和 IP 的头部是有 40 个字节的,有时如果接收方过于繁忙,导致每次应用层对于接收窗口里已接收的数据的读取都特别少,让发送窗口和接收窗口都越来越小时,接收方还是会告诉发送方比如现在只有几个字节大小的窗口,但发送方还是会发送这几个字节,而这是毫无必要的,因为光是头部就有几十个字节了,开销已经大于数据本身的传输了
该现象即可以发生在发送方也可以发生在接收方,接收方可以通知一个小的窗口,而发送方可以发送小的数据
为了解决这个问题,接收方通常会在窗口大小小于 MSS(Maximum Segment Size)和一半缓存大小中的最小值时就向发送方发送 0 窗口通知,阻止发送方继续发数据,等处理完了一些数据后,窗口大小上去了,再打开窗口
发送方则通常是使用** Nagle 算法**,只有满足窗口大小大于等于 MSS 并且数据大小大于等于 MSS 时,或者是当前已经没有未被 ACK 确认的数据了,才会发送数据,否则就一直囤积数据,直到满足条件
但接收方和发送方只要有一方没有采取这些措施,就会无法避免这种现象,只有一方采取是无效的,因为如果接收方 ACK 回复很快的话是很容易让发送方的所有已发送数据都被 ACK 的
可以详细介绍一下 TCP 的拥塞控制吗?
流量控制是为了避免发送方的数据超出接收方的接受能力,而 TCP 是一个无私的协议,为了避免发送方的数据填满整个网络导致出现网络拥堵,就有了拥塞窗口 cwnd,它是由发送方维护的,会随着网络的拥塞程度动态变化
发送窗口 swnd 本来和接收窗口 rwnd 是约等于的,现在加入了拥塞窗口之后,发送窗口的值就是拥塞窗口和接收窗口中的最小值了,只要网络中没有出现拥塞,cwnd 就会增大,但只要出现了拥塞那 cwnd 就会减少,TCP 一般通过是否发生超时重传来判断网络是否出现拥塞,从而有了慢启动、拥塞避免、快速重传、超时重传和快速恢复这五个算法
慢启动:
发送方每次收到一个 ACK,拥塞窗口 cwnd 的大小就会加一,从而形成了 1-2-4-8 的指数型增长,直到涨到超过阈值 ssthresh(slow start threshold),从而进入拥塞避免阶段
拥塞避免:
进入拥塞避免之后拥塞窗口 cwnd 的值就会变成每收到一个 ACK 时,增加 1 / cwnd,直接变成了线性增长,随着增长网络就会进入拥塞状态,发生了丢包,就要重传,从而进入了拥塞发生的快速重传和超时重传机制
快速重传和超时重传:
超时重传是一种比较严苛的机制,ssthresh 会被直接降为拥塞窗口 cwnd 原本大小的一半,cwnd 则直接被重置为初始值,进而重新开启慢启动

而快速重传则是,当接收方发现丢失了某一个中间包的时候,会发送三次这个包的前一个包的 ACK,触发发送端的快速重传,不用等待超时再重传,由于这种情况下大部分数据包是没有丢失的,只有一小部分丢失,TCP 认为情况还不算太严重,所以拥塞窗口 cwnd 会先被降为原来的一半,再把 ssthresh 也降为 cwnd 原先的一半,然后就进入了快速恢复
快速恢复:
快速恢复一般是和快速重传一起使用的,进入快速恢复之后,拥塞窗口 cwnd 会先加三(因为确认有三个数据包被收到了),再重传丢失的数据包,然后如果后续再收到重复的 ACK,拥塞窗口 cwnd 会每次加一,如果收到了新数据的 ACK 之后,把拥塞窗口 cwnd 再重新设置回刚进入快速恢复时的值,代表此时重传的数据已经被收到,快速恢复已经结束了,然后就会再次进入拥塞避免

之所以会把拥塞窗口 cwnd 重新设置回刚进入时 ssthresh 的值,是因为快速恢复是对拥塞发生后慢启动的优化,其目的仍然是降低拥塞窗口 cwnd 来缓解拥塞,中间拥塞窗口 cwnd 逐渐增加是因为需要尽快把丢失的数据包发送给接收方,从而解决拥塞的根本问题,即三次相同的 ACK 导致的快速重传,本质上网络还是发生了拥塞的,只是没有超时重传那么严重
UDP
UDP 怎么实现可靠传输?
UDP 本身是无连接不可靠的传输层协议,但我们可以在应用层来模拟 TCP 的可靠性机制,QUIC 就是这样做的
我们可以提供确认序列号,通过接收端反馈 ACK 来确认,并保证有序性和完整性,还可以提供超时重传,在没有收到 ACK 时重传数据包并计算下一次超时重传时间,另外也可以提供滑动窗口机制,允许连续发送多个数据包,通过累计 ACK 来减少通信次数
如何基于 UDP 协议实现可靠传输?
目前市面上基于 UDP 来实现可靠传输的成熟方案主要是 QUIC,已经应用在了 HTTP 3.0,它是在应用层实现的,所以并不是重复造轮子,不使用 TCP 天然支持的可靠传输是因为 TCP 有一些固有的缺陷,比如队头阻塞和在内核不方便升级等问题,另外 TCP 的拥塞控制机制也不适合现代网络环境
QUIC 相关具体细节内容待整理