☰
TCP四次挥手:状态机、CLOSE_WAIT与TIME_WAIT排障
2026/10/13 3:51:01 网站建设 项目流程

上周处理一个线上接口耗时飙升的问题,登录服务器一看,连接状态里躺着几百个CLOSE_WAIT,同事第一反应是“TCP四次挥手出问题了”。说实话,TCP四次挥手这个名词背起来很简单——FIN、ACK、FIN、ACK——但真正排障的时候,状态、时序、超时参数全都搅在一起,光靠背概念根本不够用。这篇博客就把四次挥手从原理到抓包、从状态机到线上排查完整过一遍。适合刚接触网络协议的后端开发者,也适合被CLOSE_WAIT/TIME_WAIT折腾过、想系统梳理一遍的运维或SRE同学。内容是老话题,但我会把网上讲得含糊的地方都展开,尤其是那些“为什么”和“怎么办”。

1. 四次挥手到底在挥什么?

1.1 为什么断开连接需要四个报文?

你可能会想:建立连接要三次握手,断开连接搞四次,是不是有点浪费?其实不是。TCP是全双工协议,一条连接相当于两个独立方向的数据通道。打开连接时,双方都要确认“你能听到我说话吗、我能听到你说话吗”两个方向,所以三次握手是建立双方收发能力的必要交互;而关闭连接时,由于数据可以单向停止,TCP允许先关一个方向、保留另一个方向继续传数据,这就是“半关闭”。因此,每个方向都要经历一次“我想结束了”的通知(FIN)和一次“我收到你的结束了”的确认(ACK),所以就出现了一方发出FIN、另一方回ACK,然后另一方也发出FIN、先发起方再回ACK的四个报文。这四个报文不是冗余,而是TCP针对全双工通道做优雅关闭的最低成本。

举个例子。假设A和B正在通电话,A说完自己的事情之后说“我说完了,你可以继续说”,这时候A的通知就是FIN,B回一句“知道了”就是ACK。但B可能还有一肚子话没说完,所以连接还不能完全断开,要等B也说完“我说完了”,A再回一句“知道了”,通话才真正结束。这个类比虽然简单,但很贴近TCP设计里的“半关闭”:A关闭的是A到B这个方向,B到A方向还在工作。如果B说完自己的话后直接挂电话,相当于ACK和FIN在时间上很接近,但语义上依然是两个动作。

这也解释了为什么教科书普遍强调“四次挥手至少需要四步”。假如被动关闭方在收到FIN后刚好也想关闭,理论上可以尝试把ACK和FIN放在同一个报文里捎带,抓包时确实可能看到三个包或两个包,但那是报文合并与延迟确认带来的现象,不是TCP四次挥手的基本形态。基本形态里,ACK代表“我收到了你的关闭请求”,FIN代表“我也要关闭我的方向”,两者的含义不同,不能因为时间接近就混为一谈。

1.2 三次握手和四次挥手的对称关系

三次握手大家都很熟:SYN、SYN+ACK、ACK。为什么建立连接时SYN和ACK能合并成SYN+ACK,断开连接时FIN和ACK却不能随意合并?关键在于合并的时机不同。

建连时,服务端收到客户端的SYN,只需要确认“我可以接受你这个初始序号,并且我也要建立连接”,此时服务端还没有开始向客户端发送业务数据,所以立即用一个报文回SYN+ACK是天然合理的。断开连接时,主动关闭方发出FIN后,被动关闭方虽然收到了关闭请求,但它自己这边的数据可能还没发完,所以只能先回复ACK表示“我收到你要断开的请求了”,然后继续把剩余数据发出去,发完之后才能发FIN。这个“可能还有数据要发”的窗口是无法预知的,因此四次挥手不能像三次握手那样把确认和发起合并成一次。

有经验的面试官经常在这个点上追问:“如果被动关闭方在收到FIN时数据也恰好发完了呢?能不能三次?”答案是:从报文语义上仍然不建议合并,因为如果被动关闭方把ACK和FIN放在同一个报文里,主动关闭方收到后需要同时理解“我收到你的FIN”和“我也要关闭”,但从状态机上看,这个组合报文到达时,主动关闭方应直接进入FIN_WAIT_2之后再收到FIN,还是直接处理两个事件?虽然TCP实现里可以处理这种情况,但协议设计为了保持状态转移清晰、兼容半关闭,才普遍采用四段式关闭。实际抓包里经常看到只出现三个报文或两个报文,主要是因为延迟确认、捎带确认和接收缓冲区的状态,而不是协议本身变成了三次。

另外要强调一点:主动关闭方不一定是客户端。很多讲解默认“客户端发FIN、服务端回ACK、服务端再发FIN、客户端回ACK”,那只是最常见场景。服务端同样可以主动断开连接,比如服务端空闲超时踢掉某个连接。这时候状态迁移还是会走四次挥手,只是发起方换成了服务器。真正需要记住的是:谁先发FIN,谁就是主动关闭方,谁进入TIME_WAIT。

2. 从状态到数据包:把四次挥手看成一个过程

2.1 状态机变化与四个报文的时间线

如果只用一句话描述四次挥手,那就是一个状态机的推进过程。假设A是主动关闭方,B是被动关闭方,初始状态双方都是ESTABLISHED。完整过程如下表所示:

步骤报文方向报文内容A状态B状态
初始无无ESTABLISHEDESTABLISHED
1A → BFINFIN_WAIT_1ESTABLISHED
2B → AACKFIN_WAIT_2CLOSE_WAIT
3B → AFINFIN_WAIT_2或TIME_WAITLAST_ACK
4A → BACKTIME_WAITCLOSED
等待2MSL无无CLOSEDCLOSED

注意第三步里A的状态,在发出最后一个ACK之前其实一直处于FIN_WAIT_2,只有发出最后一个ACK后才进入TIME_WAIT。所以更准确的说法是:A收到B的FIN后回复ACK,随即进入TIME_WAIT。

我把这个状态变化拆开讲一下:

第一步,A的应用层调用close(),内核发送FIN,A进入FIN_WAIT_1。这个状态表示“我已经发出关闭请求,正在等待对端确认”。如果中间网络丢包,A会在超时后重传FIN。

第二步,B收到FIN后,内核立刻回复ACK,同时B进入CLOSE_WAIT。CLOSE_WAIT这个状态非常关键,它意味着被动关闭方已经知道了对端要断开,但自己这个方向的数据传输还可以继续。应用层仍然可以往这个连接上写数据,B写完所有数据后才会调用close(),发送FIN。

第三步,B发送完所有数据并调用close(),发送FIN,B进入LAST_ACK。LAST_ACK就是“我已经发了FIN,正在等待对方最后的ACK”。如果这个FIN丢失,B会重传,直到收到ACK或超时。

第四步,A收到B的FIN后,回复一个ACK,A进入TIME_WAIT。B收到这个ACK后进入CLOSED,连接从被动关闭方视角彻底关闭。A在TIME_WAIT中等待2MSL后也会进入CLOSED。

这里面最容易被忽略的是FIN_WAIT_2。如果B一直没有发送FIN,A会一直卡在FIN_WAIT_2,直到触发tcp_fin_timeout超时。网上很多教程把TIME_WAIT讲得很细,但很少提FIN_WAIT_2,实际上生产环境里FIN_WAIT_2堆积常见于对端进程卡死或网络单通,和CLOSE_WAIT一样值得关注。

2.2 抓包确认四次挥手

状态机是理论,想要直观看到四次挥手,最好的方式就是抓包。我平时最常用tcpdump,命令大概是这样的:

tcpdump -i any -nn -s0 'tcp port 8080 and ((tcp[13] & 1 != 0) or (tcp[13] & 16 != 0))'

这个表达式的意思是抓取目标端口或源端口为8080的报文,同时只关心FIN(tcp[13] & 1)或ACK(tcp[13] & 16)置位的包。如果嫌表达式太长,直接抓全量流量也不是不行,只是在流量大的时候输出会比较吵:

tcpdump -i any -nn tcp port 8080 -c 30

抓包时可以找一个测试环境,用curl或nc模拟一次HTTP请求,比如在一台机器执行curl http://10.0.0.20:8080/,另一台机器上抓包。因为HTTP请求结束后连接会关闭,通常能看到SYN建连的三包,然后经过若干数据报文之后出现四次挥手。简化后的抓包输出大概是这样的:

1 10.0.0.10.54321 > 10.0.0.20.8080: Flags [F.], seq 421, ack 322 2 10.0.0.20.8080 > 10.0.0.10.54321: Flags [.], ack 422 3 10.0.0.20.8080 > 10.0.0.10.54321: Flags [F.], seq 322, ack 422 4 10.0.0.10.54321 > 10.0.0.20.8080: Flags [.], ack 323

这里[F.]表示FIN和ACK同时置位,[.]表示纯ACK。平均看到三步甚至两步都很正常,因为延迟确认会把第二个ACK推迟到与FIN一起发送。但注意,第三个报文里的ACK字段是422,代表B已经确认了第421字节之后的FIN;第四个报文里的ACK字段是323,代表A确认了B的FIN。看抓包时要关注sequence和ack的变化,才能真正理解这四个报文是不是在推进关闭流程。

抓包还有一个作用:判断谁先主动关闭。谁发出的第一个[F.]报文,谁就是主动关闭方。许多线上问题定位到最后,都会发现不是“客户端主动断开”或“服务器主动断开”的固定答案,而是从抓包里直接确认的。建议把抓包作为排障的第一动作,而不是登录服务器看一眼ss就下结论。

3. TIME_WAIT与CLOSE_WAIT:线上排障的重灾区

3.1 主动关闭方的TIME_WAIT:为什么它存在,为何讨厌

TIME_WAIT是主动关闭方在回复最后一个ACK后进入的状态,持续时间是2MSL。MSL是“报文最大生存时间”,Linux上常见实现把MSL定为30秒,所以TIME_WAIT一般持续60秒。这个状态存在的意义有两个,缺一不可。

第一个意义是保证最后一个ACK能够到达对端。假设A回复的ACK丢失,B在LAST_ACK状态等待一段时间后会重发FIN。如果A已经直接进入CLOSED,那么收到重传FIN后就只能回复RST,这不仅会破坏优雅关闭,还对端根本不知道连接已经关闭。A留在TIME_WAIT里,收到重传FIN后就能重新发送ACK,保证B正常关闭。

第二个意义是让旧报文在网络中彻底消失。TCP报文可能在内网里意外延迟重放。如果主动关闭方立刻关闭并随后新建一条相同四元组连接,旧连接中延迟到达的报文就可能混进新连接,造成数据错乱。TIME_WAIT等待2MSL可以确保网络中所有属于旧连接的报文都已经消亡。

但这带来一个实际问题:高并发短连接场景下,主动关闭方会积累大量TIME_WAIT。因为TIME_WAIT持续约60秒,每秒上千个短连接就会同时存在数万个TIME_WAIT,占用本地端口和连接表项,严重时会出现“Cannot assign requested address”,也就是本地端口耗尽。

遇到TIME_WAIT多,我的第一反应不是调参数,而是看业务形态。如果应用层大量使用短连接,比如每次请求都新建HTTP连接、用完就断开,那么TIME_WAIT多是正常现象。根治办法是改用连接池,让连接长期复用,而不是不断新建销毁。如果业务必须使用短连接,再考虑内核参数优化,而且必须知道哪些参数能用、哪些不能盲目开。

3.2 被动关闭方的CLOSE_WAIT:多半是代码Bug

CLOSE_WAIT出现在被动关闭方,语义很明确:被动关闭方已经回复ACK,正在等待本地应用调用close(),但应用层一直没有调。这个状态基本不是内核参数能解决的,而是业务代码里的连接泄漏。

我处理过一个典型场景。模拟项目X里有个模块定期调用外部服务拉取数据,代码用第三方HTTP客户端,但异常分支没有关闭响应体。正常情况下连接池会复用连接,请求结束后自动归还或关闭;一旦某个请求抛了超时异常,代码直接return,响应体没有被消费也没有被关闭,底层连接就一直挂在CLOSE_WAIT。刚开始只有零星几个,随着异常请求增多,CLOSE_WAIT越积越多,最终连接池耗尽,服务出现大量超时。定位方法很简单:先用ss -antp查看CLOSE_WAIT对应的PID,再用lsof -p PID找出具体文件描述符,配合日志确认是哪个模块的哪个调用路径没有释放连接。

正确的修复方式是在finally块里关闭响应体。比如Java里用try-with-resources:

try (CloseableHttpResponse response = httpClient.execute(request)) { // 处理响应 } catch (IOException e) { // 记录异常 } // 不需要手动close,异常时也会自动关闭响应

Python用with语句:

with httpx.Client() as client: with client.stream("GET", url) as response: for line in response.iter_lines(): ...

用with的好处是无论正常还是异常退出,都会关闭底层连接。很多语言的标准库和第三方库都有类似机制,关键是养成用完即关的习惯。

CLOSE_WAIT排查中的一个细节:先确认是哪个进程、哪个目标IP的CLOSE_WAIT。如果CLOSE_WAIT只集中在某个外部服务IP,那大概率是调用该服务的代码路径有问题;如果分布在各处,还要检查是否有全局的拦截器或切面把响应流吞掉了。

3.3 被忽略的RST:挥手被中断的真相

四次挥手是理想模型,现实中大量连接并不是被四次挥手正常关闭的,而是被RST中断。RST不是四次挥手中的一个步骤,而是异常关闭信号,一旦出现RST,后面的FIN/ACK流程就全部作废。

常见产生RST的场景包括:设置了SO_LINGER为0后关闭socket,系统直接发RST而不是FIN;对端已经崩溃,本端持续发送数据时收到ICMP错误从而触发RST;连接空闲时间超过中间设备的会话超时,中间设备伪造RST;连接不存在还收到数据;接收缓冲区里有未读取的数据时应用层直接close,内核也会发RST。

抓包时如果看到RST,基本说明握手和挥手都没走完。此时不要想着调TIME_WAIT参数,要先确认RST是谁发出的。如果RST源是本机,可能是应用层主动reset,也可能是因为接收缓冲区有残包导致close时触发了RST;如果RST源是对端,就要结合对端进程日志排查。很多线上“偶发连接中断”的问题,最后都会追溯到某台中间设备或某个中间层的空闲超时配置,而不是TCP协议自身的Bug。

4. 常见问题速查与实践参数

4.1 高频问题的标准答法

我整理了一张表,把最容易被问到的几个点放在一起:

问题核心答案常见坑
为什么需要四次挥手全双工,两个方向分别关闭,每个方向需要FIN+ACK想把FIN和ACK合并为三次,理解不到半关闭
TIME_WAIT为什么存在防止最后一个ACK丢失,并等待旧报文在网络中消亡把TIME_WAIT当成异常状态
CLOSE_WAIT代表什么被动关闭方等待应用层调用close()一看CLOSE_WAIT多就调内核参数
FIN_WAIT_2超时怎么办主动关闭方等待对端FIN,超时由tcp_fin_timeout控制误以为是TIME_WAIT超时
抓包只看到三个包延迟确认和捎带确认可能合并报文直接怀疑对端实现有问题
RST和四次的区别RST是异常关闭,不经过FIN/ACK流程把RST当成挥手的变种

这张表能应付大多数面试和基础排障。但表背后的逻辑比背答案更重要,尤其CLOSE_WAIT和TIME_WAIT的区别,建议多从主动方/被动方的角度理解。

4.2 调整内核参数前,先看这些风险

网上很多教程会让你直接改sysctl,但我建议先搞清楚每个参数到底干什么。下面是Linux上常见的与四次挥手相关的参数示例:

net.ipv4.tcp_timestamps = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_max_tw_buckets = 180000 net.ipv4.tcp_fin_timeout = 30 net.ipv4.ip_local_port_range = 1024 65535

tcp_tw_reuse的意思是允许客户端(发起connect的一方)在新建连接时复用处于TIME_WAIT状态的套接字。它只对主动发起连接的一方有效,服务端被动接受的连接不能靠这个参数规避TIME_WAIT堆积。要使用tcp_tw_reuse,一般要求tcp_timestamps为1,否则时间戳校验无法工作。

tcp_tw_recycle是另一个经常被提到的参数,它在NAT环境下很容易引发丢包问题。因为不同客户端经过NAT后的时间戳未必单调递增,开启快速回收会导致同一NAT后面的连接被误杀。这个参数在新版内核里已经被移除或废弃,不建议再使用。

tcp_max_tw_buckets限制系统允许存在的TIME_WAIT套接字最大数量。设置得过小,超过上限的TIME_WAIT会被直接丢弃,短时间内大量短连接时可能导致新连接异常。调整这个参数时最好监控指标,不要为了让ss输出好看就压到极低值。

tcp_fin_timeout控制的是FIN_WAIT_2状态超时时间,默认常为60秒。调小可以让长期不发送FIN的对端连接更快被回收,但也会降低对丢包场景的容忍度。一般应用不需要动它。

真正需要记住的是:内核参数调整只是缓解,不是根治。如果应用层能改成连接池,TIME_WAIT自然会大幅下降;如果CLOSE_WAIT一直上涨,任何网络参数都救不了你,只能去修代码。生产环境改任何参数都要先灰度,并且观察状态分布。

4.3 快速查看连接状态的命令

排查四次挥手问题,我用得最多的命令是ss。下面这些操作基本够用:

# 按状态统计连接数 ss -tan | awk '{print $1}' | sort | uniq -c # 只看TIME_WAIT ss -tan state time-wait # 只看CLOSE_WAIT ss -tan state close-wait # 查看进程与本地地址 ss -tanp | grep 8080

如果系统比较老,netstat -antp也是同样的用途,只是输出格式老一点。统计完状态之后,重要的是看比例和变化趋势。TIME_WAIT高多见于短连接业务,CLOSE_WAIT持续增长说明代码一定有路径没有释放连接。SYN_SENT高则是连接建立阶段的问题,比如对端不监听、防火墙丢包或回包被路由丢弃,和四次挥手无关。

实际排障时,我会先用ss找出可疑连接的四元组,再配合tcpdump抓几个包,确认是正常关闭还是异常重置。如果只凭“状态数量多”就下结论,很容易被表面现象带偏。

5. 从面试到实战:如何把四次挥手讲得有条理

5.1 回答的层次

面试里问四次挥手,其实是想看你对TCP的理解停留在背概念还是能讲出设计意图。我建议按照下面的层次回答,既能控制时间,又不会漏掉关键点。

第一层,先回答“为什么不能只有三次”:TCP是全双工协议,关闭要对两个方向分别确认,所以需要一个方向先发FIN,对端ACK,再反向FIN,再ACK。第二层,把状态机说清楚:主动关闭方进入FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT,被动关闭方进入CLOSE_WAIT、LAST_ACK。第三层,解释TIME_WAIT存在的两个原因,也就是防丢包和防旧报文串扰。第四层,如果有余力,补充异常场景:对端不发FIN会卡FIN_WAIT_2,应用不close会堆积CLOSE_WAIT,比如某外部服务SDK未释放连接导致大量CLOSE_WAIT。这样层层递进,面试官基本能确认你是真懂。

一个比较标准的回答示例是:

“连接建立后是两个独立方向的数据通道。A想关闭时会先发FIN,表示A这个方向没有数据要发了。B收到FIN后回ACK,进入CLOSE_WAIT,此时B还可以继续发数据。B发完数据后也发FIN,进入LAST_ACK。A收到FIN后回ACK,进入TIME_WAIT,等待两个MSL后关闭。TIME_WAIT有两个作用:一是防止最后这个ACK丢失后B重发FIN时无人应答,二是确保旧连接报文在网络中彻底消失,避免干扰新连接。”

这个回答大概半分钟,已经包含协议、状态、意义三个维度。

5.2 千万别踩的常见表述错误

我见过很多人在讲四次挥手时犯同样的错误,这里集中纠正一下。

第一个错误:把主动关闭方默认为客户端。服务端完全可以主动断开连接。谁发第一个FIN,谁就是主动关闭方,和客户端身份没有必然关系。

第二个错误:说“最后一个ACK发出后连接立即关闭”。主动关闭方只有在TIME_WAIT经过2MSL后才关闭,不是立即关闭。如果忽略这一点,就很难解释TIME_WAIT堆积的来源。

第三个错误:把TIME_WAIT和CLOSE_WAIT搞反。TIME_WAIT在主动关闭方,CLOSE_WAIT在被动关闭方。简单记:先发FIN的进TIME_WAIT,后收到FIN的进CLOSE_WAIT。

第四个错误:认为四次挥手一定发生。对端进程崩溃、网络单通、中间设备RST都会让四次挥手中断,实际报文里经常连FIN都看不到。所以讲协议是讲理想模型,排障是看现实报文。

第五个错误:把FIN说成“关闭连接”而不是“关闭本方向的发送通道”。这个细节如果不注意,后面理解半关闭、TIME_WAIT就很容易掉坑。

6. 最后一点经验

遇到CLOSE_WAIT先别急着改内核参数,先查代码;遇到TIME_WAIT多也别慌,用ss统计一下连接频率,然后优先考虑应用层连接复用。TCP四次挥手在网络协议里属于“看似简单但坑很多”的内容,把状态机和抓包结合起来,排障效率会高很多。

最后分享一个小技巧:抓包时用ss -tanp找到连接两端的进程,确认“谁先发出第一个FIN”。很多时候对端会一边发FIN一边发RST,如果只看状态不看报文顺序,容易把异常误判成正常关闭。只要看到RST,先不要分析TIME_WAIT,RST本身就是“挥手没走完”的最直接证据。

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

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

立即咨询