我面试别人时特别喜欢拿TCP四次挥手当开胃菜倒不是为了考背诵而是想看看对方能不能把“断开连接”这件事讲出运维和排障的味道。很多人能一口气把四次挥手的箭头画完可一旦追问“服务端CLOSE_WAIT堆积怎么办”“TIME_WAIT能不能直接调没”“为什么断开比建立多一次”就开始支支吾吾。说白了课本上那张状态图只是起点真正值钱的是状态背后那套可靠性设计以及生产环境里踩过的那些坑。今天这篇我就把TCP四次挥手从头到尾拆开讲一遍先搞懂为什么非要四次再聊TIME_WAIT和CLOSE_WAIT这两个生产环境里最常见的状态最后给出一套从现象到代码的完整排查链路以及内核参数到底该怎么调、哪些参数千万别碰。不绕弯子直接上干货。1. 断开为什么比建立多一步挥手流程的底层拆解1.1 四次挥手的完整过程与状态流转先看标准流程。假设A主动断开B是被动方A发送FIN报文进入FIN_WAIT_1状态表示“我这边没有数据要发了”。B收到FIN后返回ACK进入CLOSE_WAIT状态。A收到这个ACK后进入FIN_WAIT_2状态。B在CLOSE_WAIT里继续处理自己的事情等剩余数据发完了发送FIN报文进入LAST_ACK状态。A收到B的FIN后回复最后一个ACK进入TIME_WAIT状态。B收到这个ACK后进入CLOSED状态。注意画状态图的时候很多人会把“B发送FIN”和“A回复ACK”连在一起看但这两个动作中间其实隔着一个CLOSE_WAIT这才是四次挥手和三次握手最本质的差异。1.2 为什么不能把第二次和第三次握手合并掉这是面试里最经典的问题也是理解全双工通信的关键。TCP的两条数据通道是独立的A到B的方向和B到A的方向各自都可以单独关闭。A发FIN只能代表“A到B这个方向的数据发完了”并不代表B必须立刻结束自己的发送。B可能还有一屁股数据没发完所以必须先回一个ACK告诉对方“你的FIN我收到了”然后等自己的数据全部发完再发FIN。回ACK和发FIN这两个动作之间隔着一段未知的数据发送时间因此标准流程里不能合并。只有一种特殊情况可以“压缩”成三个包B收到FIN时恰好自己也没数据要发了可以在同一个报文里捎带FIN也就是TCP头里同时带上ACK和FIN标志位。这种piggyback方式在真实流量里偶尔能看到但协议设计上不能依赖它所以教科书一律按四次讲。如果觉得抽象可以打电话来类比A说“我说完了你还有要说的吗”这是第一次挥手B说“知道了我记下了”这是第二次挥手B把话说完说“我也讲完了”这是第三次挥手A回一句“好我清楚了挂吧”这是第四次挥手。中间B说话的空档就是CLOSE_WAIT。1.3 主动方和被动方只是角色不是固定身份很多人习惯性认为“客户端主动断开服务端被动断开”这其实是个误区。主动断开的一方取决于谁先发FIN和握手时的Client/Server角色没有绑定关系。服务端完全可能因为空闲超时、连接管理、负载均衡探测等原因主动断开连接。这个认知在生产环境里很重要。比如服务端主动断开那服务端就会进入TIME_WAIT服务端被动断开则可能卡在CLOSE_WAIT。判断问题的时候先搞清楚谁是主动方谁是被动方能少走一半弯路。1.4 半关闭状态才是四次挥手的精髓四次挥手对应的工程概念是半关闭half-close这也是socket编程里容易被人忽略的点。标准库里的close()是一次性关闭整个socket的双向通道而shutdown()可以指定只关闭某一方向。用shutdown(fd, SHUT_WR)切换半关闭后本端不能再发送数据但仍可以接收对端的数据直到对方也关闭。这种能力在实现某些协议时非常有用比如一个服务端发完响应后先关掉写方向让客户端能读到EOF然后继续等客户端发后续数据。如果不用半关闭直接close()可能会把还没读完的输入数据一并打断。很多线上诡异行为最后查下来都是半关闭语义没处理好。2. TIME_WAIT的2MSL是在保护谁它究竟是不是洪水猛兽2.1 TIME_WAIT存在的两个硬核理由TIME_WAIT是主动断开方发送最后一个ACK后进入的状态持续2MSLMaximum Segment Lifetime时长。很多教程把TIME_WAIT描述成“要避免的负担”但站在协议设计的角度它绝对是必要的原因有两个。第一最后一个ACK可能丢失。如果A发完最后的ACK直接进入CLOSED而B没有收到这个ACKB会以为自己发的FIN还没被确认于是超时重发FIN。可A早就关闭连接了收到这个FIN只能回一个RSTB收到RST后不是进入正常关闭流程而是连接被异常重置。TIME_WAIT存在的意义就是让A在这个窗口期内等待可能重发的FIN收到后重新回一个ACK让B也能干净地进入CLOSED。第二防止旧连接的报文串扰新连接。网络上有延迟的报文可能会残留在链路上如果同一个四元组源IP、源端口、目的IP、目的端口迅速被新连接复用旧连接迟到的报文就可能被新连接误认为是自己的数据导致数据错乱。等待2MSL可以确保网络中不再有旧连接的报文残留。2.2 MSL到底是多少Linux下有个常见误解MSL是报文在网络中存活的最长时间理论上每个IP包都有一个TTL上限MSL可以粗略理解成TTL对应的寿命。RFC建议MSL取2分钟但不同操作系统实现不一样Linux实际把TIME_WAIT的时长写死在了内核常量里通常是60秒也就是2MSL约为60秒。这一块有个流传很广的误解是“通过tcp_fin_timeout参数调整TIME_WAIT时长”——其实tcp_fin_timeout控制的是FIN_WAIT_2状态下的超时时间也就是被动方一直不发FIN时主动方最多等多久默认60秒。TIME_WAIT的时长由内核编译期常量决定想改只能改内核重新编译一般没人会这么干。2.3 什么样的服务会被TIME_WAIT淹没TIME_WAIT之所以在运维圈讨论度极高是因为短连接场景下它的堆积效应非常明显。假设某个网关平均每秒钟处理5000个请求每个请求都是新建连接、处理完就断那么每秒钟都会留下5000个TIME_WAIT。60秒后这个数字是30万。即便TIME_WAIT本身不占太多内存但每个连接都对应一个本地端口和一个文件描述符端口范围耗尽或者fd耗尽时新连接就没法建立了。简单算一笔账Linux默认的临时端口范围通常是32768到60999一共不到3万个端口。每秒5000个新连接、每个连接TIME_WAIT持续60秒峰值时占用端口数远超端口范围很快就能把本地端口耗干。这也是为什么高并发的短连接服务经常需要打开连接复用或者扩大端口范围。2.4 先别急着调参先判断要不要处理遇到大量TIME_WAIT第一反应不该是找内核参数去清理而是先问三个问题这些连接是不是本机主动断开的连接的生命周期是不是真的太短有没有办法从应用层减少新建连接的数量答案如果是“连接确实短平快、但业务允许复用”那就优先做连接池或长连接改造这才是治本。内核参数只是在治标。TIME_WAIT多并不等于故障只要端口和fd还没耗尽系统依然能正常工作。它真正可怕的地方是“端口耗尽之后新建连接失败”而不是TIME_WAIT本身。3. CLOSE_WAIT堆积的完整排查链路从一条SS命令到代码层根因3.1 CLOSE_WAIT是什么先纠正一个常见误解CLOSE_WAIT是被动关闭方收到对方FIN、并且已经回复ACK之后所处的状态。用一句话概括对方主动想断但己方还没执行close()连接就卡在半空。注意网上有个流传很广的误解是“CLOSE_WAIT说明网络异常、数据没发出去”这个说法不对。真正含义恰恰相反——对方已经不想继续了问题出在己方应用没有把socket关掉。CLOSE_WAIT堆积的可怕之处在于它不像TIME_WAIT那样60秒后自动消失。如果应用层一直不close()这个连接就会一直卡在CLOSE_WAIT里直到fd个数被占满。fd被占满的表现通常是新连接accept失败、日志里出现Too many open files服务看起来还活着但业务已经在崩的边缘。3.2 第一步确认状态分布锁定目标进程排查最好从统计连接状态开始一秒钟就能看清大局。Linux下我习惯用ss因为netstat在大连接数下比较慢ss -tan | awk {print $1} | sort | uniq -c | sort -rn输出里会按数量列出ESTAB、TIME_WAIT、CLOSE_WAIT、FIN_WAIT_2等状态的分布。如果CLOSE_WAIT的数量持续上涨而且不回落基本可以断定有连接泄漏。接下来把进程关联上ss -tanp | grep CLOSE_WAIT重点看CLOSE_WAIT连接的本地地址和端口再结合lsof确认对应进程lsof -nP -iTCP | grep CLOSE_WAIT | head -20这时候还要看一个关键信息对端的IP和端口。如果CLOSE_WAIT连接的对端全是某个下游服务的地址那线索就集中在“本服务和该下游的这条链路”上。3.3 第二步抓包确认FIN到底来没来状态确认之后别急着翻代码先抓包验证一下网络层的真实情况。抓包的目标是确认两件事对方的FIN有没有到达本机本机有没有回应ACK。tcpdump -i eth0 tcp[tcpflags] (tcp-fin) ! 0 -nn -s 0如果抓到大量来自对端的FIN报文但对应的连接没有正常关闭那问题基本可以确定在应用层内核已经帮我们回ACK了但应用没有感知到EOF或者感知到了却没有close()。如果抓不到FIN那就说明对端根本没发起关闭或者FIN在网络上丢了这种情况要往链路层和对端应用方向查。这一步能避免很多乌龙。我见过一个案例团队花了两天时间查代码最后发现是负载均衡设备把某些FIN包丢了对端压根没收到正常断开信号连接一直被系统判定为ESTAB直到超时。如果不抓包直接翻代码方向就完全错了。3.4 第三步在代码里找漏关路径先看这几个典型位置确认内核确实收到FIN之后就可以安心查代码了。CLOSE_WAIT的根因几乎都是应用层没有正确关闭socket常见的有四类读取循环没处理EOF。很多手动写socket读取逻辑的人会用recv返回0来判断对端关闭但如果业务代码里对返回值判断写错或者把0当成了正常数据继续循环就会漏掉EOF处理连接永远不会关闭。异常路径没兜底。只关注正常流程里的close()但遇到异常抛出、超时、业务校验失败时直接return了socket就漏关了。正确的做法是try-finally包裹所有返回路径或者统一用连接池框架管理生命周期。close()被阻塞。有些应用在close()之前会做大量清理工作比如清缓冲区、刷日志、在持有锁的状态下执行耗时操作导致本应快速关闭的连接被拖住CLOSE_WAIT自然堆积。代理/网关类程序的转发连接没释放。这一类最隐蔽主连接关了但上游或下游的连接没有被级联关闭形成半开状态。3.5 CLOSE_WAIT的连带效应和止损手段CLOSE_WAIT堆积的后果不是缓慢的而是断崖式的。fd数量一旦触及上限accept就会失败新请求全部被拒。如果服务没有健康检查机制负载均衡还会不停地把流量打到这台病入膏肓的机器上故障面进一步扩大。临时止损的办法是把fd上限调大比如systemd的LimitNOFILE或者ulimit -n但只是争取时间治标不治本。真正的止损思路是重启服务把存量连接清掉然后快速定位漏关路径。重启不能解决代码层面的问题同一个流量模式下CLOSE_WAIT会在半小时内重新堆积起来这点要有心理准备。4. 连接复用与内核参数调优生产环境里到底该怎么动4.1 TIME_WAIT的治本方案从短连接改成连接池TCP四元组里的源端口是稀缺资源短连接越多TIME_WAIT占用端口越严重。与其跟内核参数较劲不如先在应用层把连接复用起来。数据库连接池、HTTP连接池、Redis连接池这些组件存在的意义之一就是复用长连接避免每次请求都经历一遍建连和断连的开销。如果业务确实没办法用长连接那就该接受TIME_WAIT是正常现象只要它没有导致端口和fd耗尽就不算故障。我见过一些运维在监控里看到TIME_WAIT数量稍微涨一点就报警吓出一身冷汗其实完全没有必要。关键是量化为“端口占用率”和“新建连接失败率”而不是只看TIME_WAIT的绝对值。4.2 tcp_tw_reuse能用吗能用但只对出站连接生效Linux内核里有两个容易搞混的参数tcp_tw_reuse和tcp_tw_recycle。先说reuse。设置net.ipv4.tcp_tw_reuse1之后内核在建立新的出站连接时会尝试重用还在TIME_WAIT状态的四元组前提是开启TCP时间戳并且新连接的时间戳必须大于该四元组上一次连接记录的时间戳。这样既能缓解端口不足又不会和旧连接的残留报文产生冲突。但要注意这个机制只对主动发起连接的场景有效也就是本机作为客户端向外发起连接时。它是为高强度出站短连接设计的比如大量调用下游API的场景。如果是服务端接受入站连接TIME_WAIT完全不受reuse控制该等还是得等。4.3 tcp_tw_recycle千万别开它是NAT环境下的隐形杀手再说recycle。这个参数早年间确实存在它的作用是在TIME_WAIT尚未完全结束时就回收连接并且依赖TCP时间戳来判断报文是否“足够新”。问题在于NAT环境下多个客户端共用同一个公网IP不同的客户端设备时间戳基准可能不一致或者时间戳不是单调递增的内核收到时间戳偏小的报文时会直接丢掉表现就是用户访问时好时坏、随机丢包排查起来极其痛苦。因为这个原因新版Linux内核已经移除了tcp_tw_recycle支持。如果你在网上看到年代久远的文章推荐开启它请直接忽略。我看到不少团队在生产环境里踩过这个坑症状是某些用户完全正常、某些用户完全不通最后关掉recycle立刻恢复——这类案例足够说明问题。4.4 tcp_max_tw_buckets是不是越大越好别乱动tcp_max_tw_buckets控制的是系统里允许存在的TIME_WAIT连接最大数量。很多人的直觉是“这个值太小了所以TIME_WAIT堆积把它调大一点”。但真正需要注意的是这个值如果被刻意调小内核会开始丢弃TIME_WAIT连接这不是“快速清理”而是破坏TCP的可靠性保障——之前提到的最后一个ACK丢失后的重发机制会失效对端可能需要等待更久才能关闭连接甚至可能出现连接关闭异常。所以我的建议很简单如果TIME_WAIT数量超过了buckets默认值优先考虑业务层改造减少连接数而不是动这个参数。生产环境里把它调大的情况有调小的极少且通常伴随风险。4.5 服务端的另一个优化方向空闲连接探测与控制服务端处理完请求后保持连接不关是连接复用的前提但连接一多空闲连接也会占资源。这就要提到两个层面的控制传输层的TCP KeepAlive和应用层的空闲超时。TCP KeepAlive默认2小时才发起探测对大部分业务来说太慢应用层框架里一般都有空闲超时和连接空闲回收的配置比如HTTP服务器可以设置keepalive_timeout控制连接保持多久后主动关闭。主动关闭用的是四次挥手服务端主动关闭后自己就会进入TIME_WAIT。所以一个设计良好的服务端不仅要有合适的keepalive超时还要能控制住主动关闭的频率避免把自己的端口打满。4.6 端口范围和fd上限的兜底调整如果应用层暂时改不动还有两个兜底手段。一是扩大临时端口范围net.ipv4.ip_local_port_range 1024 65535但注意临时端口范围扩大只能缓解“出站连接端口不足”对于入站连接的端口耗尽没有帮助。二是提高fd上限服务进程的LimitNOFILE、内核的file-max都要看。这两个定位都是兜底真正的解法依然是连接复用和代码关闭路径的规范。4.7 用一次完整实验验证你的理解讲了这么多理论最后给一个可以在本地验证的动手实验。用Python起一个服务端监听端口客户端连接后立刻关闭在服务端主动断开连接之前抓包看一下挥手过程tcpdump -i lo -nn tcp port 18923 -c 8真实抓包里应该能看到四个包主动方发FIN状态从ESTAB进入FIN_WAIT_1被动方回ACK主动方进入FIN_WAIT_2被动方进入CLOSE_WAIT被动方发FIN状态进入LAST_ACK主动方回ACK被动方进入CLOSED主动方进入TIME_WAIT抓完包之后再用ss对比ss -tan | grep 18923能看到TIME_WAIT只会出现在主动断开的一方被动方不会出现TIME_WAIT。这一点掌握之后很多关于TIME_WAIT和CLOSE_WAIT的监控报警判断就有了依据。5. 我复盘几次排障后沉淀的习惯这些年在维护线上服务的过程中我形成了几个固定动作每次处理连接类故障第一件事永远是用ss统计状态分布而不是凭感觉猜是哪一端的问题第二件事一定是抓包确认FIN的真实走向最后才翻代码。这个顺序帮我避开过很多次误判。然后就是心态问题。TIME_WAIT多不是事故端口耗尽和fd耗尽才是CLOSE_WAIT涨也未必立刻致命但如果持续不降背后一定藏着漏close()的代码路径。遇到连接问题先判断方向再决定是调应用还是调内核千万不要一上来就开一堆内核参数很多参数改完的副作用比原问题还难收拾。最后分享一个小技巧如果你在排查过程中需要快速统计某个服务的连接状态分布可以一次性写下这条命令——ss -tan state established | wc -l统计存量连接数ss -tan state time-wait | wc -l统计TIME_WAIT数配合lsof -nP -iTCP | wc -l看fd使用总量。三个数一起看基本能判断当前连接层还安不安全。TCP四次挥手这张图背起来很容易但真正能在生产环境里用来排障、用来优化才是把它学透了的标志。
阅读完成 · 觉得有帮助?