多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

TCP四次挥手详解:从状态机到TIME_WAIT与CLOSE_WAIT排查实战

TCP四次挥手详解:从状态机到TIME_WAIT与CLOSE_WAIT排查实战 关于TCP四次挥手大多数人的知识储备停留在状态图那几行FIN、ACK、FIN、ACK四个包过去连接就断了。可真到线上出了问题比如服务端一夜之间冒出几千个CLOSE_WAIT或者压测机突然爆出“Cannot assign requested address”这套纸面知识立刻就不够用了。我第一次被四次挥手按在地上摩擦是在做一个内部联调工具的时候——两边代码看起来都正确调用了close可连接数就是只涨不降。后来一步步排查才发现问题出在我对挥手过程中两个关键状态的误解上。这篇文章直接把四次挥手从头拆到尾为什么必须是四个包、抓包时怎么对上号、TIME_WAIT和CLOSE_WAIT这两个坑分别在什么场景下出现、应用层该怎么配合。适合刚学网络的后端同学也适合被连接数量问题折磨过的运维和中间件开发。1. 为什么断开连接需要四个包一次挥手拆开看1.1 TCP连接的本质是两条独立的单向通道很多人背得住“三次握手建立连接”却理解不了“四次挥手断开连接”关键是把TCP连接想成了一根水管。事实上一条TCP连接是两个方向的独立数据流你可以把我发送给你的数据和你发送给我的数据理解成两条互不干扰的单向链路。双方各有一条发送通道也各有一条接收通道关闭时也必须各自关闭。三次握手之所以是三次是因为SYN这个动作可以“捎带”确认服务器收到客户端的SYN后同时把自己的SYN和确认ACK放在同一个包里发回去一个包干了两件事。但挥手不一样第二次ACK和第三次FIN通常必须分开。原因是这两个包代表的语义完全不同ACK表示“我收到了你不再发送数据的通知”FIN表示“我也不再发送数据了”。这两件事发生的时间点可能相差很远——对方发完FIN时我这边的业务可能还没处理完、还有数据没发出去不可能立刻回FIN。这里就引出了四次挥手最关键的一个底层逻辑一个方向的关闭需要一组FINACK两个方向就是两组。四次包本质上只是“两次单向关闭”的叠加。1.2 一次四次挥手的“剧本”FIN、ACK、FIN、ACK我们以最常见的场景为例客户端主动断开服务端被动断开。第一次客户端发送FIN进入FIN_WAIT_1状态意思很明确“我这边不再发数据了允许你把我以前发的数据消化完。”第二次服务端收到FIN后先回一个ACK进入CLOSE_WAIT状态告诉客户端“我收到你的关闭请求了。”客户端收到这个ACK后从FIN_WAIT_1进入FIN_WAIT_2状态。第三次服务端把剩余数据处理完自己的发送也结束了于是发送FIN并进入LAST_ACK状态等于说“我这边也没数据要发我也要关了。”第四次客户端收到服务端的FIN后回一个ACK并进入TIME_WAIT状态。服务端收到这个ACK后正式进入CLOSED状态。客户端则要等TIME_WAIT超时结束才真正完全关闭。为什么多等这一段时间后面专门讲。1.3 为什么服务端不把第二个和第三个包合并这是一个很自然的问题服务端收到FIN后如果自己也没数据要发能不能直接回一个“ACKFIN”混在一起把四次变成三次理论上可以前提是服务端应用层在收到客户端FIN之前就已经主动调用了close协议栈才可能把ACK和FIN捎带在一个包里。但正常业务里服务端收到FIN时往往还在处理请求、写响应甚至需要再花几百毫秒做日志落盘。这时必须先回ACK稳住对方让对方进入FIN_WAIT_2等待然后等服务端把该干的事干完再单独发FIN。这个区分不是形式主义它保证了数据完整性。如果服务端在还没发完数据的时候就把FIN和ACK合并客户端会以为服务端整个连接都关闭了提前结束读操作后面的响应数据就丢了。所以标准模型老老实实四次中间允许在某些特殊场景下出现“看起来是三次”的抓包结果但绝不代表四次挥手可以被随意替代。1.4 先记住这几条后面排查都用得上把这一段拎出来单说是因为后面的故障定位全靠这几个结论主动关闭方发FIN被动关闭方回ACK被动的应用层再发自己的FIN最后主动方回ACK。TIME_WAIT只会出现在主动关闭方。谁调用了close谁最后承担TIME_WAIT。被动关闭方如果一直停留在CLOSE_WAIT说明它的应用层压根没调用close这是连接泄漏的最典型信号。服务端和客户端都能主动断开不是只能客户端断开。HTTP请求结束后服务端也可能主动发FIN。2. 抓包复盘从ESTABLISHED到CLOSED的状态迁移实录2.1 用最小脚本搭一个本地挥手实验环境纸上谈兵不如自己抓一次包。我在本机用Python搭了一个最简单的TCP服务端和客户端专门观察挥手过程。服务端监听9000端口收到客户端数据后模拟业务处理再延迟关闭客户端连上来发一个hello等一小会儿再关闭。服务端代码import socket import time server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 9000)) server.listen(5) conn, addr server.accept() print(accept, addr) data conn.recv(1024) print(recv:, data) time.sleep(0.5) # 模拟业务处理延迟关闭 conn.close() server.close()客户端代码import socket import time c socket.create_connection((127.0.0.1, 9000)) c.sendall(bhello) time.sleep(0.1) # 保证hello先发送出去FIN单独发 c.close()注意客户端这里直接调close意味着客户端是主动关闭方。服务端收到数据后故意sleep了500毫秒目的是让第二次ACK和第三次FIN分开出现这样抓包时能分辨出四次挥手的过程。2.2 tcpdump抓包四个包的seq和ack怎么对应在另一个终端用tcpdump抓包sudo tcpdump -nn -i lo port 9000 -c 16 -w fin.pcap抓完用Wireshark打开或者直接tcpdump读包sudo tcpdump -nn -i lo port 9000 -c 16正常情况下会同时抓到建立连接的三个包、数据传输以及挥手阶段的包。如果只想看挥手可以加过滤条件匹配FIN标志和ACK标志sudo tcpdump -nn -i lo port 9000 tcp[13] 0x03 ! 0抓包结果里典型的挥手四包是下面这样seq和ack数字都是相对值方向Flagsseqack含义客户端 - 服务端[F.]10012001客户端发送FIN服务端 - 客户端[.]20011002服务端确认FIN服务端 - 客户端[F.]20011002服务端发送FIN客户端 - 服务端[.]10022002客户端确认FIN第二包只有ACK第三包只有FIN这也是教科书里最标准的四次。抓包时注意seq的规律ACK包携带的ack号等于对端FIN包的seq加1FIN本身要消耗一个序列号。2.3 ss实时观察两端状态状态机的走位顺序单纯看包还不够我再开一个终端实时刷新连接状态看两端状态怎么跳watch -n 0.5 ss -tanp | grep 9000观察到的过程应该是建立连接后客户端和服务端都是ESTABLISHED。客户端调用close后客户端先进入FIN_WAIT_1很短几乎立刻变成FIN_WAIT_2。服务端收到FIN后进入CLOSE_WAIT并且在代码sleep的那500毫秒里一直停留在CLOSE_WAIT。服务端执行完close进入LAST_ACK。客户端收到服务端的FIN后进入TIME_WAIT服务端收到ACK后变成CLOSED。这里有一个值得多说一句的现象服务端sleep的500毫秒在状态机上表现为CLOSE_WAIT持续了500毫秒。如果你在线上看到大量连接卡在CLOSE_WAIT本质上就是一个一个连接在“等待应用层close”而不是内核能帮你解决的问题。2.4 这个实验告诉我的三件事第一TIME_WAIT确实在主动关闭方。实验里客户端虽然是短命连接但它承担了TIME_WAIT服务端倒是干净利落地直接CLOSED。第二被动关闭方的CLOSE_WAIT持续时间完全由应用层决定。应用层处理越快CLOSE_WAIT越短应用层忘了close连接就一直停在那。第三抓包看到的四包顺序会受TCP延迟ACK机制影响。如果本地实验运气不好第二包ACK可能会被延迟一小会儿甚至和第三包FIN合到一起导致抓包只看到三包。这时候不要慌先确认是不是被动关闭方在极短时间内就完成了close再判断是不是真的三包挥手。3. TIME_WAIT横行的现场从压测失败到问题收敛3.1 TIME_WAIT为什么必须存在TIME_WAIT是主动关闭方在发完最后一个ACK之后进入的收尾状态持续一段时间再彻底消失。这个状态被无数人吐槽但它的存在有两个坚实理由。第一个理由防止旧连接的迟到报文污染新连接。TCP报文在网络中可能会因为路由变化、拥塞等原因延迟到达。如果主动关闭方发完最后的ACK直接关闭假设这条连接的四元组源IP、源端口、目的IP、目的端口很快被一个新建连接复用几十毫秒前那个旧连接里“迟到”的报文就可能被新连接当成自己的数据接收造成数据错乱。TIME_WAIT通过一个足够长的等待期确保旧连接的所有报文要么已经送达要么已经在网络中消亡。第二个理由确保被动关闭方能够收到最后的ACK。如果客户端的最后一个ACK在网络中丢了服务端会重传自己的FIN。此时客户端如果已经彻底关闭就收不到这个重传也不会再回ACK服务端只能一直卡在LAST_ACK状态最终超时强制关闭。TIME_WAIT给了客户端一个“坐镇”的窗口让重传的FIN有地方可去也能再补发一个ACK保证双方干净利落地关闭。3.2 压测失败现场几万个连接卡在TIME_WAITTIME_WAIT不是病真正出问题的是短连接压测。我曾用一个内部压测工具打一个网关服务请求模型是典型的“建立连接-发请求-收响应-断开”没跑多久压测机就报“Cannot assign requested address”。排查过程很直接先看连接统计ss -sTIME_WAIT数量直接涨到两三万已经逼近客户端侧能用的本地端口上限。客户端的四元组里本地端口是有限的。Linux默认的本地端口范围大约是32768到60999一共两万多个。每一条短连接在客户端主动关闭后该本地端口就被一个TIME_WAIT占住暂时不能被新连接复用。压测并发一高端口瞬间被耗尽新连接自然建不起来。这个故障的根因不是内核参数而是“短连接客户端主动关闭”这个组合过于消耗端口资源。3.3 别急着改内核tw_reuse和tw_recycle的正确打开方式网上最常见的止血方案是修改两个内核参数tcp_tw_reuse和tcp_tw_recycle。我强烈建议你不要在一知半解的情况下直接改。tcp_tw_recycle老内核里用来快速回收TIME_WAIT连接的参数。它依赖时间戳并且会粗暴地根据时间戳对同源IP的报文做一致性判断。在NAT环境下同一出口IP后面可能有很多客户端时间戳不一定都单调递增这个参数会导致大量正常连接被丢弃。这个参数问题太多新内核里已经被移除2023年的今天不应该再去考虑它。tcp_tw_reuse允许客户端在发起新连接时复用TIME_WAIT状态下的端口前提是双方都开启TCP时间戳选项并且旧连接的报文时间戳比新连接更旧。它比tcp_tw_recycle安全但也有约束它只在发起连接的一端生效服务端accept进来的连接没法靠它规避TIME_WAIT。实际处理压测问题时我的顺序是先改压测工具的连接模型把短连接改成连接复用从源头上减少TIME_WAIT产生然后才考虑增大客户端本地端口范围最后才考虑tcp_tw_reuse这种内核层面的回收策略。3.4 我的处理顺序应用层优先内核参数兜底如果你现在面对一台上万TIME_WAIT的机器第一步永远是搞清楚这些连接是哪里来的、为什么频繁创建。常见来源有HTTP客户端没开连接复用、代理层每个请求新建上游连接、监控探活脚本用短连接做健康检查。区分好场景之后不同位置的TIME_WAIT处理方式不一样场景该谁处理最优解法压测客户端大量短连接压测脚本改用连接池、复用连接网关访问上游大量短连接网关配置开启上游keep-alive服务端主动断开空闲连接服务端控制空闲超时尽量由客户端先关客户端建连后异常断开业务代码排查是否超时或异常路径未清理内核参数只能缓解不能根除。我通常不会把net.ipv4.tcp_tw_reuse默认打开因为一旦开了很多网络排查变得更难做TCP时间戳还会让序列号的随机性受到一些影响。更稳妥的做法是保证应用层对连接生命周期有清晰的控制TIME_WAIT只是正常流量下的一部分而不是故障源头。4. CLOSE_WAIT堆积的排查链路从ss命令到代码定位4.1 CLOSE_WAIT到底意味着什么如果说TIME_WAIT是“主动关闭方的负担”CLOSE_WAIT就是“被动关闭方的失职”。当服务端收到客户端的FIN并回完ACK后服务端立刻进入CLOSE_WAIT。这个状态表示客户端已经不再发送数据服务端知道自己应该处理完剩余工作后调close把本端的FIN发出去。如果应用层一直没有调用closeTCP协议栈并不知道业务已经结束只能一直停在CLOSE_WAIT。这就是为什么CLOSE_WAIT大量堆积几乎总是应用层问题——要么代码忘了关要么关得太晚。TIME_WAIT让人觉得烦但它至少会自己消失。CLOSE_WAIT不同它没有任何超时机制可以永远挂在那里把文件描述符耗尽最终导致新连接accept失败。4.2 一次完整的排查过程之前我排查过一个吃连接的服务现象是服务端每隔一段时间就拒绝新请求重启后恢复但过一阵又死。第一步先看全局限量ss -sCLOSE_WAIT数量一片飘红。第二步把所有CLOSE_WAIT连接捞出来看关联进程ss -tanp state close-wait输出里能看到大量的连接停在9001端口同一个进程PID停在CLOSE_WAIT。第三步查这个进程打开了多少个文件描述符ls -l /proc/pid/fd | wc -l描述符数很快逼近进程ulimit上限这说明连接句柄没有被释放。到这里还只是确认现象真正的难点在代码定位。我用了一个比较笨但有效的办法在本地用同样的客户端持续触发这个服务并用gdb或者strace抓进程的系统调用观察哪些连接在调用close时被跳过strace -p pid -f -e traceaccept,read,close顺着系统调用时间戳找到了一个第三方客户端库使用不当的场景它在连接建立后把Socket对象放到了一个全局缓存池里正常情况下请求结束会归还池子但某些异常分支直接return了导致Socket对象没有归还也没有关闭fd慢慢泄漏。4.3 常见代码路径与修复方式结合我看到的案例CLOSE_WAIT堆积最常见的几个原因可以列成一张表方便你对着自查元凶表现修复方向输入输出流未关闭只有Socket.close没有关InputStream/OutputStream用try-with-resources或finally统一关连接池归还失败连接用完后没有还回池子或销毁在finally里归还捕获异常时销毁连接业务耗时过长服务端持续处理期间CLOSE_WAIT堆高这是正常情况但需要合理预估并控制耗时读取EOF后未关闭读到了-1/0但代码没有触发closeread结果需要判空EOF后主动close线程池拒绝任务请求被拒绝但连接没关闭在拒绝分支调用close修复代码时最稳的方式是让Socket生命周期完全在受控范围内。Java侧用try-with-resourcestry (Socket socket serverSocket.accept(); InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream()) { // 处理业务 } catch (IOException e) { // 记录日志 } // 退出时自动关闭Python侧也建议尽量用with管理连接with socket.create_connection((127.0.0.1, 9000)) as s: s.sendall(bhello) data s.recv(4096)4.4 CLOSE_WAIT的监控阈值怎么定CLOSE_WAIT不需要一出现就报警真正的健康信号是“CLOSE_WAIT的数量平稳且能够回落”。如果一台机器的CLOSE_WAIT数量一直在增长哪怕绝对值只有几十个也说明有连接在被慢慢泄漏。如果数量非常高但稳定不动很可能是业务本身需要长时间处理服务端没有办法及时关闭。我的经验是分两条线增长趋势线CLOSE_WAIT数量连续5分钟持续上升立刻报警。绝对值线单机CLOSE_WAIT超过可用文件描述符的5%或者超过200视机器规格调整进行告警和人工介入。监控手段最省事的是用Prometheus的node_exporter抓socket状态或者写一个简单的定时脚本执行ss -s做数据上报。不要等到连接拒绝服务了才发现CLOSE_WAIT的可怕之处就在于是“温水煮青蛙”。5. 应用层的优雅退出主动关闭、半关闭与心跳设计5.1 谁来主动关闭TIME_WAIT这口锅该谁背写网络应用时很多人没认真想过“这个连接应该由谁先close”。实际上谁先close谁就承担TIME_WAIT。TIME_WAIT本身不是坏事但大量集中在某一端时端口和四元组资源就紧张了。一个聪明的设计原则是让资源更充裕的一端主动关闭或者让连接复用能力更强的一端主动关闭。比如在HTTP场景下客户端开启keep-alive并复用连接绝大多数连接都可被复用客户端侧的TIME_WAIT数量就很低。反过来如果服务端在每次响应后立即主动关闭连接TIME_WAIT就会集中到服务端服务端承受高并发时容易端口或连接表紧张。我平时设计内部RPC框架时会让连接池里的空闲连接由客户端在空闲回收时主动关闭因为客户端管理着池子清楚哪些连接真正空闲关闭动作也更可控服务端只需要配合回ACK和释放fd。5.2 shutdown写通道半关闭的正确用法close和shutdown要区分开。close会直接关闭整个连接把发送方向和接收方向都关掉。但有一种业务场景需要“只关写不关读”客户端发完请求后告诉服务端“请求发完了但我还等着你继续发响应数据”。这种场景用shutdown(SHUT_WR)而非close。shutdown(SHUT_WR)会发送FIN表示“我这边不再发送数据了”但接收方向仍然打开可以继续读对端数据。服务端在read时遇到EOF就知道客户端不会再发数据了可以安心生成完整响应完成后再close。一个典型例子是客户端向服务端上传文件上传完成后客户端shutdown写服务端读到EOF后开始回执“上传成功”客户端还能正常接收回执。如果客户端用close可能会在服务端回执还没发出前就关闭了接收方向导致回执半路丢失。c.sendall(bPUT /upload HTTP/1.0\r\n...) c.shutdown(socket.SHUT_WR) # 半关闭发送FIN data c.recv(4096) # 依然可以读响应5.3 假死连接与心跳策略四次挥手处理的是“正常关闭”现实里更让人头疼的是“连接已经死了但双方都不知道”。比如一个客户端连接因为网络闪断、设备重启而消失服务端如果没有在报文上感知到这个TCP连接会一直活在ESTABLISHED状态直到内核超时。Linux自带TCP keepalive但要明确它的定位它默认要在连接空闲2小时之后才会发探测包而且每隔75秒发一次连发9次失败才会通知应用层。这个时间尺度对绝大多数高可用业务来说太慢了。生产环境应该自己做应用层心跳。我通常在协议里加一个ping/pong字段客户端每30秒发一个轻量级心跳服务端如果在连续3个周期内没收到任何数据就认定连接不可用主动close并清理资源。这里还有一个小技巧心跳可以和业务消息复用同一条连接不需要单独一条控制连接否则连接数直接翻倍。心跳机制带来的额外好处是它能暴露CLOSE_WAIT泄漏。如果心跳线程发现连接长时间没有收到对端应用层的pong但TCP层面又没有RST就可以主动关闭这个连接把fd释放出来避免连接池被半死不活的连接占满。5.4 强制关闭RST的适用场景和反效果最后聊一聊强制关闭。TCP正常关闭靠FIN靠四次挥手慢慢协商。但有一些场景不需要协商甚至可以主动发RST把连接立刻掐断。通过SO_LINGER可以实现这个效果。正常情况下SO_LINGER关闭时Socket会在发送缓冲区里把数据发完再优雅关闭。把linger时间设为0内核会在close时直接丢弃发送缓冲区的数据并发送RST给对方import socket import struct # Python里开启SO_LINGERonoff1, linger0 s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack(ii, 1, 0))Java里对应的是socket.setSoLinger(true, 0);SO_LINGER0的效果是立刻释放端口不进入TIME_WAIT对端会在read时收到RST而不是正常的EOF。它适合明确知道连接必须废弃的场景比如健康检查的探测失败、反作弊系统主动拒绝、检测到对方协议异常。它不适合正常的“还有数据没发完”的业务关闭因为发送缓冲区里的数据会被直接扔掉对端拿到的也不是干净的EOF容易把错误原因搞混。使用RST关闭时要意识到它绕过了四次挥手等于剥夺了双方“把最后的话说完”的机会。一个经验法则是能为对端保留友好退出信息的地方优先用优雅关闭只有确认对端已经死了、优雅关闭只会浪费时间的时候才用RST强制打断。我自己在做连接层调优时最后一定会在代码里写下注释明确这个连接的关闭策略正常请求走shutdownclose空闲清理走close异常检测走RST。关得干净比关得快更重要。TCP四次挥手从来不只是内核的职责它需要应用层的每一步都踩在点上。
返回列表