☰
TCP三次握手与四次挥手:从状态机到线上排障实战
2026/10/27 5:15:12 网站建设 项目流程

聊网络问题,很少有人能绕开 TCP 三次握手和四次挥手这两件事。我自己刚入行那会儿,看状态机图觉得挺简单,三个箭头四个箭头,背下来考试能过。结果真到线上环境,一个"连不上"的问题摆在面前,抓包一看 SYN 发出去了、对面就是没回 SYN+ACK,我当场懵住——书上没讲这种情况该从哪儿下手。后来处理得多了才慢慢明白,握手挥手真正的价值不在那几张图,而在它把"连接"这件事拆成了一串可以观测、可以验证、可以定位的状态。TCP 三次握手决定了连接能不能建立、建立时双方确认了哪些能力;TCP 四次挥手决定了连接怎么体面地收场,以及为什么你的服务器上会有一堆 TIME_WAIT 和 CLOSE_WAIT。这两个过程串起来,就是你排查绝大多数网络连通性问题的基本坐标系。这篇文章写给两类人:一类是准备面试、需要把原理讲清楚而不是背答案的朋友;另一类是已经在一线干活、但每次遇到连接异常还得靠重启试试的同行。我会把报文级的细节、抓包实操、参数调优和真实排障思路都摊开讲,最后附上一批面试里高频出现的问题和我的答法,你可以直接拿去对照。

1. 先搞清楚这两件事在工程里卡在哪个环节

1.1 从一个"服务连不上"的现场说起

我印象最深的一次,是某个业务方反馈调用后端接口偶发超时,比例不高,大概千分之几,但很烦人。第一反应是查应用日志,日志里只有一句 connection timeout,什么信息都没有。于是上机器抓包,看到的现象是:客户端连续发了三个 SYN,间隔分别是 1 秒、2 秒,然后放弃。服务端那边对应的端口上,一个包都没收到。这个结论一下子把排查范围从"应用层慢"缩小到了"链路或者中间设备丢包",后面顺着防火墙策略变更记录一查,果然是某次规则调整误伤了新扩容的机器网段。

这件事让我形成一个习惯:遇到连接类问题,先别猜,先看握手走到了哪一步。握手走到哪一步,问题就落在哪一段,这是 TCP 设计给排查工作留下的最大便利。SYN 没发出去,问题在客户端或者本地路由;SYN 发出去了没有 SYN+ACK,问题在中间链路、对端监听或者对端背压队列;SYN+ACK 回来了但客户端没回最后的 ACK,问题多半在客户端内核或者本地策略。这条链路一画出来,排查方向就不会散。

1.2 TCP 到底在替应用层扛什么事

要理解握手挥手,得先知道 TCP 承诺了什么。它承诺的是:你交给它的字节流,对面收到的顺序和你发出去的顺序一致,不会丢,不会重复,不会中间插进来别人的数据。而底层链路,无论是物理线路还是虚拟网络,本质上都是不可靠的——包会丢、会乱序、会重复、会延迟。

TCP 做的所有事情,就是在这条不可靠的链路上,靠双方各自维护一份状态,把可靠性"演"出来。而状态这个东西有个前提:双方必须对"现在处于什么阶段"达成一致。握手就是建立这份共识的过程,挥手就是解除这份共识的过程。你把这个前提想通了,后面所有细节都是自然推论——为什么需要序列号、为什么需要确认号、为什么断开要四次、为什么最后要等一段时间。

我用一个生活化的类比:两个人打电话谈合作。握手相当于"喂,你能听到吗"、"能听到,你能听到我吗"、"我也能听到,那我们开始",三次确认是因为双方都要验证"我发得出去"和"我收得到"这两个方向。挥手相当于"我这边说完了"、"好,我知道了,但我这边还有两句"、"那我也说完了"、"行,挂了"。多出来的那一次,是因为一方说完不代表另一方也说完了。

1.3 六个状态先混个脸熟

在看具体流程之前,先把状态名过一遍,不然后面看抓包会晕。客户端侧主要经历 CLOSED、SYN_SENT、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT;服务端侧主要经历 CLOSED、LISTEN、SYN_RCVD、ESTABLISHED、CLOSE_WAIT、LAST_ACK。这些名字你在ss或者netstat的输出里天天见,但很多人只知道 ESTABLISHED 是正常的、CLOSE_WAIT 是异常的,至于为什么异常、异常在哪一侧,说不清楚。

我建议的做法是:拿一张纸,把客户端和服务端两列画出来,然后把三次握手和四次挥手的箭头画上去,每个箭头旁边标注状态变化和报文标志位。自己画一遍,比看十遍图有用。我当年就是这么干的,画完之后再看ss -ant的输出,那些状态名一下子就有了位置感。

2. 三次握手:把每一步拆到字段级别

2.1 报文头里真正起作用的就那么几个字段

TCP 头部固定 20 字节,加选项最多 60 字节。字段一大堆,但握手阶段真正在起作用的主要是这几个:源端口和目的端口(决定这条连接属于谁)、序列号 seq(本次发送的字节流起点)、确认号 ack(我期待你下次从哪个字节开始发)、标志位(SYN、ACK、FIN、RST 这几个是关键)、窗口大小(我这边还能收多少)、以及选项里的 MSS(我能接受的最大报文段长度)和窗口扩大因子。

这里我想强调一个容易被忽略的点:握手不只是"打个招呼",它还在协商能力。MSS 在 SYN 里告诉对方"你别发超过这个大小的段",窗口扩大因子告诉对方"我的接收窗口要乘以这个系数"。这意味着,如果两端 MSS 协商得不一致,或者中间设备把 MSS 改小了,后续数据传输的性能会直接受影响。我在排查过一个吞吐量只有预期三成的问题,最后发现是某段链路上的设备把 MSS 强制压到了很小的值,握手阶段就已经注定性能上不去了。

2.2 第一次握手:客户端把 SYN 发出去

客户端调用 connect 之后,内核做几件事:分配一个本地端口(如果没指定)、初始化序列号 ISN、把 SYN 标志位置 1、填入 MSS 等选项,然后发出去,状态从 CLOSED 变成 SYN_SENT。这个 ISN 不是从 1 开始,也不是随机数那么简单,现代实现里它是一个基于时钟和四元组哈希算出来的值,目的是防止旧连接的报文混进新连接,同时也增加序列号被猜中的难度。

这里有个实操细节:如果客户端在 SYN_SENT 状态停留很久,说明 SYN 发出去了但没有收到回应。内核会按指数退避重传,重传次数由net.ipv4.tcp_syn_retries控制,默认是 6 次,累计时间大约 127 秒。也就是说,一个连不上的地址,你的应用可能要卡两分多钟才返回错误。如果你的业务对超时敏感,这个值是需要调整的,或者干脆在应用层设置更短的连接超时。

2.3 第二次握手:服务端回 SYN+ACK

服务端收到 SYN,如果对应端口处于 LISTEN 状态且半连接队列还有空间,就会做这几件事:为该连接创建一个请求块放进半连接队列(SYN 队列),生成自己的 ISN,把 SYN 和 ACK 都置 1,ack 填成客户端的 seq+1,发回去,状态从 LISTEN 变成 SYN_RCVD。

这一步是问题高发区。如果服务端的半连接队列满了,新来的 SYN 会被丢弃,客户端只能重传,表现就是连接慢、偶发失败。相关参数是net.ipv4.tcp_max_syn_backlog,以及应用层 listen 时传入的 backlog(最终生效的是二者较小值,还受somaxconn限制)。我遇到过一次突发流量下大量连接超时,查下来是 listen backlog 设得太小,半连接队列被打满,SYN 被丢弃。改成通过配置中心动态调整之后问题消失。

2.4 第三次握手:客户端回 ACK,连接建立

客户端收到 SYN+ACK,校验 ack 是不是自己 seq+1,通过之后把状态改成 ESTABLISHED,同时发出最后一个 ACK(这个 ACK 可以携带数据)。服务端收到这个 ACK,把连接从半连接队列移到全连接队列(accept 队列),状态变成 ESTABLISHED。

注意这里有个经典现象:如果服务端应用迟迟不调用 accept,全连接队列会满,满了之后新完成的连接会被丢弃或者被拒绝,具体行为取决于net.ipv4.tcp_abort_on_overflow的设置。默认是 0,也就是静默丢弃,客户端看到的是连接建立了、发数据没响应,非常迷惑。这也是为什么我一直建议把 accept 队列长度和应用的并发处理能力对齐,并且监控队列溢出计数。

2.5 为什么必须三次,两次到底会出什么问题

面试最爱问这个。标准答案说"为了防止已失效的连接请求报文段突然又传送到服务端",这句话本身没错,但很多人背完并不理解场景。我换个说法你可能一下就通了。

假设只有两次握手:客户端发 SYN,服务端回 SYN+ACK,双方就认为连接建立。现在考虑一个情况:客户端发了一个 SYN,因为网络延迟,这个 SYN 在网络里滞留了很久。客户端等不到回应,超时放弃了。过了一段时间,这个滞留的 SYN 突然到达服务端,服务端按两次握手的逻辑,直接认为连接建立了,开始分配资源等着收数据。而客户端这边压根没想建立这个连接,收到 SYN+ACK 也会当成无效包丢掉。结果就是服务端白白维持了一个永远不会有人用的连接,资源被浪费。如果这种滞留包多来几个,服务端资源就被吃光了。

三次握手的价值就在这里:服务端在收到第三个 ACK 之前,不确定客户端的接收能力和意愿,所以不分配完整资源。第三个 ACK 相当于客户端的"我确实要建这个连接"的最终确认。除此之外还有一层:三次握手让双方都确认了对方的发送能力和接收能力。第一次让服务端知道客户端能发;第二次让客户端知道服务端能收能发;第三次让服务端知道客户端能收。三个方向的信息,少一次就少一个确认。

还有个更本质的原因:TCP 是全双工的,双方各自维护独立的序列号空间。要同步两个方向的初始序列号,最少需要三次交互。这个角度比"防止旧报文"更能说明为什么是三次而不是别的数字。

3. 四次挥手:多出来的那一次不是浪费

3.1 半关闭状态:FIN 到底意味着什么

很多人把 FIN 理解成"连接关闭",这是不准确的。FIN 的准确含义是"我这边没有数据要发了",也就是关闭了我这个方向的发送通道。但对方那个方向的通道还是开着的,它还可以继续给我发数据。这个状态叫半关闭(half-close)。

理解这一点,四次挥手就顺理成章了。因为 TCP 是全双工的,两个方向要分别关闭,所以需要两次 FIN 和两次 ACK,加起来四次。如果某一方收到 FIN 时正好也没有数据要发,它可以把 ACK 和自己的 FIN 合并成一个报文发出去,这时候看起来是三次,但本质还是四次,只是合并了。你在抓包时会看到这种情况,不要以为协议变了。

半关闭在实际开发里挺有用。比如客户端发完请求后调 shutdown(SHUT_WR) 只关写方向,然后继续读服务端的响应,这样能明确告诉服务端"我的请求发完了",服务端不用靠解析报文长度来判断请求边界。这是 HTTP 早期版本常用的一种做法,现在用得少了,但理解它对理解 FIN 的语义很有帮助。

3.2 四次挥手的完整流程

主动关闭方(假设是客户端)调用 close,内核发一个 FIN,seq 是当前序列号,状态从 ESTABLISHED 变成 FIN_WAIT_1。被动关闭方(服务端)收到 FIN,回一个 ACK,状态变成 CLOSE_WAIT,此时客户端收到 ACK 进入 FIN_WAIT_2。这时候服务端还可以继续发数据,客户端也还能收——这就是半关闭。

等服务端应用也调用 close,它发一个 FIN,状态从 CLOSE_WAIT 变成 LAST_ACK。客户端收到 FIN,回 ACK,状态从 FIN_WAIT_2 变成 TIME_WAIT,等待 2MSL 后变成 CLOSED。服务端收到这个 ACK,状态从 LAST_ACK 变成 CLOSED。

这里有个关键点必须说清楚:CLOSE_WAIT 出现在被动关闭方,它的成因是应用层没有调用 close。也就是说,如果一个服务端上堆了大量 CLOSE_WAIT,问题一定在你的代码里——要么是连接池没有正确释放,要么是异常路径上漏了关闭,要么是某个地方阻塞住了导致没法走到关闭逻辑。这跟内核参数一点关系都没有,调参是治不好的。我看到过不少团队遇到 CLOSE_WAIT 堆积就去改tcp_fin_timeout,那是白费功夫,那个参数管的是 FIN_WAIT_2 状态,跟 CLOSE_WAIT 完全是两码事。

3.3 TIME_WAIT 为什么必须等 2MSL

主动关闭方最后进入 TIME_WAIT,等待 2MSL(Maximum Segment Lifetime,报文最大生存时间)才真正关闭。Linux 上 MSL 固定为 60 秒,所以是 120 秒。为什么要等,两个原因。

第一个原因,保证最后一个 ACK 能到达对方。如果这个 ACK 丢了,对方会重传 FIN,处在 TIME_WAIT 的一方还能再回一个 ACK。如果直接关掉了,对方重传的 FIN 会收到 RST,对方会认为连接异常终止。这在实际场景里会造成什么问题呢?如果对方还有数据在缓冲区里没处理完,收到 RST 会直接丢弃,数据就丢了。

第二个原因,让这条连接上残留的旧报文在网络里自然消亡。因为四元组(源 IP、源端口、目的 IP、目的端口)相同的新连接可能复用这个端口,如果有旧报文还在网络里游荡,混进新连接就会造成数据错乱。等 2MSL 就是给这些报文足够的时间死掉。

TIME_WAIT 本身不是问题,它是协议的正常行为。真正的问题是高并发短连接场景下,TIME_WAIT 大量堆积占用端口,导致新连接无法分配端口。这时候才需要干预。常见的干预手段有几个,我在第 5 节详细说,但先给个态度:能改成长连接就改长连接,能从设计上减少短连接就减少,参数调整是最后手段。

3.4 状态迁移的完整对照表

把两个方向的状态整理成表,方便对照:

阶段主动关闭方状态被动关闭方状态关键动作
发起关闭FIN_WAIT_1ESTABLISHED主动方发 FIN
对方确认FIN_WAIT_2CLOSE_WAIT被动方回 ACK,应用层待关闭
被动方关闭FIN_WAIT_2LAST_ACK被动方发 FIN
最终确认TIME_WAITCLOSED主动方回 ACK
等待结束CLOSEDCLOSED2MSL 超时

看着表排查会快很多:看到 FIN_WAIT_1 堆积,说明主动发起的 FIN 没收到 ACK,通常是对方没响应或者链路问题;看到 FIN_WAIT_2 堆积,说明对方迟迟不发 FIN,通常是对端应用不关连接;看到 CLOSE_WAIT 堆积,本端应用没调 close;看到 LAST_ACK 堆积,说明最后的 ACK 没收到,一般是网络问题或者对端已经异常退出。

4. 抓包实操:让理论在屏幕上跑一遍

4.1 环境与工具准备

理论讲完了,不上手等于没学。准备两台机器,一台当服务端,一台当客户端,或者干脆本地起一个服务端用 127.0.0.1 也行,本地回环抓包最省事,不受中间设备干扰。工具方面,抓包用 Wireshark 的图形界面最直观,服务器上没有图形环境就用 tcpdump 抓文件,下载回来用 Wireshark 打开。

服务端我用 Python 起一个最简单的监听,几行代码的事,方便控制什么时候 accept、什么时候关闭:

import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('0.0.0.0', 9000)) s.listen(5) while True: conn, addr = s.accept() print('accepted', addr) conn.recv(1024) conn.close()

客户端就用 curl 或者 telnet 连一下,触发完整的建立和关闭流程。抓包命令我一般这么写:

tcpdump -i any -nn -s 0 'tcp port 9000' -w handshake.pcap

-s 0表示抓完整包不截断,-nn表示不做域名和端口解析,能快一点。想直接在终端看,去掉-w加-A或者不加都行,但我强烈建议存文件,因为分析要看字段,终端里看太累。

4.2 抓包文件里该重点看什么

打开 pcap,先加过滤tcp.port == 9000,然后看前三个包。Wireshark 会自动识别出握手过程,在 Info 列显示 [SYN]、[SYN, ACK]、[ACK]。点开第一个包,展开 TCP 层,重点看这几个字段:Sequence Number(原始序列号)、Acknowledgment Number(此时应该是 0,因为 ACK 标志没置位)、Flags(0x002 表示 SYN)、Options 里的 Maximum Segment Size 和 Window Scale。

第二个包里,Sequence Number 是服务端自己生成的,Acknowledgment Number 等于第一个包的 seq 加 1,Flags 是 0x012(SYN+ACK)。第三个包 Acknowledgment Number 等于第二个包的 seq 加 1,Flags 是 0x010(纯 ACK)。

这个"加 1"是有讲究的:SYN 和 FIN 这两个标志各占用一个序列号,虽然它们不携带实际数据。所以确认号要加 1。很多人算序列号时忘了这一点,对不上号。数据报文里,确认号加的是实际数据长度,不是固定加 1,这个区别要分清。

挥手过程同理,看 FIN 包和对应的 ACK,注意 FIN 也占一个序列号。如果你在抓包里看到 FIN 之后对方又发了数据,不用惊讶,这就是半关闭,对方在关闭自己方向之前还可以发。

4.3 用系统命令看状态机

抓包看的是报文,看状态得用系统命令。ss -ant是我最常用的,比 netstat 快很多,尤其在连接数多的时候。常用组合:

ss -ant state time-wait | wc -l ss -ant state close-wait ss -s

ss -s会给一个汇总,各状态的数量一目了然。排查时我一般先看ss -s定方向,再针对具体状态过滤详细看。比如看到 close-wait 数量几百,直接ss -ant state close-wait看是哪些进程,配合ss -antp能看到进程名,一下就定位到是哪个应用没关连接。

这里提醒一个细节:ss显示的 Recv-Q 和 Send-Q 在 LISTEN 状态下含义特殊,Recv-Q 是当前全连接队列里等待 accept 的连接数,Send-Q 是队列上限。这个用法很多人不知道,但在排查 accept 队列满的问题时特别好用,一眼就能看出队列是不是打满了。

4.4 内核参数的取舍逻辑

调参之前得知道每个参数管什么,不然容易改了没效果还埋雷。我列几个常用的:

参数默认值作用调整建议
net.ipv4.tcp_syn_retries6客户端 SYN 重传次数内网可降到 3,缩短失败感知时间
net.ipv4.tcp_synack_retries5服务端 SYN+ACK 重传次数一般保持默认
net.ipv4.tcp_max_syn_backlog1024 或更大半连接队列上限高并发短连接场景可调大
net.core.somaxconn4096全连接队列上限与应用 listen backlog 对齐
net.ipv4.tcp_fin_timeout60FIN_WAIT_2 超时一般不动,它不管 TIME_WAIT
net.ipv4.tcp_tw_reuse0允许复用 TIME_WAIT 连接客户端侧可开启,服务端慎用
net.ipv4.ip_local_port_range32768-60999本地端口范围短连接多的客户端可扩大

tcp_tw_reuse这个参数我要多说两句。它只对主动发起连接的一方有效,作用是允许把处于 TIME_WAIT 且时间超过 1 秒的连接复用于新的对外连接。对客户端来说通常是安全的,因为新连接的序列号比旧连接大,不会混淆。但服务端不要随便开,服务端是被动接受连接,复用的语义不一样。至于tcp_tw_recycle,在新版本内核里已经被移除,老系统上也强烈不建议开,它基于时间戳判断,在 NAT 环境下会造成大量连接被误丢,这个坑踩过的人不少。

5. 线上高频故障与排查实录

5.1 握手卡在 SYN_SENT:包去哪了

现象是应用报连接超时,机器上ss -ant state syn-sent能看到一堆连接卡着。排查路径是这样的:先确认 SYN 到底发出去没有。用tcpdump在客户端机器上抓,如果能看到 SYN 发出但没看到 SYN+ACK,说明问题在对端或者中间链路。这时候再在对端抓,如果对端也没收到,那就是中间设备的问题——安全组规则、负载均衡健康检查、路由配置、甚至 MTU 不匹配导致的大包被丢。

我遇到过一个特别隐蔽的案例:客户端和服务端网络都通,但就是握手失败。抓包发现 SYN 到了服务端,服务端也回了 SYN+ACK,但客户端收不到。最后定位是中间某台设备上做了会话保持,把返回的包导向了另一条路径,而那条路径上的策略没放通。这种问题只能靠逐跳抓包定位,没有别的捷径。

还有一种情况是 SYN 发出去了,对方回了 RST。RST 意味着对方明确拒绝,通常是对应端口没有监听,或者被防火墙配置成了 reject 而不是 drop。如果服务端确实在监听,那就要检查是不是有中间设备在伪造 RST,这种情况在链路质量差的时候偶有出现。

5.2 TIME_WAIT 太多导致端口不够用

短连接高并发的场景下,客户端侧会出现大量 TIME_WAIT,本地端口被占满,新连接报 "Cannot assign requested address"。这个错误的字面意思很有迷惑性,看起来像地址配置问题,实际是端口耗尽。

解决思路按优先级排:第一,能不能改成长连接或者连接池,这是治本的;第二,如果不能改,扩大本地端口范围,ip_local_port_range可以调到 1024-65535;第三,开启tcp_tw_reuse,让过期的 TIME_WAIT 可以被复用;第四,如果对端支持,用SO_LINGER设置为 0 让 close 直接发 RST,跳过 TIME_WAIT——但这招副作用很大,会造成对方收到 RST 丢弃未处理数据,只有在明确知道后果的情况下才能用,我一般不建议。

这里我要强调一个认知:TIME_WAIT 出现在客户端还是服务端,含义完全不同。出现在客户端是正常的,因为客户端主动关闭;出现在服务端,说明是服务端主动关闭了连接,这时候要反思为什么服务端要先关——通常是服务端的空闲连接超时设置比客户端短,导致服务端先动手。把两边的超时时间对齐,让客户端先关,TIME_WAIT 就回到客户端去了,服务端的压力就小了。这是个很实用的调整,成本低效果好。

5.3 连接看着正常但一发数据就断

这种情况握手挥手都正常,但传输过程中连接被重置。最常见的原因是中间设备(负载均衡、防火墙、NAT 网关)维护了会话表,一段时间没有流量就把表项删了,之后再来的包找不到对应会话,直接回 RST 或者静默丢弃。表现就是长连接空闲一段时间后第一次请求失败,重试就好了。

应对办法有两个方向:一是在应用层加心跳,定期发一个小包维持会话表项,间隔要小于中间设备的空闲超时时间,通常设 30 秒到 60 秒比较稳妥;二是开启 TCP keepalive,把tcp_keepalive_time调小。但我更推荐应用层心跳,因为 keepalive 的探测包很小,有些设备不认,而且默认两小时才发第一次,完全来不及。

还有一个隐蔽的原因是 MTU 问题。握手阶段报文都很小,能正常通过,一旦开始传大数据,超过路径 MTU 的包被丢弃,且 ICMP 的"需要分片"消息被中间设备拦掉,就会表现为小请求正常、大请求卡死。排查方法是用ping -M do -s逐步加大包大小探测路径 MTU,或者在抓包里看是否有大量重传。

5.4 常见问题速查表

现象大概率原因排查命令处理方向
连接超时,SYN 无响应链路不通、策略拦截、对端未监听tcpdump 逐跳抓包检查安全策略与监听状态
连接被 RST端口未监听、中间设备伪造 RSTss -antl 看监听确认服务状态与链路设备
CLOSE_WAIT 大量堆积应用未调用 closess -antp state close-wait检查代码资源释放逻辑
FIN_WAIT_2 堆积对端不发 FINss -ant state fin-wait-2检查对端应用关闭逻辑
端口耗尽短连接过多、TIME_WAIT 占满ss -s 看 time-wait 数量改长连接、扩端口范围
accept 队列满应用处理慢或 backlog 太小ss -antl 看 Recv-Q调整 backlog、优化处理速度
长连接空闲后断开中间设备会话超时抓包看首次失败时间点加应用层心跳

这张表我基本上是背下来的,实际排查时对照着走,能省很多时间。但表只能给方向,具体原因还得靠抓包确认,这一点千万别偷懒。

6. 面试题带答案:从背题到答出原理

6.1 基础题:把流程讲清楚就够了

问:简述三次握手的过程。

答:客户端发送 SYN 报文,携带自己的初始序列号 x,进入 SYN_SENT;服务端收到后回复 SYN+ACK,携带自己的初始序列号 y,确认号为 x+1,进入 SYN_RCVD;客户端收到后回复 ACK,确认号为 y+1,进入 ESTABLISHED;服务端收到 ACK 后也进入 ESTABLISHED。注意 SYN 和 FIN 各占一个序列号,所以确认号要加 1。

问:为什么是三次而不是两次?

答:三个层面。第一,全双工需要同步两个方向的初始序列号,三次是最少交互次数。第二,防止历史滞留的 SYN 导致服务端建立无效连接、浪费资源。第三,三次握手让双方都确认了对方的发送能力和接收能力,四个方向的验证在三次交互里刚好完成。

问:为什么断开要四次?

答:因为 TCP 是全双工的,两个方向的关闭要分别进行。主动方发 FIN 只表示自己没有数据要发了,被动方还要把剩余数据发完才能发自己的 FIN,中间必须有一次独立的 ACK。如果被动方恰好没有数据要发,ACK 和 FIN 可以合并,看起来是三次,但协议语义上仍然是四次。

6.2 进阶题:考的是你有没有真排查过问题

问:服务器上出现大量 CLOSE_WAIT,怎么排查?

答:CLOSE_WAIT 出现在被动关闭方,含义是收到了对方的 FIN 并回了 ACK,但本端应用还没调用 close。所以原因一定在应用层,跟内核参数无关。排查步骤:先用ss -antp state close-wait找到对应进程,确认是哪个应用;再结合代码检查连接释放逻辑,重点看异常路径、超时路径、连接池归还逻辑是否有遗漏;如果是用了第三方客户端库,检查是否有连接泄漏。常见的坑包括:拿到连接后抛异常没在 finally 里关闭、连接池配置的最大连接数不合理导致借出的连接一直不还、以及某个地方阻塞导致走不到关闭逻辑。

问:TIME_WAIT 为什么是 2MSL 而不是 1MSL?

答:MSL 是报文在网络中的最大生存时间。等 2MSL 是为了覆盖一个往返:主动方发出的最后一个 ACK 最多经过 1MSL 到达对端,对端如果没收到会重传 FIN,这个重传的 FIN 最多再经过 1MSL 回到主动方。等 2MSL 能保证在这个往返窗口内,主动方都还能响应对端的重传。同时,2MSL 也确保这条连接上的所有旧报文都已经从网络中消失,不会干扰复用相同四元组的新连接。

问:TIME_WAIT 过多怎么优化?

答:分场景。如果 TIME_WAIT 在客户端,先看能不能改长连接或者用连接池;不能改的话扩大本地端口范围,开启 tcp_tw_reuse。如果 TIME_WAIT 在服务端,说明服务端主动关闭了连接,应该调整服务端的空闲超时时间,让它比客户端的超时更长,把关闭动作交给客户端。绝对不要用 tcp_tw_recycle,新内核已经移除,老内核上开了在 NAT 环境会造成严重问题。

6.3 陷阱题:专门用来分辨背题和真懂

问:第三次握手可以携带数据吗?

答:可以。第三次握手的 ACK 报文,客户端已经进入 ESTABLISHED 状态,此时携带数据是允许的,服务端收到后会正常处理。但前两次握手不能携带数据,因为此时连接还没建立,双方的状态还没同步好,携带数据会导致处理逻辑混乱,而且容易被利用来放大攻击。这个设计上的不对称,本质上是因为第三次握手时客户端已经确认了服务端的接收能力。

问:SYN Flood 攻击的原理是什么,怎么防护?

答:攻击者伪造大量源地址发送 SYN,服务端每收到一个就分配半连接资源并回 SYN+ACK,但因为源地址是伪造的,永远收不到最后的 ACK,半连接队列被占满,正常用户的 SYN 被丢弃,服务无法建立新连接。防护手段包括:开启 SYN Cookie,让服务端不立即分配资源,而是把状态编码进序列号,收到 ACK 时再验证;调大半连接队列;以及在网络层做限速和清洗。SYN Cookie 是性价比最高的方案,现代内核默认就支持,net.ipv4.tcp_syncookies设为 1 即可。

问:如果服务端收到 SYN 后不回复 SYN+ACK,客户端会怎样?

答:客户端会重传 SYN,按指数退避,间隔大约 1 秒、2 秒、4 秒、8 秒这样递增,重传次数由tcp_syn_retries控制,默认 6 次,总耗时大约 127 秒。超过次数后返回 ETIMEDOUT。这个超时时间对应用体验影响很大,所以内网调用我一般会把这个值调小到 3,让失败尽快暴露出来,由上层做重试和降级,而不是傻等两分钟。

问:四次挥手过程中,如果主动方最后没发 ACK 会怎样?

答:被动方处在 LAST_ACK 状态等不到 ACK,会重传 FIN,重传次数由tcp_orphan_retries或者tcp_retries2相关逻辑控制。主动方如果还在 TIME_WAIT 或者 FIN_WAIT_2 状态,收到重传的 FIN 会再回一次 ACK。如果主动方已经彻底关闭(比如进程被杀),被动方重传几次后会收到 RST 或者超时,连接被强制回收。这就是为什么 TIME_WAIT 状态必须存在——它保证了在这个窗口内,主动方还能响应对方的 FIN 重传,不至于让对端卡在 LAST_ACK。

7. 几句实操上的体己话

我在实际工作中体会最深的一点是,TCP 状态机不是用来背的,是用来读的。你把它当成一张地图,每次遇到连接问题,先在地图上定位当前位置,再决定往哪个方向查,效率会比凭感觉猜高一个数量级。我刚入行时习惯先看应用日志,现在反过来了,先ss -s看一眼状态分布,三十秒内就能判断问题大类。

还有个小技巧分享给你:如果你所在的环境不方便抓包,可以先用ss -anti看每个连接的定时器信息。输出里的 timer 字段会显示重传定时器、keepalive 定时器、TIME_WAIT 定时器的剩余时间,配合重传次数,能推断出不少信息。比如看到一个连接的重传定时器在倒计时且次数在增加,基本就能判断是丢包导致的重传,不用抓包也能定性。

最后提一句,这套东西看着基础,但真正能把它和实际排障串起来的人不算多。面试的时候,能把"为什么"讲清楚、能举出自己排查过的例子、能说出参数调整的取舍和副作用,比背下状态迁移图有价值得多。我面过不少人,流程背得滚瓜烂熟,一问"CLOSE_WAIT 堆积你会调哪个参数",答tcp_fin_timeout的能占一半,这就是典型的只背了名词没理解状态归属。把每个状态落在哪一侧、由谁触发、什么条件下退出这三件事搞清楚,这块知识才算真正长在自己身上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询