ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TCP三次握手与四次挥手全解析:从原理到tcpdump抓包排查实战

TCP三次握手与四次挥手全解析:从原理到tcpdump抓包排查实战 做后端和网络排查的朋友没被TCP三次握手和四次挥手虐过几回都不好意思说自己在搞网络。这两个概念不光是面试常客线上接口超时、连接被重置、服务端CLOSE_WAIT堆积最后多半都要回到这组状态变迁上来。这篇文章不打算念教科书我会从为什么需要握手、为什么是三次而不是两次、挥手的四个包到底怎么走到用tcpdump抓包、用netstat看状态把TCP连接生命周期的每个关键点讲透。适合刚入门想弄懂TCP协议栈的人也适合被线上连接问题折磨的应用开发和运维同学。搞懂这一块之后很多问题基本一眼就能定位到方向。1. TCP三次握手连接到底是怎么“谈拢”的1.1 连接的本质不是在打电话而是在“对表”很多人第一次接触TCP连接时脑子里会浮现一根物理网线或者一条“通道”。实际上TCP的连接并不是一个看得见摸得着的东西而是通信两端在内核协议栈里维护的一组状态包括序列号、确认号、窗口大小、MSS等参数。三次握手的核心目标就是让双方确认三件事你发的包能到我我发的包能到你并且我们俩的“数据坐标”从同一个起点开始数。我常给新人打一个比方这不是打电话而是两个人对表。建立连接之前客户端和服务端各自手里有一串完全随机的初始序列号ISN。如果不先把这串数同步好后面发出去的每个字节都没法对上位置。第一次握手客户端说“我要连你”第二次握手服务端回应“我知道了我的序号从Y开始你的序号我记下了”第三次握手客户端再确认“我也收到你的序号了”。只有双方都觉得自己能收能发连接才真正算是建立起来。这里有个很容易忽略的点初始序列号必须是随机的不能每次都从0开始。如果序列号固定一个旧连接里延迟到达的数据包很可能被新连接误判成有效数据造成数据错乱。随机化ISN是TCP在传输层做“防串包”的重要手段。1.2 三次握手逐包拆解SYN、SYN-ACK、ACK我们用最经典的流程走一遍。假设客户端端口是5000服务端端口是8080两端各自生成了初始序列号x和y。步骤方向标志位序列号确认号状态变化1客户端 → 服务端SYNseqx无客户端 CLOSED → SYN_SENT2服务端 → 客户端SYNACKseqyackx1服务端 LISTEN → SYN_RCVD3客户端 → 服务端ACKseqx1acky1客户端 → ESTABLISHED服务端 → ESTABLISHED注意几个细节SYN包和FIN包都会消耗掉一个序列号。所以服务端收到客户端seqx的SYN之后回应的ack必须是x1意思是“我期望你下一个段从x1开始”。第2步的SYNACK是合并的TCP头部里SYN和ACK两个标志位同时置1不需要拆成两个包。第3步的ACK里seqx1是因为客户端已经消耗掉了最初的x这个序号后续数据从x1开始编排。有些同学会有疑问为什么第三次握手之后客户端就已经是ESTABLISHED了而服务端要等收到这个ACK才进入ESTABLISHED因为服务端是在收到第三次ACK的瞬间才确认了“客户端确实能收到我这个方向的包”。假如服务端收不到第三个ACK它就不敢把这个连接标记为可用只能继续在SYN_RCVD状态里兜底。1.3 为什么必须是三次两次会怎样四次行不行这是整个TCP面试里被问得最多的问题也是最值得认真理解的问题。先看两次握手为什么不行。假设只有两次客户端发SYN服务端回SYN-ACK之后就认为连接建立然后服务端开始为这个连接分配文件描述符、缓冲区资源。问题来了如果这个SYN是一个在网络里绕了很久的“幽灵包”客户端早就放弃了这次连接服务端却不知道。它傻乎乎地建立起一条客户端根本不存在的连接白白占用资源。更麻烦的是服务端回出去的SYN-ACK包对客户端没有任何意义客户端不会理它也不会给它发数据这条僵尸连接会一直挂到超时。三次握手解决的就是这个“旧连接的延迟包”问题。只有客户端再回一个ACK服务端才能确认“客户端确实是当前时刻想连我的那个人”而不是一个无效SYN。用通俗的话说前两次握手只证明了“客户端说话服务端能听到”第三次握手才证明了“服务端说话客户端能听到”而只有两个方向都确认“听到”双方才敢开始正式通信。那为什么不用四次因为第三次握手之后服务端已经确认了客户端能收到自己的包再多的“确认确认”没有新信息了。TCP不追求无限互证能完成状态同步的最小次数就是三次。加第四次只会白白增加一次往返延迟对建立连接没有帮助。有些场景下四次也不是不行比如某些安全实现会额外交换认证信息但那已经是应用层的逻辑不是TCP连接本身的需要。1.4 抓包怎么看三次握手一眼认出[S]、[S.]、[.]讲了这么多理论不如直接看包。Linux上用tcpdump抓一段三次握手非常方便sudo tcpdump -i eth0 -nn -S tcp port 8080假设客户端IP是10.0.0.2服务端IP是10.0.0.1抓到的输出大概是这样的10.0.0.2.50000 10.0.0.1.8080: Flags [S], seq 1000 10.0.0.1.8080 10.0.0.2.50000: Flags [S.], seq 2000, ack 1001 10.0.0.2.50000 10.0.0.1.8080: Flags [.], ack 2001这里的Flags标记含义要记牢[S] 表示SYN置位这是连接发起包。[S.] 表示SYN和ACK同时置位服务端回应客户端。[.] 表示只有ACK置位第三次握手就是这个包。-S参数的意思是显示绝对序列号否则tcpdump默认会显示相对序列号如果你看到seq 1、ack 2这种数字别慌那是相对值方便人看。我排查问题时更喜欢加-S因为绝对序列号能直接和内核状态对应上尤其在对比两端日志时更有用。2. 四次挥手拆掉一座“还在说话”的桥2.1 为什么挥手是四次而不是像握手那样对称地两三次三次握手是为了“建立”四次挥手是为了“拆除”两者最大的区别在于建立连接时双方都是空闲的只要同步好状态就行断开连接时两个方向很可能还有数据在流动不能同时说停就停。TCP是一个全双工协议。所谓全双工就是两端各自独立地发送和接收数据。这意味着断开连接时必须为两个方向分别执行“我说完了→你确认→你说完了→我确认”的过程。每个方向都需要一次FIN和一次ACK这就是四次挥手的最根本原因。你可以想象两个人协作搬运一批箱子。一个人搬完自己负责的那批会说“我这边搬完了”FIN对方回一句“收到”ACK。但对方那批箱子可能还没搬完不能立刻结束。等对方也搬完了也得说“我这边也搬完了”FIN先说完的那个人再回一句“收到”ACK。两边都确认完毕整个搬运工作才算真正结束。如果被动关闭方在收到FIN时已经把所有该发的数据都发完了并且立刻调用close那么理论上它的ACK和FIN可以合并成一个包发送抓包上会看到三个包而不是四个。这种情况后面实操部分会遇到。2.2 四次挥手逐包拆解FIN、ACK、FIN、ACK以客户端主动关闭为例。假设客户端最后发送的数据序列号是u服务端最后发送的数据序列号是v完整流程如下步骤方向标志位序列号确认号状态变化1客户端 → 服务端FINsequ无客户端 ESTABLISHED → FIN_WAIT_12服务端 → 客户端ACKseqvacku1服务端 → CLOSE_WAIT客户端 → FIN_WAIT_23服务端 → 客户端FINseqw无服务端 → LAST_ACK4客户端 → 服务端ACK无ackw1客户端 → TIME_WAIT服务端 → CLOSED第2步之后服务水平器侧虽然已经确认客户端不再发数据但它自己仍然可以继续发送数据。这个阶段叫“半关闭”。在应用层可以通过shutdown(SHUT_WR)实现只关闭发送方向、保留接收方向的操作。很多长连接协议里客户端发送完请求后完全可以先半关闭自己的写方向然后继续等服务端把响应读完服务端再关闭自己的写方向。第4步客户端进入TIME_WAIT后还需要等待2MSL才会最终进入CLOSED状态。很多人不理解为什么要等这么久下一节详细讲。2.3 TIME_WAIT为什么必须等2MSL不是为了拖时间是为了“擦干净现场”MSL全称是Maximum Segment Lifetime即一个TCP报文段在网络中能够存活的最大时间。不同操作系统默认值不同常见的是30秒或60秒所以2MSL通常是60秒或120秒。TIME_WAIT等待2MSL有两个关键目的。第一个目的确保最后一个ACK一定能到达对端。网络是会丢包的如果第4步的ACK丢了被动关闭方在LAST_ACK状态没有收到确认就会重新发送FIN。主动关闭方必须在TIME_WAIT状态下能收下这个重传的FIN并再次回复ACK。如果主动方立即进入CLOSED被动方重传的FIN没人接它只能一直重传到超时连接就卡死了。第二个目的让这条连接上所有延迟到达的报文都在网络中“自然死亡”。TIME_WAIT持续2MSL意味着一个报文从一端发出最多经过MSL到达另一端再经过MSL消失两个MSL足够覆盖任何迟到包。这样一来如果后一个使用了相同四元组源IP、源端口、目的IP、目的端口的新连接出现就不会收到旧连接残留的数据包避免数据混淆。记住一个关键点TIME_WAIT只出现在主动关闭的一方。如果服务端主动断开连接服务端会积累大量TIME_WAIT如果是客户端主动断开TIME_WAIT就都在客户端一侧。线上看到一个服务端有海量TIME_WAIT不代表它出问题了而是说明这个服务在大量主动关闭连接比如Nginx对keepalive超时的连接主动close这是正常行为。2.4 连接状态变迁速查每个状态代表什么怎么排障TCP连接状态一共就那么多不想死记硬背的话抓住一条主线谁向谁完成了哪一步。我把常见状态整理成一张速查表排查时对着看非常方便。状态通常出现在哪一侧含义排障提示LISTEN服务端正在监听端口进程是否存在端口是否被占用SYN_SENT客户端已发出SYN等待SYN-ACK检查网络是否可达、服务端是否监听SYN_RCVD服务端收到SYN已回SYN-ACK等待ACK半连接队列是否溢出ESTABLISHED双方连接正常建立正常状态FIN_WAIT_1主动关闭方已发FIN等待ACK如果长期存在检查对端是否响应FIN_WAIT_2主动关闭方已收到ACK等待对端FIN如果长期存在检查服务端是否卡在CLOSE_WAITCLOSE_WAIT被动关闭方收到FIN应用还没close最高频异常点多半是应用没关socketLAST_ACK被动关闭方已发FIN等待最终ACK检查主动方是否存活TIME_WAIT主动关闭方最后一个ACK已发等待2MSL不是问题过多的TIME_WAIT要结合业务判断CLOSED双方连接完全结束无实际排查时我一般先执行ss -ant看一遍状态状态种类多不多、某个状态数量是否持续上涨基本就能猜出问题落在哪一层。比如CLOSE_WAIT一多几乎可以断定应用层有人忘了close。3. 实操亲手把握手和挥手“看”出来3.1 最小复现nc加tcpdump十秒钟看完整生命周期理论讲再多不如抓一次包来得实在。在自己的Linux机器上就能做不需要什么高深环境。打开三个终端第一个终端先开启抓包sudo tcpdump -i lo -nn -S tcp port 8080第二个终端启动一个最简单的TCP服务端nc -l 8080第三个终端用nc作为客户端去连接nc 127.0.0.1 8080连接建立之后在客户端随便敲几个字符比如hello再按CtrlD或者CtrlC关闭连接。第一个终端里会看到从SYN到FIN的完整过程。抓包输出大致长这样127.0.0.1.50000 127.0.0.1.8080: Flags [S], seq 1000 127.0.0.1.8080 127.0.0.1.50000: Flags [S.], seq 2000, ack 1001 127.0.0.1.50000 127.0.0.1.8080: Flags [.], ack 2001 127.0.0.1.50000 127.0.0.1.8080: Flags [P.], seq 1001, ack 2001 127.0.0.1.8080 127.0.0.1.50000: Flags [.], ack 1002 127.0.0.1.50000 127.0.0.1.8080: Flags [F.], seq 1002, ack 2001 127.0.0.1.8080 127.0.0.1.50000: Flags [.], ack 1003 127.0.0.1.8080 127.0.0.1.50000: Flags [F.], seq 2001, ack 1003 127.0.0.1.50000 127.0.0.1.8080: Flags [.], ack 2002前三个包就是完整的三次握手后面跟着的是数据段和四次挥手。注意这里用-i lo是因为我们在本机回环接口上通信地址显示为127.0.0.1。如果是在真实网络环境记得把网卡名换掉比如eth0、ens33。如果你看到的结果里挥手部分只有三个包也就是服务端发出去的ACK和FIN合并成了一个[F.]包不用怀疑抓包出了问题。服务端在接收到FIN并回复ACK后如果应用立刻退出且没有额外数据要发内核确实有可能把ACK和FIN合并发送这是完全合法的。3.2 用ss和netstat查看连接状态不要只看端口通不通连接建立起来之后可以用ss命令看状态比netstat输出更干净速度也更快ss -tan典型的输出State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* ESTAB 0 0 127.0.0.1:8080 127.0.0.1:50000 TIME-WAIT 0 0 127.0.0.1:50000 127.0.0.1:8080你可以在客户端关闭连接后再执行一次ss -tan大概率会看到TIME-WAIT状态残留在客户端一侧这正是前面讲的主动关闭方要等2MSL。这能帮你破除一个误解TIME-WAIT不是“连接没关干净”而是TCP状态机设计中的正常阶段。如果只想看某一种状态可以直接过滤ss -tan state close-wait ss -tan state time-wait我排查CLOSE_WAIT堆积时最常用的就是这条命令再配合lsof -i :8080看是哪个进程持有这个socket。找到进程之后下一步基本就是去翻代码里有没有忘记close。3.3 正常断开和异常断开的差别RST是怎么来的并不是所有断开都能走完四次挥手。网络异常、进程崩溃、对端不可达都会让连接以乱七八糟的方式结束。最简单的一种异常断开是RST。RST包的作用是“我这边不认这条连接了立刻重置”。典型场景包括客户端向一个没有监听的端口发连接请求服务端内核直接回RST。应用已经关闭了socket但还有残留数据没读干净内核收到后续数据时会回RST。双方seq状态错乱比如收到一个乱序到无法处理的包内核也可能回RST。抓包时看到Flags [R]或[R.]基本意味着连接被异常重置不要再期待后续的FIN流程。还有一种更隐蔽的情况拔网线、断电、对端宕机。这种场景下根本没有机会发FINTCP也无法马上感知对方掉线。如果没有心跳机制这条连接会一直挂在ESTABLISHED状态直到系统自带的keepalive超时。Linux默认的TCP keepalive参数是2小时才第一次探测对很多实时业务来说太慢了所以生产环境里常用应用层心跳几秒到几十秒发一次比TCP保活灵敏得多。3.4 应用层视角HTTP、连接复用和“粘包”其实都绕不开握手理解了TCP连接生命周期再看应用层的很多现象就清楚多了。HTTP/1.1默认开启Keep-Alive目的是复用同一个TCP连接处理多次请求避免每请求一次就重新握手一次。你想想一个页面几十个资源如果每个资源都新建TCP连接握手开销和TIME_WAIT堆积会非常可观。用了Keep-Alive之后连接可以长时间复用握手只发生一次。“粘包”问题也经常被拿来和TCP握手一起讨论。TCP是字节流协议底层不关心应用层的消息边界。你在应用层调两次send内核完全可能一次就把数据发出去接收方如果只按字节流去读很可能把两个消息拼在一起。这个问题和握手没有直接关系但理解了TCP连接的封装机制就明白为什么应用层协议必须自己定义帧格式。最常见的做法是长度前缀。比如每条消息用4字节记录长度后跟payload收端先读4字节得到长度再读指定长度的内容。用Python写出来很直观import struct data bhello msg struct.pack(I, len(data)) data接收端先recv(4)解析长度再recv(length)取完消息主体。像这样在应用层做消息分帧是每个TCP服务器都绕不开的基础功课。4. 高频问题与排查经验实录4.1 握手过程中丢包会发生什么三次握手的三条典型故障路径面试和线上问题里最常问的是“第三次握手ACK丢了会怎么样”。我把三次握手每个包丢失后的行为都整理一遍方便对照。第一次握手SYN丢失客户端发完SYN后等不到SYN-ACK超时后重传SYN。Linux里net.ipv4.tcp_syn_retries控制重传次数默认是6也就是说大约127秒内尝试7次。如果客户端一直卡在SYN_SENT多半是网络不通或防火墙丢包。第二次握手SYN-ACK丢失服务端在SYN_RCVD状态下会重传SYN-ACK同时客户端也在等SYN-ACK并重传SYN。两边可能同时在重传最终只要有一个SYN-ACK顺利到达连接就能继续往下走。这里有个细节客户端收到了一个自己“没有主动请求过”的SYN-ACK时会根据seq是否匹配来判断是否发送RST所以别以为握手期间对方乱发包一定会被拒绝。第三次握手ACK丢失这是最经典的场景。客户端已经进入ESTABLISHED并开始发数据服务端还在SYN_RCVD会不断重传SYN-ACK。客户端收到重复的SYN-ACK后如果连接仍处于可用状态会再次发送ACK最终把服务端拉进ESTABLISHED。但如果客户端此时已经关闭了这条连接则会回复RST服务端看到RST后会放弃建立。用抓包看这种场景你会看到大量的[S.]重传和RST非常醒目。4.2 线上案例CLOSE_WAIT堆积和SYN队列溢出我在实际环境里排查过的两类高频问题值得单独拿出来说。第一类是CLOSE_WAIT堆积。现象是ss -ant里全是CLOSE_WAIT连接数不断上涨最终把文件描述符耗尽。CLOSE_WAIT出现在被动关闭方对端发来FIN内核已经ACK了但应用进程没有调用close。最常见的原因就是业务代码里socket分支没有兜底关闭或者某个异常路径直接return导致close永远执行不到。排查思路很简单先看是哪个进程、哪个端口、哪些对端地址在堆积然后按代码路径把close补上。如果是先读后写然后关闭的模式还要注意读返回0时是否立刻close。第二类是SYN队列溢出。服务端收到SYN之后如果accept队列已满处理能力跟不上SYN就会堆积在SYN_RCVD状态。用netstat -s可以看到类似“SYNs to LISTEN sockets dropped”的计数。调整方向有两个一是调大net.core.somaxconn和应用层listen的backlog二是看net.ipv4.tcp_max_syn_backlog这直接影响内核能缓存多少个半连接。遇到SYN攻击或者瞬间大量连接进来时这两个参数经常要一起调。4.3 几个特别容易踩的误区坑踩多了之后我发现下面这几个点是很多人反复犯的第一个误区认为四次挥手一定严格是四个包。前面已经说过如果被动关闭方没有任何数据要发完全可能在回复第一个ACK时把FIN一起带上实际抓包只有三个包。判断连接是否正常关闭不能只看包数量要看状态是否都走完。第二个误区认为TIME_WAIT是“连接没有释放干净”然后急着去改内核参数。TIME_WAIT是主动关闭方的正常收尾状态不是问题本身。如果非要在高并发短连接场景下调整优先考虑业务层连接复用而不是直接开tcp_tw_reuse。就算要开也要知道tcp_tw_reuse只对发起连接的一方有效并且要配合TCP时间戳选项不是所有场景都适合。第三个误区认为收到FIN之后就不能再发数据了。准确说法是发送方发出FIN之后它自己的数据发送方向就关闭了不能再向对端发新数据但接收方向仍然可以继续收。如果服务端在收到客户端FIN后还要下发剩余数据完全可以在进入CLOSE_WAIT期间继续写数据等数据写完了再发FIN。这正体现出“半关闭”的实用性。4.4 调试TCP的几个独家技巧最后分享一些我每次排查连接问题都在用的招数不算高深但很实用。抓包时不要一上来就抓全部流量。先用过滤表达式缩小范围sudo tcpdump -i eth0 -nn -S host 10.0.0.2 and tcp port 8080 and \ (tcp[tcpflags] (tcp-syn|tcp-fin) ! 0)这样只抓SYN和FIN两个标志位置位的包没有中间数据一眼就能看到握手和挥手过程。Wireshark里对应的过滤器是tcp.flags.syn 1 or tcp.flags.fin 1。看重传率可以用netstat -s | grep -i retrans重点看“segments retransmited”和“timeouts”两个计数。如果重传数持续飙升说明链路丢包已经很严重再结合tcpdump里的重传包和RTT延迟基本能判断是物理链路问题还是对端处理不过来。如果是抓本机环回流量记得接口是lo。很多新手在服务器上抓包默认用eth0结果发现什么都抓不到其实是因为程序在走本地回环。多看一眼Recv-Q和Send-Q也有用。正常情况下这两个队列数值都很低。如果Recv-Q长期卡在某个值说明应用没有及时读数据如果Send-Q一直很大并且上升多半对端接收窗口满了或者迟迟不ACK是典型的“对端太慢”。我在实际项目中吃了不少CLOSE_WAIT的亏后来养成一个习惯每次写socket代码时把close逻辑和异常分支一样当一等公民看待先问自己“这条路径如果出现EOFsocket会不会被关掉”。排查问题时也先看状态再看代码别急着重启进程。TCP连接问题其实是最好定位的一类问题因为它有清晰的状态机、有抓包工具、有明确的报文格式只要你不把它当成黑盒99%的问题都能顺着状态和包找到根因。
返回列表