我面试别人时特别喜欢拿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后进入的状态,持续2MSL(Maximum Segment Lifetime)时长。很多教程把TIME_WAIT描述成“要避免的负担”,但站在协议设计的角度,它绝对是必要的,原因有两个。
第一,最后一个ACK可能丢失。如果A发完最后的ACK直接进入CLOSED,而B没有收到这个ACK,B会以为自己发的FIN还没被确认,于是超时重发FIN。可A早就关闭连接了,收到这个FIN只能回一个RST,B收到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_reuse=1之后,内核在建立新的出站连接时,会尝试重用还在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四次挥手这张图背起来很容易,但真正能在生产环境里用来排障、用来优化,才是把它学透了的标志。