1 TCP基础

1.1 头部字段

  • 序列号:建立时生成随机数,后续每发送一次数据加一,用于解决网络包乱序的问题
  • 确认应答号:表示下一次「期待」收到的序列号,发送端收到这个确认应答后认为在这个序列号之前的数据都被正常接受,用来解决丢包问题
  • 控制位:
    • ACK:为1表示「确认应答」字段有效,除SYN包之外该位必须设置为1
    • RST:为1表示TCP连接异常,强制断开
    • SYN:为1时表示希望建立连接,并初始化「序列号」
    • FIN:为1时表示之后不会再有数据发送,表示希望断开连接

1.2 TCP的特点

  • 面向连接:一对一建立连接,通过「源地址,源端口,目的地址,目的端口」确定唯一连接
  • 可靠:TCP协议尽自己最大努力保证报文一定能够到达接收端
  • 字节流:通过TCP传输的数据,可能会被操作系统拆分为多个TCP报文,如果接收方不知道「消息边界」就无法正确读取消息。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,那么也不能扔给应用层去处理,同时对「重复」的 TCP 报文会自动丢弃。

2 TCP三次握手

2.1 三次握手如何建立

  • 首先,客户端和服务器都处于close状态,然后服务器主动监听某个端口,处于Listen状态。
  • 客户端随机初始化序号,同时把SYN标识为设为1,表示SYN报文,把SYN送给服务端后,变换为SYN-SENT状态。
  • 服务端收到客户端的SYN报文后,随机初始化自己的序号,同时把确认应答号设置为客户端初始序号+1,然后把SYNACK标识为设置为1,最后送给客户端,变换为SYN-RCVD状态。
  • 客户端收到服务端报文后,向服务器回应最后一个应答报文,将ACK标识为设置为1,确认应答号填入服务器初始序号+1,这次报文可以携带数据,然后送给服务端,状态变化为ESTABLISHED
  • 服务器收到应答报文后,状态也设置为ESTABLISHED

2.2 如何在Linux中查看TCP状态

netstat -napt

2.3 为什么是三次握手?而不是两次、四次?

  • 三次的原因:
    • 三次可以阻止重复历史连接的初始化: 两次握手对于新旧TCP连接请提无法避免初始化步骤。
    • 三次可以同步双方的初始序列号:可以保证双方初始的序列号被可靠的同步给对方,四次握手没有必要,服务器确认和初始化自己的两步可以合并为一步。
    • 三次可以避免浪费资源:两次握手由于服务器没有中间状态,无法知道客户端是否收到自己回复的ACK,所以每收到一个SYN报文就需要建立一个连接,同一个连接由于网络阻塞而重发SYN报文时,服务端会产生资源浪费。
  • 为什么每次建立TCP连接初始化序列号都要求不同:
    • 为了防止历史报文被下一个相同四元组接受。
    • 为了安全性, 防止黑客伪造TCP报文。

2.4 IP层即然会分片,为什么TCP还需要MSS呢?

  • MTU:指的是一个网络包的最大长度,以太网中一般为1500字节
  • MSS:除去IP和TCP头部之后,一个网络包能容纳的TCP数据的最大长度

当IP层有一个超过MTU大小的数据要发送时,IP层就要进行分片,但是当一个分片丢失时,整个IP报文就需要重传,因为IP层没有超时重传机制,所以为了达到最佳的传输效率,建立TCP连接时通常需要协商双方的MSS值,当TCP层发现报文超过MSS时,就会进行分片,保证在IP层形成的IP包长度不会大于MTU,所以也就不会分片了。

2.5 三次握手信息丢失会发生什么

  • 第一次握手丢失

    • 客户端触发超时重传,重传次数默认为5,超时重试时间从1s开始翻倍。
    • 超过次数限制后断开连接
  • 第二次握手丢失

    • 客户端和服务端都超时重传,各自超过重传次数上限后,就会断开连接,回到Close状态
  • 第三次握手丢失

    • ACK报文不重传,服务端触发超时重传,超过次数限制后断开连接。

2.6 SYN攻击

攻击者伪造不同IP的tcp请求,打满服务器半连接队列。

  • TCP三次握手时,Linux内核会维护两个队列

    • 半连接队列:SYN队列
    • 全连接队列:accept队列

    image-20240824174930240

    • 当服务器收到SYN报文时,会创建一个半连接对象,加入到内核的SYN对队列
    • 发送SYN+ACK等待客户端响应
    • 服务器收到客户端响应的ACK报文,从SYN队列取出半连接对象,构建一个新的连接对象放入全连接队列
    • 应用通过调用accept()的socket接口,从全连接队列中取出连接对象。
  • 避免方式:

    • 增大netdev_max_backlog:网卡接受数据包速度大于内核处理速度时,会有一个队列保存数据包,这个参数对应的就是队列长度

    • 增大半连接队列

      • 增大 net.ipv4.tcp_max_syn_backlog
      • 增大 listen() 函数中的 backlog
      • 增大 net.core.somaxconn
    • 开启tcp-syncookies:开启后可以在不使用SYN半连接队列的情况下成功建立连接。

      • 当SYN队列满后,服务器收到SYN会根据算法算出一个cookie值
      • 将cookie值放入第二次握手报文的序列号中
      • 服务器收到应答报文时会检查ACK包的合法性,如果合法则放入全连接队列中,最后应用程序调用accept()接口取出连接。
    • 减少SYN+ACK重传次数:内核参数tcp_synack_retries

3 TCP四次挥手

3.1 挥手过程

  • 客户端打算关闭连接,发送FIN报文,进入FIN_WAIT_1状态
  • 服务器收到报文后,发送ACK应答报文,服务器进入CLOSE_WAIT状态
  • 客户端收到ACK报文进入FIN_WAIT_2状态
  • 服务器处理完数据,发送FIN报文,进入LAST_ACK状态
  • 客户端收到FIN报文,回应ACK报文,进入TIME_WAIT状态
  • 服务器收到ACK报文后,进入CLOSE状态,服务端完成连接关闭动作
  • 客户端经过2MSL后,自动进入CLOSE状态,客户端完成连接关闭动作

3.2 为什么需要四次挥手

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
  • 特定情况下会变成三次挥手:被动关闭方「没有数据要发送」并且「开启了TCP延迟确认机制」,那么第二次和第三次挥手就会合并传输。
    • 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
    • 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
    • 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK

3.3 挥手报文丢失会发生什么

  • 第一次挥手丢失:客户端触发超时重传,超过tcp_orphan_retries定义的重传次数时,直接关闭TCP连接。服务器处于连接建立状态。
  • 第二次挥手丢失:服务器ACK丢失,但是ACK报文不重传,所以客户端会触发超时重传机制,服务器处于CLOSE_WAIT状态。
    • 正常使用close关闭连接,客户端收到第二次挥手后进入FIN_WAIT_2状态会默认等待60秒(tcp_fin_timeout可以控制close关闭的连接),超时自动关闭连接。
    • 但是如果使用shutdown关闭连接(只关闭了发送反向),那么进入FIN_WAIT_2状态后就会死等(tcp_fin_timeout无法控制shutdown关闭的连接)。
  • 第三次挥手丢失:服务端会触发超时重传机制,超过重试次数后关闭TCP连接,客户端处于FIN_WAIT_2有等待时常上线,超时没有收到第三次挥手报文会主动断开连接
  • 第四次挥手丢失:服务器会触发超时重传机制,而客户端进入2MSL的TIME_WAIT状态,但是客户端每收到一个第三次挥手消息就会重置2MSL的时间,所以等服务器达到超时上限后发出最后一个报文,再过2MSL客户端会进入关闭状态。

3.4 TIME_WAIT状态

  • 为什么要有TIME_WAIT状态:

    • 防止历史连接中的数据被后面相同四元组连接错误接受。
    • 保证「被动关闭」一方能够被正确关闭。
  • TIME_WAIT过多,会占用系统资源,占用端口资源

    • 如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。
    • 如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。
  • 优化TIME_WAIT

    • 打开net.ipv4.tcp_tw_reuse 和 tcp_timestamps选项:复用处于TIME_WAIT状态的socket作为新的连接使用。
    • 设置net.ipv4.tcp_max_tw_buckets:TIME_WAIT的连接超过这个值,系统会将后续的TIME_WAIT连接状态直接重置。
    • 程序使用SO_LINGER,如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。不提倡
  • 服务器出现大量TIME_WAIT有什么原因

    TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。

    • HTTP没有使用长连接,禁用Keep-Alive后,服务器会主动关闭连接,所以会出现大量TIME_WAIT。
    • HTTP长连接超时,长连接没有请求超过一定时间,服务器会主动关闭。(比如nginx提供的keepalive_timeout)
    • HTTP长连接的请求数量达到上限,Web服务器会定义一个参数,表示一个HTTP长连接上能够请求的最大请求数量,超过数量后会主动关闭。
  • 服务器出现大量CLOSE_WAIT有什么原因

    CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接

    • TCP服务端流程

      1. 创建服务端 socket,bind 绑定端口、listen 监听端口
      2. 将服务端 socket 注册到 epoll
      3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
      4. 将已连接的 socket 注册到 epoll
      5. epoll_wait 等待事件发生
      6. 对方连接关闭时,我方调用 close

      可能导致服务端没有调用close的原因:

    • 第二步没做:没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。

    • 第三步没做:有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。

    • 第四步没做:通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。

    • 第六步没做:当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。

    通常都是代码问题,主要分析方向就是为什么没有调用close

3.5 建立连接后,有一方崩溃会发生什么?

  • 主机崩溃:TCP有保活机制,应用层可以自行实现心跳机制。如果没有开启保活机制,服务端会一直保持ESTABLISHED的TCP连接。

    • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制;
    • tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
    • tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

    应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效。(对端响应,重置保活时间;对端重启,回应RST报文;对端无响应,达到上限报告死亡)。

  • 进程崩溃:TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

  • 客户端主机宕机:

    • 服务端发送数据:超时重传达到上限断开连接
    • 服务器不发送数据
      • 开启保活机制:探测到死亡后断开连接
      • 一直存在保持ESTABLISHED状态。

4 TCP重传、滑动窗口、流量控制、拥塞控制

4.1 重传机制

  • 超时重传:发送数据时,启动一个定时器,超过指定时间,没有收到ACK 确认报文,就重发该数据
    • RTO超时时间设置小于RTT(包往返时间时)会造成网络拥塞,当过于大于RTT时会没有效率,所以应当略大于RTT。
  • 快速重传:收到三次相同的ACK,就开始重传ACK中期望的数据包
  • SACK:在TCP头部增加SACK选项,可以将已经收到的数据信息发送给发送方,从而发送方就不需要重传全部信息。
  • D-SACK:可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;可以知道是不是「发送方」的数据包被网络延迟了;可以知道网络中是不是把「发送方」的数据包给复制了。

4.2 滑动窗口

窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

对应累计应答模式,只要收到ACK报文,就表示之前的报文全部都被正确接受。

  • 窗口大小的确定:TCP头部有一个字段叫Window,接收段用来告诉发送端自己还有多少缓冲区可以接受数据,然后发送端根据接收端的能力发送数据,所以通常是由接收端确认窗口大小的。
  • 接收窗口和发送窗口的大小是相等的吗?并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。

4.3 流量控制

TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。

  • 操作系统缓冲区和滑动窗口的关系:实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整

    • 当接收方应用进程没办法及时读取缓冲区的内容时,接收方接受窗口会关闭
    • 当接收方资源紧张,操作系统直接减少了接收缓冲区大小,应用程序又无法及时读取缓存数据,这是数据包会丢失,发送方可用窗口大小会变为负值。
      • 为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
  • 窗口关闭:如果窗口大小为0,就会阻止发送方给接受方出阿奴第数据,直到窗口非0为止。接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。

    • 窗口关闭后,处理完数据,发送的非零ACK报文丢失会出现死锁(相互等待):TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
  • 糊涂窗口综合症:如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症

    • 让接收方不通告小窗口给发送方:当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
    • 让发送方避免发送小数据:使用 Nagle 算法,满足1)要等到窗口大小 >= MSS 并且 数据大小 >= MSS;2)收到之前发送数据的 ack 回包;二者之一就发送数据,否则一直囤积数据。

    接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症

4.4 拥塞控制

  • 为什么要有拥塞控制呀,不是有流量控制了吗?

    流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。

    在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….

  • 什么是拥塞窗口?和发送窗口有什么关系呢?

    拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的

    发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。只要网络中没有出现拥塞,cwnd 就会增大;但网络中出现了拥塞,cwnd 就减少。

  • 拥塞控制算法:cwnd初始大小为10,ssthresh初始大小为65535字节。

    • 慢启动:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。

    • 拥塞避免:当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。每当收到一个 ACK 时,cwnd 增加 1/cwnd。

    • 拥塞发生:拥塞发生后触发超时重传机制,此时ssthresh 会设为 cwnd/2cwnd 重置为 初始值,重新开始慢启动。

    • 快速恢复:拥塞发生后触发快速重传机制(快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈),此时cwnd = cwnd/2 ,也就是设置为原来的一半;ssthresh = cwnd`;然后进入快速恢复算法:

      • 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了)

      • 重传丢失的数据包;

      • 如果再收到重复的 ACK,那么 cwnd 增加 1;

      • 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态。