TCP四次挥手详解:Wireshark抓包验证与常见异常排查
1. 为什么一个“挥手”值得单独拎出来讲
很多人学网络基础,三次握手背得滚瓜烂熟,一到四次挥手就开始含糊。我在带新人的时候做过一个小统计:让他手画一遍TCP连接建立和释放的完整时序,十个人里有七个能把SYN、SYN-ACK、ACK画对,但到了FIN阶段,要么少画一个ACK,要么把半关闭状态搞混,要么说不清为什么是四次而不是三次。这个现象挺有意思,因为挥手在实际排障中的出场频率其实比握手更高——连接泄漏、端口耗尽、服务端大量CLOSE_WAIT堆积、抓包里看到莫名其妙的RST,这些问题的根子往往都埋在四次挥手的某个环节里。
这篇内容就围绕TCP四次挥手和Wireshark抓包验证这两件事展开。我会把挥手过程中每个报文的作用、状态迁移的来龙去脉讲透,然后手把手带你用Wireshark把整个过程抓下来、看懂它。适合正在准备HCIA这类入门认证的朋友,也适合已经上手但抓包时只会看“绿了红了”的运维和开发同学。你不需要有很深的协议栈功底,但最好自己动手抓过一次包,哪怕只是随便点开一个HTTP请求看看。
我个人的习惯是:讲挥手之前先把“连接”这件事的本质说清楚。TCP连接不是一根物理线,而是两端各自维护的一套状态机,双方通过交换报文来同步彼此的状态。所谓“挥手”,本质上是两个方向的数据流各自独立关闭的过程。理解了“独立”这两个字,四次挥手的很多疑惑就自动消解了。下面我按“设计思路—报文细节—抓包实操—问题排查”的顺序往下走,中间会穿插我自己踩过的坑和几个容易忽略的细节。
2. 四次挥手的整体设计与状态机拆解
2.1 为什么关闭连接需要四次而不是三次
先说结论:因为TCP是全双工的,两个方向的数据通道是独立的,关闭一个方向不等于关闭另一个方向。三次握手能成立,是因为建立连接时双方没有数据要传,SYN和ACK可以合并在一个报文里捎带过去;而关闭连接时,主动关闭方发出FIN只代表“我这边没有数据要发了”,不代表“我不能再收数据”。被动关闭方收到FIN后,应用层可能还有数据没处理完、没发送完,所以它的ACK和它自己的FIN通常不能合并,这就多出了一次交互。
用一个生活场景类比:两个人打电话。A说“我这边说完了,先不说了”(FIN),B回答“好的,我知道了”(ACK),但B可能还有话没讲完,于是继续说了一会儿,等B也讲完了,B才说“那我也说完了”(FIN),A再回一句“好,挂了”(ACK)。这四句话对应四个报文,缺一不可。如果B恰好也没话说了,理论上ACK和FIN可以合并,这就是所谓的“延迟确认”或者某些实现里的合并优化,但标准流程和抓包中最常见的仍然是四次。
这里有个容易被忽略的点:主动关闭方在发出FIN之后,仍然可以接收数据。这个状态叫半关闭(half-close)。很多新手以为发了FIN连接就断了,实际上此时A到B的方向已关闭,B到A的方向还开着。这个特性在某些协议设计里被刻意利用,比如客户端发完请求后关闭发送方向,但仍然等待服务端返回完整响应。理解半关闭,是理解四次挥手的关键。
2.2 四个报文与五个状态的对应关系
把状态机和报文对应起来看,逻辑会清晰很多。我习惯用一张表来记,比纯背文字靠谱:
| 阶段 | 发送方 | 报文 | 发送方状态变化 | 接收方状态变化 |
|---|---|---|---|---|
| 第一次挥手 | 主动关闭方 | FIN=1, seq=u | ESTABLISHED → FIN_WAIT_1 | ESTABLISHED → CLOSE_WAIT |
| 第二次挥手 | 被动关闭方 | ACK=1, ack=u+1 | FIN_WAIT_1 → FIN_WAIT_2 | CLOSE_WAIT 保持 |
| 第三次挥手 | 被动关闭方 | FIN=1, seq=w | CLOSE_WAIT → LAST_ACK | FIN_WAIT_2 保持 |
| 第四次挥手 | 主动关闭方 | ACK=1, ack=w+1 | FIN_WAIT_2 → TIME_WAIT | LAST_ACK → CLOSED |
这张表建议自己默画几遍。重点盯两个地方:一是CLOSE_WAIT出现在被动关闭方,二是TIME_WAIT出现在主动关闭方。实际排障中,服务端出现大量CLOSE_WAIT,几乎可以断定是应用层代码收到FIN后没有调用close(),连接被晾在那儿了;而大量TIME_WAIT则通常出现在主动发起关闭的一方,是正常的协议行为,只是数量过多时需要调优。这两个状态的方向搞反,排查思路就会完全跑偏。
关于序号,FIN报文本身会消耗一个序列号,所以确认号是seq+1。这一点和SYN一样,SYN也消耗一个序号。纯ACK报文不消耗序号。这个细节在抓包里看序号连续性时很有用,如果你发现某个ACK的ack号对不上,往往就是漏算了FIN或SYN占用的那一个序号。
2.3 TIME_WAIT为什么必须等2MSL
TIME_WAIT是四次挥手里被问得最多的状态。主动关闭方在发出最后一个ACK后,不会立刻进入CLOSED,而是要等2MSL(Maximum Segment Lifetime,报文最大生存时间)才真正关闭。MSL在不同实现里取值不同,常见的是30秒、60秒或120秒,所以2MSL通常是1到4分钟。
为什么要等?两个原因。第一,保证最后一个ACK能到达对端。如果这个ACK丢了,被动关闭方会重传FIN,此时主动关闭方还处于TIME_WAIT,能重新回一个ACK;如果已经CLOSED了,收到重传的FIN会回RST,对端就会认为连接异常终止。第二,让本次连接产生的旧报文在网络中自然消亡,避免它们干扰后续使用相同四元组建立的新连接。第二个原因更微妙,涉及序列号回绕的问题,入门阶段知道“等是为了清理残留”就够了。
我见过有人为了减少TIME_WAIT数量,直接把等待时间改得很短,结果在压测环境里偶发连接异常。这个参数不是不能调,但要知道代价。后面排查章节我会细说。
3. Wireshark抓包前的准备与过滤技巧
3.1 抓包环境怎么搭才不白抓
抓四次挥手,最怕的是抓了半天只抓到一半——要么没抓到连接建立,要么FIN发出去了但没抓到对端的响应。我的建议是:抓包点尽量靠近你想观察的那一端,并且保证双向流量都能被捕获。如果你在客户端本机抓,看到的是客户端视角的进出流量;如果在服务端抓,看到的是服务端视角。两端同时抓当然最理想,可以对照时间戳分析延迟。
具体操作上,本机抓包用Wireshark直接选对应网卡即可。如果是抓本机与远程服务器的通信,注意选对网卡——有虚拟网卡、回环网卡、无线网卡多个选项时,选实际承载业务流量的那个。我踩过的坑是:在装了虚拟化软件的机器上默认选了虚拟网卡,抓了半天全是背景广播,业务报文一个没有。判断方法很简单,看抓包界面的包速率,如果每秒几十上百个无关包,多半选错了。
抓包前先想清楚过滤条件。四次挥手涉及的是TCP连接,最基础的过滤是tcp.port == 目标端口。如果知道对端IP,加上ip.addr == x.x.x.x更精准。但要注意:过滤表达式在抓包阶段和显示阶段是两回事。抓包阶段的过滤器(capture filter)语法是BPF,写错了直接抓不到;显示过滤器(display filter)语法是Wireshark自己的,写错了只是不显示。新手常犯的错是在抓包过滤器里写tcp.port==80这种带空格的表达式,导致抓包启动失败。稳妥做法是抓包阶段不加过滤或者只加最简单的tcp,抓完再用显示过滤器精筛。
3.2 几个必会的显示过滤器
抓完包面对成百上千个报文,不会过滤等于白抓。下面这几个过滤器是我每次分析挥手都会用到的:
tcp.flags.fin == 1:只显示FIN报文,快速定位挥手起点。tcp.flags.reset == 1:找RST,判断连接是否异常终止。tcp.port == 8080:按端口筛,把无关流量排除。tcp.stream eq 3:按TCP流编号筛,把一条完整连接的所有报文拎出来。这个最实用,右键任意报文选“Follow → TCP Stream”也能达到类似效果。tcp.time_delta > 1:找相邻报文间隔超过1秒的,用来发现挥手过程中的异常延迟。
其中tcp.stream这个过滤器值得单独说。Wireshark会给每条TCP连接分配一个流编号,同一条连接的所有报文编号相同。分析挥手时,先用FIN过滤器找到目标连接,看它的流编号,然后用tcp.stream eq N把整条连接过滤出来,从SYN一直看到最后的ACK,时序一目了然。这比手动一个个找报文高效得多。
3.3 抓包时机与复现技巧
四次挥手不是随时都能碰上的,尤其是短连接,可能你刚点开始抓包,连接已经关完了。我的做法是主动制造一次可控的关闭。最简单的方式是用一个短连接请求,比如命令行发一个HTTP请求然后让它自然结束,或者用脚本建立TCP连接后主动close。如果你想观察服务端主动关闭的场景,可以找一个配置了短超时的服务,或者自己写个简单的服务端程序,处理完请求就关闭连接。
还有一种情况是抓“异常挥手”,比如RST。RST通常在连接被强制终止时出现,比如向一个已关闭的端口发数据、或者应用层直接abort。抓RST相对容易,随便连一个没监听的端口就能触发。把正常四次挥手和RST终止放在一起对比看,对理解TCP的优雅关闭和强制关闭的区别很有帮助。
提示:抓包文件建议及时保存为.pcapng格式并命名规范,比如“日期_场景_端口.pcapng”。我吃过亏,抓完没存,回头想复现发现环境变了,只能重来。
4. 逐报文拆解一次完整的四次挥手抓包
4.1 从FIN_WAIT_1到CLOSE_WAIT:第一次和第二次挥手
假设我们抓到了客户端主动关闭的一条连接。在Wireshark里用流过滤器筛出来后,找到第一个FIN报文。展开TCP层,你会看到几个关键字段:Flags里FIN置1,Seq是当前序列号(假设为u),Ack是上一次收到的序列号。这个报文发出后,客户端进入FIN_WAIT_1。
紧接着应该能看到对端的ACK报文,Ack号等于u+1,因为FIN消耗了一个序号。这个ACK到达后,客户端从FIN_WAIT_1进入FIN_WAIT_2,服务端进入CLOSE_WAIT。在抓包里,这两个报文的时间间隔通常很短,因为ACK由内核协议栈自动回复,不涉及应用层处理。如果你看到FIN和对应ACK之间隔了很久,那就要怀疑网络延迟或者中间设备的问题了。
这里有个抓包细节:Wireshark的“Time”列默认显示相对第一个包的时间,分析挥手间隔时建议把时间显示格式调成“Seconds Since Previous Displayed Packet”,这样相邻报文的时间差直接可见。在菜单里 View → Time Display Format 里改。我一开始不知道这个,对着绝对时间戳心算差值,效率极低还容易算错。
另外,FIN报文里如果带了数据(FIN可以和payload一起发),Seq和Ack的计算会稍微复杂一点,因为数据也占序号。不过大多数场景下FIN是单独发的,入门阶段先掌握纯FIN的情况即可。
4.2 CLOSE_WAIT的停留时间说明了什么
第二次挥手之后,服务端处于CLOSE_WAIT,等待应用层调用close()。这个停留时间在抓包里体现为ACK报文和后续FIN报文之间的间隔。这个间隔可长可短,取决于应用层什么时候处理完并关闭socket。
这个间隔是排查问题的金矿。如果间隔很短(毫秒级),说明应用层处理很快,属于正常短连接;如果间隔很长(几秒甚至几十秒),说明应用层在收到FIN后迟迟没有关闭连接,可能是业务逻辑在处理耗时任务,也可能是代码里压根忘了close。后者就是典型的CLOSE_WAIT泄漏。我在实际项目里见过一个服务,因为异常分支里没有正确释放连接,导致CLOSE_WAIT数量持续上涨,最终把文件描述符耗尽。当时就是靠抓包看ACK到FIN的间隔,结合代码定位到具体分支的。
所以抓包时别只看“有没有FIN”,要看“FIN来得有多晚”。这个时间差往往比报文本身更能说明问题。
4.3 LAST_ACK与最后的ACK:挥手收尾
服务端应用层关闭连接后,发出自己的FIN(第三次挥手),Seq为w,此时服务端从CLOSE_WAIT进入LAST_ACK。客户端收到后回复ACK,Ack号为w+1,客户端进入TIME_WAIT,服务端收到这个ACK后进入CLOSED。
在抓包里,第三次挥手的FIN和第四次挥手的ACK通常也是紧挨着的。但要注意,如果第四个ACK丢失,服务端会重传FIN,此时你在抓包里会看到重复的FIN报文。Wireshark会把重传标记为“[TCP Retransmission]”或者“[TCP Fast Retransmission]”。看到FIN重传,基本可以判断最后一个ACK没送达,这时候TIME_WAIT的存在就体现出价值了——客户端还在等待期内,能重新应答。
还有一个容易混淆的点:抓包里有时会看到FIN之后跟着一个RST。这通常发生在连接状态已经不一致的情况下,比如一端已经CLOSED,另一端还在发FIN,收到的一方回RST。这种情况在长连接保活、中间设备超时清理等场景下比较常见,看到RST不要慌,先看它出现在挥手的哪个阶段,再结合两端状态判断。
4.4 用Wireshark的统计功能看全局
单看报文是微观视角,Wireshark的统计功能能给你宏观视角。分析挥手时我常用两个:Statistics → Flow Graph和Statistics → TCP Stream Graphs → Time-Sequence (tcptrace)。
Flow Graph会把一条连接的所有报文按时间顺序画成箭头图,谁发给谁、什么标志位,一目了然。分析四次挥手时,Flow Graph能直观展示四个报文的往返关系,特别适合给新人讲解或者写文档时截图。Time-Sequence图则把序列号和时间的对应关系画出来,能看出数据传输和挥手的时间分布,判断连接是短连接还是长连接、挥手是否及时。
这两个功能不需要额外配置,选中目标流后直接打开即可。我习惯在排查完一个问题后,用Flow Graph截个图存档,下次遇到类似问题直接对照,比翻文字记录快得多。
5. 常见异常与排查思路实录
5.1 只看到三次挥手是怎么回事
抓包里只看到三个报文,FIN、ACK、FIN,最后的ACK没抓到,有两种可能。第一种是抓包点位置问题,最后一个ACK没有经过抓包网卡,比如抓包点在中间设备上,回程路径不对称。第二种是ACK确实发了但被合并或延迟,某些实现里最后的ACK可能和后续数据捎带,或者抓包工具漏抓。判断方法是两端同时抓包对照,如果一端抓到了完整四个报文,另一端只抓到三个,那就是抓包点的问题,不是协议异常。
还有一种“三次挥手”是ACK和FIN合并的情况:被动关闭方收到FIN后,如果应用层立刻也关闭了,内核可能把ACK和自己的FIN合并成一个报文发出。这在抓包里表现为FIN报文同时带ACK标志,Ack号确认了对端的FIN。这种情况是合法的,不算异常,只是把第二和第三次挥手压缩了。看到这种报文不用惊讶,理解成“对方也没话说了,一起回复”即可。
5.2 大量CLOSE_WAIT和大量TIME_WAIT分别意味着什么
这两个状态是面试和排障的高频考点,我把判断逻辑整理成表:
| 现象 | 出现方 | 根因方向 | 处理思路 |
|---|---|---|---|
| 大量CLOSE_WAIT | 被动关闭方(通常是服务端) | 应用层收到FIN后未调用close() | 查代码连接释放逻辑,重点看异常分支 |
| 大量TIME_WAIT | 主动关闭方(通常是客户端或短连接服务端) | 主动关闭后等待2MSL,属正常行为 | 数量过大时考虑连接复用、调整内核参数 |
| 大量FIN_WAIT_2 | 主动关闭方 | 对端一直不发FIN,半关闭卡住 | 查对端应用是否卡住或未关闭 |
| 大量SYN_RECV | 服务端 | 握手未完成,可能是SYN Flood或网络问题 | 结合安全防护和队列参数排查 |
CLOSE_WAIT的排查核心在应用层,抓包只能告诉你“FIN来了但没关”,具体哪行代码没关得看程序。TIME_WAIT则要区分场景:如果是客户端主动关闭短连接,TIME_WAIT多是正常的;如果是服务端主动关闭,且数量巨大,可以考虑让客户端主动关闭,或者启用连接复用减少连接创建频率。调内核参数(比如缩短TIME_WAIT时间、开启重用)要谨慎,改之前先想清楚代价,别为了指标好看引入新问题。
5.3 抓包时看不到FIN的几种可能
有时候你明明知道连接关了,抓包里却找不到FIN。常见原因有几个:一是连接被RST终止,没有走正常挥手流程,这时候找RST报文而不是FIN;二是抓包开始得太晚,连接关闭发生在抓包启动之前;三是过滤条件把FIN滤掉了,比如只过滤了特定长度的报文;四是连接被中间设备静默清理,两端都没收到FIN,后续通信才发现连接已失效。
排查这类问题,我的顺序是:先去掉所有显示过滤器看全量,再用tcp.flags.reset==1找RST,然后看连接的最后几个报文是什么。如果最后是数据报文后突然没了,可能是抓包中断或连接被中间设备清理;如果最后是RST,那就是强制终止。这个顺序能覆盖大部分“找不到FIN”的场景。
注意:分析抓包时不要只看单个报文,一定要结合上下文和两端状态。同一个RST,在不同阶段出现,含义可能完全不同。
6. 我踩过的坑和几条实操建议
先说一个我早期犯的错:抓包时只抓了服务端,看到客户端发来FIN、服务端回了ACK,然后就没了,以为服务端没发FIN是异常。后来两端对照才发现,服务端的FIN其实发了,只是抓包网卡选错了方向,回程报文没经过那块网卡。从那以后我养成了习惯:分析挥手异常前,先确认抓包点是否覆盖双向流量。这个前置检查能省掉大量误判。
第二个坑是关于时间戳的。有次排查一个“挥手很慢”的问题,看抓包发现FIN到ACK隔了好几秒,一度怀疑网络问题。后来发现是Wireshark的时间显示设置成了绝对时间,我误读了。改成相对时间后,实际间隔只有几十毫秒。所以分析延迟类问题,先把时间显示格式调对,这是基本功。
第三个经验是关于过滤器的组合使用。单独用tcp.flags.fin==1可能筛出很多条连接的FIN,定位不到目标。我的做法是先按端口或IP缩小范围,再叠加FIN过滤,最后用流编号锁定。三步走比一步到位更稳。另外,Wireshark的“Apply as Filter”右键功能很好用,选中一个报文的某个字段右键就能快速生成过滤表达式,比手写快还不容易错。
最后分享一个我觉得很值的小习惯:每次分析完一个挥手案例,把关键报文的时间线、状态变化、异常点整理成三五行的笔记存起来。积累多了,你会发现大部分挥手问题就那么几类,排查时直接对号入座,比每次从头分析快得多。协议这东西,看十遍不如抓一次包,抓十次包不如认真分析透一次。