TCP 其他八股总结
如何理解TCP是面向字节流的协议,UDP是面向报文的协议?
主要原因 :操作系统对TCP和UDP的发送方的机制不同
为什么UDP是面向报文的协议?
当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分 ,在组装好 UDP 头部后就交给网络层来处理 ,所以发出去的 UDP 报文 中的数据部分就是完整的用户消息,也就是每个 UDP 报文 就是一个用户消息的边界 ,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文。
补充:
IP 分片 ≠ UDP 拆包,UDP 依然"保证每个报文是完整消息",因为:
- 分片发生在 IP 层,对 UDP 完全透明
- 接收端 IP 层会自动重组所有分片 → 只有当全部分片成功到达,IP 层才把完整的原始 UDP 报文交给 UDP 层
- UDP 层看到的,永远是一个
原子的、未被拆解过的数据报 ------ 要么全到(完整交付),要么全不到(IP 层重组失败 → 直接丢弃,不通知 UDP 层) - 所以:UDP 的面向报文 特性,指的是它与应用层 之间的契约;而 IP 分片,是网络层为传输服务做的内部优化(或妥协),不破坏该契约。
为什么TCP是面向字节流的协议?
字节流 :字节流是计算机中表示数据的一种方式,指以字节(8位二进制数)为基本单位进行连续传输或存储的数据序列。
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文 ,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输 。
这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。
因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。 TCP 保证的是字节的顺序和完整性,而不是应用层消息的边界。
如何解决粘包?
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式 (在应用层实现):
-
固定长度的消息
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息
-
特殊字符作为边界
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就认为已经读完一个完整的消息。
-
自定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
客户端和服务端初始化序列号都是随机生成的话,就能完全避免连接接收历史报文了吗?
回答 :++不能完全避免!++
首先我们先回顾一些前置知识:
- 如果每次建立连接客户端和服务端的初始化序列号都不一样,就有大概率因为历史报文的序列号不在对方接收窗口,从而很大程度上避免了历史报文,相反,如果每次建立连接客户端和服务端的初始化序列号都一样,就有大概率遇到历史报文的序列号刚好在对方的接收窗口内,从而导致历史报文被新连接成功接收。所以,每次初始化序列号不一样能够很大程度上避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了。
- 序列号 ,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
- 初始序列号 ,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
在一个速度足够快 的网络中传输大量数据时,序列号的回绕时间就会变短 。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
为了解决这个问题,就需要有 TCP 时间戳 。tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕.
举个例子:
32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。
使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳 (Recent TSval ),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
如果时间戳也回绕了怎么办?:
时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。
时间戳回绕的速度只与对端主机时钟频率有关。
Linux 以*本地时钟计数(jiffies)*作为时间戳的值,不同的增长时间会有不同的问题:
-
如果时钟计数加 1 需要1ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。(可以假设从0开始的特殊情况得知为什么是一半)
"回绕" 指计数器从最大值(
2³² - 1)再加 1 时,会因位数限制溢出,回到最小值 0,形成循环;"回绕一半" 即计数器从任意值递增到 "当前值 + 2³¹"(32 位的一半是 2³¹,约 21.47 亿)。计算过程:
- 回绕一半需要的 "计数增量":
2³¹ = 2147483648次; - 每次增量耗时 1ms,总耗时 =
2147483648 ms; - 换算为天:1 天 = 24×60×60×1000 = 86400000 ms,因此总耗时 ≈ 2147483648 ÷ 86400000 ≈ 24.8 天。
- 回绕一半需要的 "计数增量":
-
如果时钟计数提高到 1us 加1,则回绕需要约71.58分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过70分钟,只是如果70分钟没有报文收发则会有一个包越过PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的TCP连接少之又少),但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题;
-
如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,对于TCP连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加;
Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效 ,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。
要解决时间戳回绕的问题,可以考虑以下解决方案:
1)增加时间戳的大小,由32 bit扩大到64bit
这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的IPv4与IPv6一样
2)将一个与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳的增速不变
随着时钟频率的提高,TCP在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。
补充:

SYN 报文什么情况下会被丢弃?
开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃
背景 :客户端(发起连接方 )都是和目的 IP+ 目的 PORT 都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接过多 的话,就会受端口资源限制,如果占满了所有端口资源 ,那么就无法再跟目的 IP+ 目的 PORT都一样的服务器建立连接了。
Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:
-
net.ipv4.tcp_tw_reuse ,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接 ,然后就可以正常使用该端口了。所以该选项只适用于连接发起方。
补充说明:为什么是1秒?:
- 它远小于 2×MSL(60 秒),能显著提升高并发短连接场景下的端口利用率;
- 为了保证PAWS算法能正确工作,新连接初始数据包的时间戳必须大于前一个处于TIME_WAIT状态的连接所记录的最后时间戳。内核设定1秒的等待期,是为了确保新连接的时间戳有足够大的增量 (因为系统时间戳通常以毫秒或微秒为单位递增,但是某些嵌入式或者老系统可能每秒才加1[1Hz])。这有效避免了新连接时间戳与旧连接相同导致新链接被丢弃.
-
net.ipv4.tcp_tw_recycle ,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收;
tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。
要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。
PAWS机制
tcp_timestamps选项开启后,PAWS机制会自动开启,作用是防止当TCP包中的序列号发生回绕时,将原本应该接收的数据包丢弃造成数据传输错误。
在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
对于服务器来说,如果同时开启了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 检查,那么就不会存在这个问题了。
TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃
半连接队列满了
当服务器受到syn攻击,就有可能导致 TCP 半连接队列满了,这时后面来的 syn 包都会被丢弃。但是,如果开启了syncookies 功能,即使半连接队列满了,也不会丢弃syn 包。
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。
全连接队列满了
在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,就会造成 accpet 队列满了 ,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。

要解决这个问题,我们可以:
-
调大 accpet 队列的最大长度,调大的方式是通过调大 backlog 以及 somaxconn 参数。 ++最终生效的 accept 队列长度 = min(backlog, somaxconn)++。所以,必须同时调大两者,才能真正扩大队列上限。
backlog:调用listen(sockfd,backlog)时传入的值,你自己希望内核为这个socket分配多大的全连接队列
somaxconn : 是一个 系统级内核参数,定义了所有 socket 的
backlog最大允许值。 -
检查系统或者代码为什么调用 accept() 不及时。
已建立连接的TCP,收到SYN会发生什么?
大概意思是,一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?
1.客户端的SYN报文里的端口号与历史连接不相同
如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一样,此时服务端会认为是新的连接要建立,于是就会通过三次握手来建立新的连接。
那旧连接里处于 Established 状态的服务端最后会怎么样呢?
- 如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。
- 如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。
2.客户端的 SYN 报文里的端口号与历史连接相同
处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。
接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。

如何关闭一个TCP连接
伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足
四元组相同和序列号是对方期望的这两个条件。
如果处于 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 报文:
- 用 Challenge ACK 里的确认号伪造 RST 报文发送给服务端,服务端收到 RST 报文后就会释放连接。
- 用 Challenge ACK 里的序列号伪造 RST 报文发送给客户端,客户端收到 RST 也会释放连接。
正是通过这样的方式,成功将一个 TCP 连接关闭了!
除了 killcx 工具能关闭 TCP 连接,还有 tcpkill 工具也可以做到。
这两个工具都是通过伪造 RST 报文来关闭指定的 TCP 连接,但是它们拿到正确的序列号的实现方式是不同的。
- tcpkill 工具是在双方进行 TCP 通信 时,拿到对方下一次期望收到的序列号 ,然后将序列号填充到伪造的 RST 报文,并将其发送给对方,达到关闭 TCP 连接的效果。
- killcx 工具是主动发送一个 SYN 报文,对方收到后会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK,这时就可以拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文,并将其发送给对方,达到关闭 TCP 连接的效果。
可以看到, 这两个工具在获取对方下一次期望收到的序列号的方式是不同的。
tcpkill 工具属于被动获取 ,就是在双方进行 TCP 通信的时候,才能获取到正确的序列号,很显然这种方式无法关闭非活跃的 TCP 连接 ,++只能用于关闭活跃的 TCP 连接++。因为如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。
killcx 工具则是属于主动获取,它是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取正确的序列号,所以这种方式无论 TCP 连接是否活跃,都可以关闭。
四次挥手中收到乱序的FIN包会如何处理?
在 FIN_WAIT_2 状态下,是如何处理收到的乱序的 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?
在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,并不会进入到 TIME_WAIT 状态。
等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。
在TIME_WAIT状态的TCP连接,收到SYN后会发生什么?
针对这个问题,关键是要看 SYN 的 序列号和时间戳 是否合法 ,因为处于 TIME_WAIT 状态的连接收到 SYN 后,会判断 SYN 的序列号和时间戳是否合法,然后根据判断结果的不同做不同的处理。
先跟大家说明下, 什么是++合法++的 SYN?
- 合法 SYN :客户端的 SYN 的序列号比服务端期望下一个收到的序列号要大 ,并且 SYN 的时间戳比服务端最后收到的报文的时间戳要大。
- 非法 SYN :客户端的 SYN 的序列号比服务端期望下一个收到的序列号要小 ,或者 SYN 的时间戳比服务端最后收到的报文的时间戳要小。
上面 SYN 合法判断是基于双方都开启了 TCP 时间戳机制的场景,如果双方都没有开启 TCP 时间戳机制,则 SYN 合法判断如下:
- 合法 SYN :客户端的 SYN 的序列号比服务端期望下一个收到的序列号要大。
- 非法 SYN :客户端的 SYN 的序列号比服务端期望下一个收到的序列号要小。
合法SYN
重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
非法SYN
如果处于 TIME_WAIT 状态的连接收到非法的 SYN 后,就会再回复一个第四次挥手的 ACK 报文 ,客户端收到后,发现并不是自己期望收到确认号 (ack num),就回 RST 报文给服务端。

在TIME_WAIT状态,收到RST会断开连接吗?
会不会断开,关键看 net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):
- 如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
- 如果这个参数设置为 1, 就会丢掉 RST 报文。
TIME_WAIT 状态收到 RST 报文而释放连接,这样等于跳过 2MSL 时间,这么做还是有风险。
个人觉得将 net.ipv4.tcp_rfc1337 设置为 1 会比较安全。
TCP连接,一端断电和进程崩溃有什么区别?
客户端主机崩溃
客户端主机崩溃了,服务端是无法感知到的 ,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。
所以,我们可以得知一个点,在没有使用 TCP 保活机制且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态,并不代表另一方的连接还一定正常。
进程崩溃
TCP 的连接信息是由内核维护的 ,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与 ,所以即使服务端的进程退出了,还是能与客户端完成 TCP四次挥手的过程。
所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。
有数据传输的场景
客户端主机宕机,又迅速重启
在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的响应,在一定时长后,服务端就会触发超时重传机制,重传未得到响应的报文。
服务端重传报文的过程中,客户端主机重启完成后,客户端的内核就会接收重传的报文,然后根据报文的信息传递给对应的进程:
- 如果客户端主机上没有 进程绑定该 TCP 报文的目标端口号,那么客户端内核就会回复 RST 报文,重置该 TCP 连接;
- 如果客户端主机上有 进程绑定该 TCP 报文的目标端口号,由于客户端主机重启后,之前的 TCP 连接的数据结构已经丢失了,客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会回复 RST 报文,重置该 TCP 连接。
所以,只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接。
客户端主机宕机,一直没有重启
这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
在 Linux 系统中,提供一个叫 tcp_retries2 配置项,默认值是 15,如下图:

这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。
不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核会根据 tcp_retries2 设置的值,计算出一个 timeout (如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms ),如果重传间隔超过这个 timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接。
在发生超时重传的过程中,每一轮的超时时间(RTO)都是倍数增长的,比如如果第一轮 RTO 是 200 毫秒,那么第二轮 RTO 是 400 毫秒,第三轮 RTO 是 800 毫秒,以此类推。
而 RTO 是基于 RTT(一个包的往返时间) 来计算的,如果 RTT 较大,那么计算出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的 timeout 值了。
举个例子,如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms,如果重传总间隔时长达到了 timeout 就会停止重传,然后就会断开 TCP 连接:
- 如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200 毫秒,由于 timeout 总时长是 924600 ms,表现出来的现象刚好就是重传了 15 次,超过了 timeout 值,从而断开 TCP 连接
- 如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000 ms,也就是第一轮的超时时间是 1 秒,那么根本不需要重传 15 次,重传总间隔就会超过 924600 ms。
补充:拔掉网线后,原本的TCP连接还存在吗?
++客户端拔掉网线后,并不会直接影响 TCP 连接状态++ 。实际上,TCP 连接在 Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。++当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变++。所以,拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。
有数据传输的情况:
- 在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。
- 在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。
没有数据传输的情况:
- 如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。
- 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。
TCP 协议有什么缺陷
升级TCP的工作很困难
因为TCP协议是在内核中实现的,想升级TCP协议只能升级内核,又由于升级内核涉及到底层软件和运行库的更新 。服务程序需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。
很多 TCP 协议的新特性,都是需要客户端和服务端同时支持才能生效的,比如 TCP Fast Open 这个特性,虽然在2013 年就被提出了,但是 Windows 很多系统版本依然不支持它,这是因为 PC 端的系统升级滞后很严重,W indows Xp 现在还有大量用户在使用,尽管它已经存在快 20 年。
所以,即使 TCP 有比较好的特性更新,也很难快速推广,用户往往要几年或者十年才能体验到。
TCP建立连接的延迟
基于 TCP 实现的应用协议,都是++需要先建立三次握手才能进行数据传输++,比如 HTTP 1.0/1.1、HTTP/2、HTTPS。现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS 四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。
TCP 三次握手的延迟被 TCP Fast Open (快速打开)这个特性解决了,但是它需要服务端和客户端的操作系统同时支持才能体验到 ,而 TCP Fast Open 是在 2013 年提出的,所以市面上依然有很多老式的操作系统不支持,而升级操作系统是很麻烦的事情,因此 TCP Fast Open 很难被普及开来 。还有一点,针对 HTTPS 来说,TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,这两个握手过程是无法结合在一起的,总是得先完成 TCP 握手,才能进行 TLS 握手。
TCP存在队头阻塞问题
TCP头阻塞问题:TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。只能等这部分数据重传后,接收方的应用层才可以从内核中读取到数据。
网络迁移需要重新建立TCP连接
当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了 ,那么就必须要断开连接,然后重新建立 TCP 连接,而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 ++TCP 慢启动++,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
补充:
-

-
TCP 慢启动(Slow Start) 是 TCP 拥塞控制机制中的一个核心算法阶段,用于在连接初始或网络恢复后,谨慎探测网络的可用带宽,避免突然发送大量数据导致网络拥塞。
如何基于UDP协议实现可靠传输?
如今市面上已经有基于UDP协议实现可靠传输协议的成熟方案了,那就是QUIC协议,已经应用在HTTP/3
QUIC如何实现可靠传输?
要基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,也就是要设计好协议的头部字段。
基于UDP实现可靠传输只能在应用层做弥补,是因为UDP为系统内核态的标准协议,开发者无权限修改其底层逻辑和固定结构,仅能在UDP数据报的应用层数据部分自定义控制字段。
拿HTTP/3举例:在UDP报文头部与HTTP消息之间,共有3层头部:


-
Packet Header 细分为:
- Long Packet Header:用于首次建立连接 (包含原连接ID 目标连接ID)
- Short Packet Header:用于日常传输数据 (包含目标连接ID,编号,负载数据)
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发生超时重传时,由于序列号相同,服务端返回的ACK是相同的,这样客户端就无法判断是原始报文的响应还是重传报文的响应,这样在计算RTT(往返时间)和RTO(超时时间,基于RTT计算)的误差较大。QUIC 报文中的 Pakcet Number 是严格递增的, 即使是重传报文,它的 Pakcet Number 也是递增的,这样就能更加精确计算出报文的 RTT。
- QUIC 使用的 Packet Number 单调递增的设计,可以让数据包不再像 TCP 那样必须有序确认,QUIC 支持乱序确认,当数据包Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动
-
QUIC Frame Header
一个Packet 报文中可以存放多个QUIC Frame,每个Frame都有明确的类型,针对类型不同,功能和格式也不同,以下是Stream类型的Frame格式:

- Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;
- Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性;
- Length 作用:指明了 Frame 数据的长度。
通过 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因某个数据包重传而阻塞后续所有待发送数据包的问题
QUIC如何解决TCP队头阻塞问题
前置知识:
-
TCP对头阻塞:当接收窗口收到的数据不是有序的,比如收到第 33~40 字节的数据,由于第 32 字节数据没有收到, 接收窗口无法向前滑动,那么即使先收到第 33~40 字节的数据,这些数据也无法被应用层读取的。只有当发送方重传了第 32 字节数据并且被接收方收到后,接收窗口才会往前滑动,然后应用层才能从内核读取第 32~40 字节的数据。
导致接收窗口的队头阻塞问题,是因为 TCP 必须按序处理数据,也就是 TCP 层为了保证数据的有序性,只有在处理完有序的数据后,滑动窗口才能往前滑动,否则就停留,停留接收窗口会使得应用层无法读取新的数据。
-
HTTP/2的队头阻塞 : HTTP/2 多个 Stream 请求都是在一条 TCP 连接上传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,那么当发生数据丢失,滑动窗口是无法往前移动的,此时就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
QUIC借鉴了HTTP/2中Stream的概念,在一条QUIC连接上可以并发发送多个HTTP请求(Stream),但是QUIC给每个Stream都分配了一个独立的滑动窗口,假如某个Stream丟了一个UDP包,也只会影响该Stream的处理,不会影响其他Stream。
QUIC是如何做流量控制的?
QUIC 实现流量控制的方式:
- 通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
- 通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
QUIC 是基于 UDP 传输的,而 UDP 没有流量控制,因此 QUIC 实现了自己的流量控制机制,QUIC 的滑动窗口滑动的条件跟 TCP 有一点差别,但是同一个 Stream 的数据也是要保证顺序的,不然无法实现可靠传输,因此同一个 Stream 的数据包丢失了,也会造成窗口无法滑动。
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别
Stream级别的流量控制

可以看到,接收窗口的左边界取决于接收到的最大偏移字节数 ,此时的接收窗口 = 最大窗口数 - 接收到的最大偏移数。
这里就可以看出 QUIC 的流量控制和 TCP 有点区别了:
- TCP 的接收窗口只有在前面所有的 Segment 都接收的情况下才会移动左边界,当在前面还有字节未接收但收到后面字节的情况下,窗口也不会移动。
- QUIC 的接收窗口的左边界滑动条件取决于接收到的最大偏移字节数。
- QUIC的接收窗口的右边界滑动条件:图中绿色部分数据超过最大接收窗口的一半后,最大接收窗口向右移动,接收窗口的右边界也向右扩展,同时告诉对端 ; 如果中途丢失了数据包,导致绿色部分的数据没有超过最大接收窗口的一半,那接收窗口就无法滑动了, 这个只影响同一个 Stream,其他 Stream 是不会影响的,因为每个 Stream 都有各自的滑动窗口。
Collection 流量控制
对于 Connection 级别的流量窗口,其接收窗口大小就是各个 Stream 接收窗口大小之和。
QUIC对拥塞控制的改进
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。
TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。但是因为 QUIC 处于应用层,所以就可以针对不同的应用设置不同的拥塞控制算法,这样灵活性就很高了。
QUIC更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话服用,也需要至少 2 个 RTT。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的"记录",再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):

QUIC如何迁移连接
QUIC通过连接ID标记通信的两个端点,客户端和服务器可以各自选择一组ID标记自己,即使网络变化后,只要仍保有上下文信息(比如连接ID,TLS密钥等),就可以复用原连接,消除重连的成本.
TCP和UDP可以同时绑定相同的端口吗?
监听 这个动作是在TCP才具有的,而UDP中没有这个动作
答案是:可以的!
传输层端口号的作用是为了区分同一主机上不同应用程序的数据包 ,TCP和UDP传输协议在内核中是两个完全独立的软件模块实现的,当主机收到数据包后。可以在IP包头的协议号字段知道该数据包是TCP还是UDP,然后确定送往哪个模块处理,送给TCP/UDP模块的报文根据端口号确定送给哪个应用程序处理。
补充 :
多个TCP服务进程可以同时绑定同一个端口号吗?
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同 ,那么执行 bind() 时候就会出错,错误是"Address already in use"。如果两个 TCP 服务进程绑定的端口都相同,而 IP 地址不同,那么执行 bind() 不会出错。
如何解决服务端重启时,报错"Address already in use"的问题?
当我们重启 TCP 服务进程的时候,意味着服务器端发起了关闭连接操作 ,于是就会经过四次挥手,而对于主动关闭方 ,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。要解决这个问题,我们可以对 socket 设置 SO_REUSEADDR 属性。这样即使存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,依然可以正常绑定成功,因此可以正常重启成功。
客户端的端口可以重复使用吗?
在客户端执行 connect 函数的时候,只要客户端连接的服务器不是同一个,内核允许端口重复使用。TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。
客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?
要看客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。
如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。即使在这种状态下,还是可以与其他服务器建立连接的,只要客户端连接的服务器不是同一个,那么端口是重复使用的。
如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?
打开 net.ipv4.tcp_tw_reuse 这个内核参数。
因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态。
如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。
服务端没有listen,客户端发起连接建立,会发生什么?
服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。
解析:
- Linux 内核处理收到 TCP 报文的入口函数是 tcp_v4_rcv,在收到 TCP 报文后,会调用 __inet_lookup_skb 函数找到 TCP 报文所属 socket 。
- __inet_lookup_skb 函数首先查找连接建立状态的socket(__inet_lookup_established),在没有命中的情况下,才会查找监听套接口(__inet_lookup_listener)。
- 查找监听套接口(inet_lookup_listener)这个函数的实现是,根据目的地址和目的端口算出一个哈希值,然后在哈希表找到对应监听该端口的 socket。
- 服务端是没有调用 listen 函数的,所以自然也是找不到监听该端口的 socket 。
所以,__inet_lookup_skb 函数最终找不到对应的 socket,于是跳转到no_tcp_socket。
没有listen,能建立TCP连接吗?
Answer :可以,客户端可以自己连自己的形成连接(TCP自连接) ,也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与。也就还没有listen,就能建立连接。
内核中还有个全局hash表,可以用于存放连接信息
- TCP自连接 :客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。
- 两个客户端 :TCP 中,
connect()会先在内核创建并登记一条连接记录(四元组:本地 IP/端口 + 对端 IP/端口),再发送 SYN。无论是 TCP 自连接还是 TCP 同时打开,SYN 经回环或网络返回时,内核都会用该四元组在全局 hash 表中命中这条已存在的连接;如果连接处于SYN_SENT状态却收到对端的 SYN,TCP 状态机会按规则进入SYN_RECEIVED并回复SYN+ACK,最终双方在无需listen()的情况下完成三次握手并建立连接。
没有accept,能建立TCP连接吗?
答案 :就算不执行accept()方法,三次握手照常进行,并顺利建立连接。在服务端执行accept()前,如果客户端发送消息给服务端,服务端是能够正常回复ack确认包的。建立连接的过程中根本不需要accept()参与, 执行accept()只是为了从全连接队列里取出一条连接。
为什么半连接队列要设计成哈希表?
- 全连接队列本质是个链表,服务端取走连接时不关心具体是哪个连接,只要直接从队头取走就行。这个过程算法复杂度O(1)
- 半连接队列由于队列中都是不完整的连接,假设现在有个第三次握手来了,要取出对应的连接,如果是链表需要一次遍历,而设计成哈希表算法复杂度是O(1);
Cookies方案为什么不直接取代半连接队列?
目前看下来syn cookies方案省下了半连接队列所需要的队列内存,还能解决 SYN Flood攻击,那为什么不直接取代半连接队列?
凡事皆有利弊,cookies方案虽然能防 SYN Flood攻击 (攻击方模拟客户端疯狂发第一次握手请求过来,在服务端憨憨地回复第二次握手过去之后,客户端死活不发第三次握手过来,这样做,可以把服务端半连接队列打满,从而导致正常连接不能正常进来 ),但是也有一些问题。因为服务端并不会保存连接信息,所以如果传输过程中数据包丢了,也不会重发第二次握手的信息。
另外,编码解码cookies,都是比较耗CPU 的,利用这一点,如果此时攻击者构造大量的第三次握手包(ACK包) ,同时带上各种瞎编的cookies信息,服务端收到ACK包后以为是正经cookies ,憨憨地跑去解码(耗CPU),最后发现不是正经数据包后才丢弃。
这种通过构造大量ACK包去消耗服务端资源的攻击,叫ACK攻击 ,受到攻击的服务器可能会因为CPU资源耗尽导致没能响应正经请求。
用了TCP协议,数据一定不会丢吗?
TCP保证的可靠性,是传输层的可靠性 。只保证数据从A的传输层可靠地发到B的传输层 。至于能不能从传输层到应用层,TCP并不管。A的消息传到B的传输层TCP协议的接收缓冲区,中间不管是否丢包都可以通过重传保证消息到达,此时接收端回复一个ack,发送端收到后将自己发送缓冲区的消息扔掉,到这里TCP任务就结束了。
举个例子 :此时,聊天软件还需要将数据从TCP的接收缓冲区里读出来,如果在读出来这一刻,手机由于内存不足或其他各种原因,导致软件崩溃闪退了。 发送端以为自己发的消息已经发给对方了,但接收端却并没有收到这条消息。于是,消息就丢了。
解决方法:有时候我们在手机里聊了一大堆内容,然后登录电脑版,它能将最近的聊天记录都同步到电脑版上。也就是说服务器可能 记录了我们最近发过什么数据,假设每条消息都有个id ,服务器和聊天软件每次都拿最新消息的id 进行对比,就能知道两端消息是否一致,就像对账 一样。对于发送方 ,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。如果接收方的聊天软件崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,同步上来就是了,所以也不存在上面提到的丢包情况。
可以看出,TCP只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。
那么问题叒来了,两端通信的时候也能对账,为什么还要引入第三端服务器?
主要有三个原因。
- 第一,如果是两端通信,你聊天软件里有
1000个好友,你就得建立1000个连接。但如果引入服务端,你只需要跟服务器建立1个连接就够了,聊天软件消耗的资源越少,手机就越省电。 - 第二,就是安全问题 ,如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种鉴权校验。
- 第三,是软件版本问题 。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,且两端的软件版本跨度太大,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本升级,否则不能使用软件。但对于大部分兼容性问题,给服务端加兼容逻辑就好了,不需要强制用户更新软件。
TCP四次挥手,可以变成三次挥手吗?
前提须知:
Q: 什么是TCP延迟确认机制?
A : 当发送没有携带数据的ACK,由于它也有IP头和TCP头,却没有数据,所以它的网络效率很低,于是诞生了TCP延迟确认:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
当被动关闭方在 TCP 挥手过程中,如果没有数据要发送 ,同时没有开启 TCP_QUICKACK(默认情况就是没有开启,没有开启 TCP_QUICKACK,等于就是在使用 TCP 延迟确认机制) ,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
所以,出现三次挥手现象,是因为 TCP 延迟确认机制导致的。
TCP序列号和确认号的变化
根据经验总结万能公式:
发送的 TCP 报文:
- 公式一:序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 上一次发送的序列号 + 1。
- 公式二:确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。
为什么第二次和第三次握手报文的确认号是将对方的序列号+1后作为确认号呢?
SYN 报文是特殊的 TCP 报文,用于建立连接时使用,虽然 SYN 报文不携带用户数据,但是 TCP 将 SYN 报文视为 1 字节的数据,当对方收到了 SYN 报文后,在回复 ACK 报文时,就需要将 ACK 报文中的确认号设置为 SYN 的序列号 + 1 ,这样做是有两个目的:
- 告诉对方,我方已经收到 SYN 报文。
- 告诉对方,我方下一次
期望收到的报文的序列号为此确认号,比如客户端与服务端完成三次握手之后,服务端接下来期望收到的是序列号为 client_isn + 1 的 TCP 数据报文。
如果客户端发送的第三次握手ACK报文丢了,处于SYN_RCVD状态的服务端收到了客户端的第一个TCP数据报文会发生什么?
客户端与服务端完成 TCP 三次握手后,发送的第一个 TCP 数据报文的序列号和确认号都是和第三次握手的 ACK 报文中序列号和确认号一样的。
- 序列号设置为 client_isn + 1。客户端上一次发送报文是 ACK 报文(第三次握手),该报文的 seq = client_isn + 1,由于是一个单纯的 ACK 报文,没有携带用户数据,所以 len = 0。根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 client_isn + 1 + 0,即 client_isn + 1。
- 确认号设置为 server_isn + 1。没错,还是和第三次握手的 ACK 报文的确认号一样,这是因为客户端三次握手之后,发送 TCP 数据报文 之前,如果没有收到服务端的 TCP 数据报文,确认号还是延用上一次的,其实根据公式 2 你也能得到这个结论。
Answer : 发送的第一个 TCP 数据报文的序列号和确认号 都是和第三次握手的 ACK 报文中序列号和确认号一样的 ,并且该 TCP 数据报文也有将 ACK 标记位置为 1。所以,服务端收到这个数据报文,是可以正常完成连接的建立,然后就可以正常接收这个数据包了。