TCP四次挥手这个事,理论上讲起来不过几行字,但真正到了线上,大多数人都在TIME_WAIT和CLOSE_WAIT这两个状态上栽过跟头。Java客户端重连报"地址已在使用",Docker启动容器提示端口不可用,nginx压测到一定并发就上不去,最后扒到底全是四次挥手在背后捣鬼。这篇就把四次挥手从协议设计到状态迁移,再到线上排查和参数调优,完整捋一遍,保证你能直接拿去用。
适合谁看?后端开发、运维、网络工程师,还有那些刚把TCP状态机背下来但没实际抓过包的同学。看完你至少能回答这几个问题:为什么挥手非要四次而不是三次?TIME_WAIT到底在等什么?线上出现大量TIME_WAIT或者CLOSE_WAIT该怎么处理?
1. 先搞明白:为什么断开连接非要"挥手"四次
1.1 全双工通道的关闭逻辑
要知道TCP是全双工的,一条连接里有两条独立的"单向数据通道":客户端到服务端一条,服务端到客户端一条。你可以把TCP连接想象成一条双向隧道,两边各自负责一个方向的车辆通行。四次挥手的本质,就是把这两条单向通道分别关闭,每关闭一条都需要一次FIN和一次ACK的确认。
主动关闭方发出FIN,意思是"我这边的数据发完了,我不再往你这儿发数据了",但这不代表我不收数据。被动关闭方收到FIN后回复ACK,意思是"我知道你发完了",但此时被动方可能还有数据要发给主动方,所以它的FIN要等自己的数据全部发完后再单独发出来。这就是为什么需要四次:前两次关闭主动方到被动方的通道,后两次关闭被动方到主动方的通道。两次不够,是因为被动方没法保证自己的数据能和ACK一起秒发;三次也行不通,因为被动方在收到FIN的那一刻并不知道自己还要多久才能发完剩余数据。
1.2 什么情况下可以三次"挥手"
实际抓包时你经常会看到三次挥手的情况,那是因为被动关闭方在收到FIN后,如果它的数据恰好也发完了,会把ACK和FIN合并到一个报文里发出去。经典的四次挥手瞬间变成了三次:FIN,ACK+FIN,ACK。这在TCP的协议栈实现里是被允许的,RFC 793也没有强制要求ACK和FIN必须分开发。所以在Wireshark里看到三次挥手别慌,不是Bug,是优化。
还有一个容易混淆的点:UDP没有连接的概念,自然也就没有挥手。基于UDP的应用(比如RTP音视频、DNS查询)根本不需要维护连接状态,发完拉倒。这也是面试里"TCP和UDP区别"那道题背后隐藏的深意:有连接,就要有状态管理,就要处理可靠关闭,就要付出四次挥手的成本。
2. 四次挥手状态机:从ESTABLISHED到CLOSED的每一步
2.1 主动关闭方的三个阶段
主动关闭方发出FIN后,进入FIN_WAIT_1。这个状态很短暂,如果被动方的ACK先回来,就立刻进入FIN_WAIT_2。FIN_WAIT_2是个很有意思的状态,此时主动方已经不能发数据了,但还能收数据。如果被动方一直不关连接(比如业务代码里死循环了),主动方就会一直挂在FIN_WAIT_2。Linux上这个状态可以通过net.ipv4.tcp_fin_timeout控制超时时间,默认60秒。
等到被动方发来FIN,主动方回复ACK,进入TIME_WAIT。注意这个状态理论上要持续2个MSL(Maximum Segment Lifetime,报文最大生存时间)。MSL是网络里一个TCP报文能存活的最长时间,RFC建议2分钟,实际Linux里经常是30秒到60秒。为什么要等这么久?两个原因。第一,防止最后一个ACK丢失后被动方重发FIN,如果主动方直接关闭,重发的FIN会被当成新连接的数据,协议栈就乱了;第二,让连接里的旧报文在网络中彻底消失,避免它们串扰到后续使用同一四元组的新连接上。
2.2 被动关闭方的两个状态
被动关闭方收到FIN后进入CLOSE_WAIT。这个状态经常出问题,因为它完全由应用层决定。协议栈收到FIN后告诉你"对端要关了",但你自己的socket还没调用close(),连接就停在CLOSE_WAIT。如果业务代码忘记关闭连接,CLOSE_WAIT会一直堆积下去。
被动方在应用层调用close()后,协议栈发出FIN,进入LAST_ACK。这个状态就是干等主动方的最后一个ACK。如果这个ACK在网络里丢了,被动方会重发FIN,一直等到主动方的ACK到达或者超时。
2.3 TIME_WAIT为什么是"最值得研究的2MSL"
TIME_WAIT可能是整个TCP状态机里被讨论最多的状态。一方面它是可靠关断的保障,另一方面它又是高并发短连接场景下端口资源紧张的元凶。网上很多"优化方案"都盯着TIME_WAIT下手,但这确实是一把双刃剑。
| 状态 | 方向 | 进入条件 | 常见问题 |
|---|---|---|---|
| FIN_WAIT_1 | 主动方 | 发出FIN | 一般秒过 |
| FIN_WAIT_2 | 主动方 | 收到ACK | 对端不关连接会一直挂着 |
| TIME_WAIT | 主动方 | 收到FIN并回复ACK | 2MSL内端口不释放,量大 |
| CLOSE_WAIT | 被动方 | 收到FIN | 代码未close会堆积 |
| LAST_ACK | 被动方 | 发出FIN | 等不到最终ACK就反复重发 |
我之前在线上遇到过一个极端案例:压测服务端时,主动关闭方全压在TIME_WAIT上,单机TIME_WAIT连接数一度冲到3万。那台机器上客户端程序的端口范围是32768到60999,接近28000个可用端口,TIME_WAIT一占,后面的新连接直接报"地址已在使用"。为什么会这样?因为压测客户端每完成一次请求就关连接,短连接在TIME_WAIT上排队,2MSL内端口就是被占着不放。
3. 实操:抓包、复现与线上参数调整
3.1 用tcpdump和Wireshark完整看一次四次挥手
理论说完了,上手抓包才是最直观的。我习惯用tcpdump在服务端直接抓,再用Wireshark分析。
# 抓取特定端口的所有TCP包 tcpdump -i eth0 tcp port 8080 -w tcp_close.pcap抓完包用Wireshark打开,在显示过滤器里输入tcp.flags.fin == 1,就能筛选出所有带FIN标志的报文。完整的四次挥手看起来是这样:
192.168.1.10:50001 -> 192.168.1.20:8080 TCP FIN 192.168.1.20:8080 -> 192.168.1.10:50001 TCP ACK 192.168.1.20:8080 -> 192.168.1.10:50001 TCP FIN 192.168.1.10:50001 -> 192.168.1.20:8080 TCP ACK有一种很反直觉的情况:连接是服务端先关的,那么服务端就是主动关闭方。比如nginx配置了keepalive超时,超时后nginx会主动发FIN,服务端反而去坐TIME_WAIT的"牢"。所以排查TIME_WAIT堆积时,不要只盯着客户端看,要看谁先发的FIN。
3.2 线上案例:Java客户端重连报"地址已在使用"
搜热词里"java tcp客户端重连时报地址已在使用",这是典型的TIME_WAIT + 端口未释放问题。Java的Socket客户端每次请求都新建连接,断开后本地端口进入TIME_WAIT,如果短时间内大量重连,端口号被占满,底层bind()就会抛出Address already in use。
解法有几个。最直接的是复用同一个连接,用连接池代替频繁建连。Java的HttpClient、Netty、Apache HttpClient都支持连接复用,这是最稳的。如果确实需要短连接,给Socket设置SO_REUSEADDR:
Socket socket = new Socket(); socket.setReuseAddress(true); socket.connect(new InetSocketAddress(host, port), timeout);但注意,SO_REUSEADDR解决的是主动关闭方在TIME_WAIT状态下能不能重新bind同一个端口的问题。在Linux上它默认就允许bind TIME_WAIT状态的端口?实际上这里有个容易搞混的点:SO_REUSEADDR在Linux上主要是针对服务端监听socket,让服务端可以快速重启而不用等TIME_WAIT结束。对客户端来说,Linux的临时端口分配机制本身就允许跳过TIME_WAIT来满足新连接的端口需求。Java里报地址已在使用,往往是本地端口被占满了,不是SO_REUSEADDR没设置。更彻底的解法是调大临时端口范围,或者缩短TIME_WAIT时间,下面第3.4节会详细说。
3.3 线上案例:Docker端口不可用与nginx连接数瓶颈
搜热词里还有一条Docker报错:error response from daemon: ports are not available: exposing port tcp 0.0.0.0:8080。这个报错大多时候是宿主机端口被占了。次数的源头链路是:要么有别的进程在监听同一端口,要么大量TIME_WAIT连接占住了那个端口。
排查方法很简单:
# 查看端口被谁占用 netstat -tnlp | grep 8080 # 查看端口对应连接的TIME_WAIT数量 netstat -tan | grep 8080 | grep TIME_WAIT | wc -l如果是TIME_WAIT太多,把宿主机上产生大量短连接的进程找出来,优先从业务层面减少短连接。另外,给Docker映射端口时,尽量避免把宿主机端口范围暴露得太窄。默认的可用端口范围是32768-60999,如果容器频繁重启且每次都有TIME_WAIT残留,映射端口很容易撞上TIME_WAIT。
nginx作为反向代理时的连接数瓶颈也跟四次挥手强相关。nginx和后端服务之间如果走短连接,nginx每处理完一个请求就断一次,作为主动关闭方时会产生大量TIME_WAIT。这时最有效的优化是让nginx和后端之间使用keepalive长连接:
upstream backend { server 10.0.0.1:8080; keepalive 32; # 保留32个空闲长连接 } server { location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; } }keepalive值不要拍脑袋,开太小起不到效果,开太大会占用后端连接资源。一般32到64之间比较合适,具体看后端最大并发连接数。这个方案能把nginx侧主动关闭的次数降一个数量级,TIME_WAIT自然就少了。
3.4 Linux/Windows下的超时与回收参数调整
如果真的需要动系统参数,Linux上主要管这四个:
# 查看当前值 sysctl net.ipv4.tcp_fin_timeout sysctl net.ipv4.tcp_tw_reuse sysctl net.ipv4.tcp_tw_recycle # Linux 4.12之后已移除 sysctl net.ipv4.ip_local_port_rangetcp_fin_timeout:控制FIN_WAIT_2的等待时间,默认60秒。如果你的服务端作为主动关闭方且存在大量对端不关闭的场景,可以适当调小,但别低于5秒,否则可能切断还在传输数据的连接。
tcp_tw_reuse:这个参数只对出站连接生效,允许新连接复用处于TIME_WAIT状态的端口,前提是能确认新连接的时间戳比旧连接大。开启它以后,主动关闭方创建新连接时不太容易撞上TIME_WAIT占用。但对于服务端监听场景(被动关闭方经历TIME_WAIT),tcp_tw_reuse帮不上忙,它只影响连接发起方。
tcp_tw_recycle:老生常谈,这玩意在NAT环境下会误杀连接,因为同一个NAT出口后面的不同设备时间戳可能不同步,导致合法的连接被丢弃。Linux 4.12内核已经把它删了。如果你在网上看到古老的优化教程让你开它,别开,尤其是在云服务器上。
ip_local_port_range:临时端口的可用范围。高并发短连接场景下可以扩大这个范围来缓解端口耗尽:
sysctl -w net.ipv4.ip_local_port_range="1024 65000"调完后用ss -tan确认实际占用情况。注意端口范围不是越大越好,太小的起始端口可能撞上系统服务的固定端口。
Windows上对应的命令是热词里提到的netsh interface tcp show global,它显示的是TCP全局参数。常用调整:
# 查看当前参数 netsh interface tcp show global # 开启/关闭连接速率限制之类的功能,按需处理 netsh interface tcp set global timestamps=enabledWindows上做压力测试时,如果遇到客户端端口耗尽,可以调整动态端口范围:
netsh int ipv4 set dynamicport tcp start=10000 num=500004. 常见问题与排查技巧实录
4.1 TIME_WAIT堆积问题
现象:netstat -tan | grep TIME_WAIT数量上万,新连接建立失败,报错"Address already in use"或"Resource temporarily unavailable"。
排查思路先看谁在主动关闭。短连接场景下,主动关闭方通常是请求发起方。如果是服务端主动关的(比如服务端配置了空闲超时),那么服务端机器上TIME_WAIT堆积。这时候调整服务端keepalive时长,让连接不要那么快被关。如果是客户端主动关的,优先改客户端逻辑,用连接池,其次再考虑系统参数。
一个比较容易踩的坑:TIME_WAIT多不等于有问题。短连接应用出现几千个TIME_WAIT是常态,只要端口没耗尽,内核能正常分配新端口,就无需处理。我见过有运维不管三七二十一就把tcp_tw_recycle打开,结果线上出现大量连接被重置,业务直接受损。
4.2 CLOSE_WAIT泄漏:代码bug的经典表现
CLOSE_WAIT堆积几乎都是应用层没关socket导致的。对端发了FIN,内核把状态切到CLOSE_WAIT,但你的代码没有调用close(),这个连接就一直占着。排查方法:
# 查看CLOSE_WAIT数量 ss -tan state close-wait | wc -l # 定位到具体进程 ss -tan state close-wait | awk '{print $6}' | sort | uniq -c | sort -rn | head -20 # 用lsof确认进程和文件描述符 lsof -i :8080 | grep CLOSE_WAIT修复办法:找到持有对应文件描述符的代码路径,确认在异常分支里也执行了close()。Java里特别强调finally块中的关闭,Go里用好defer close。Nginx、Tomcat这类成熟中间件出现CLOSE_WAIT堆积,多半是后端起连接一直不释放,或者上游返回异常导致worker不回收连接。重启一下中间件能临时缓解,但根因还是要看业务代码。
CLOSE_WAIT还有一个隐蔽场景:半关闭。如果一方调用了shutdown()只关闭了发送方向,连接仍然处于半开状态,对端正常发FIN时你其实应该继续读数据直到EOF,再关闭socket。这块在写长连接服务时经常处理不干净,抓包时就表现为一直被挂在CLOSE_WAIT不动。
4.3 同时关闭与半开连接的边界场景
正常情况下四次挥手有明确的主动方和被动方,但TCP也支持同时关闭:双方同时发FIN,然后各自收到对方的FIN,都回复ACK,双双进入TIME_WAIT。这种场景在真实业务里很少见,一般在协议栈测试时出现。处理上没问题,但如果有人问"四次挥手有没有可能变成两次",指的就是同时关闭时,双方都可以把ACK和FIN合并在同一个包里发送。
半开连接指的是连接一方已经关闭或重启,另一方还不知道,仍然维持着ESTABLISHED状态。这在移动网络里太常见了:手机切网导致网络路径变化,TCP对端感知不到。检测半开连接的方法有几个:应用层心跳(比如WebSocket的ping/pong)、TCP keepalive、或者干脆用netstat -tan看长时间没有数据传输的连接。
4.4 快速排查工具箱与避坑清单
把常用命令整理成一个速查表:
| 命令 | 用途 | 常用参数 |
|---|---|---|
ss -tan | 查看所有TCP连接状态 | state time-wait、state close-wait |
netstat -tan | 老牌查看连接状态工具 | -tnlp看监听端口 |
lsof -i :port | 查看端口被哪个进程占用 | -p PID过滤进程 |
tcpdump | 抓包分析挥手过程 | tcp port 8080 -w file.pcap |
Wireshark | 图形化分析抓包结果 | 过滤tcp.flags.fin == 1 |
strace | 确认应用是否调用了close() | -p PID -e trace=network |
sysctl | 查看/调整TCP内核参数 | net.ipv4.tcp_* |
避坑清单按经验排一下:
- 别在NAT环境下开tcp_tw_recycle,会丢连接。
- 别把tcp_fin_timeout调到5秒以下,极端网络下会切段正常连接。
- 别一看到TIME_WAIT就调参数,先确认端口是否真的不够用。
- 服务端监听socket的TIME_WAIT,客户端连接池帮不上忙,要调服务端自己的空闲超时策略。
- ss比netstat快,大并发时用ss。
- Linux下tcp_tw_reuse只对主动发起连接的一方生效,对服务端TIME_WAIT没作用。
- 抓包一定要抓两端才好看,单端抓看到的时序可能误导你。比如服务端抓包里只看到了FIN没看到对应ACK,不一定是对端丢包,可能是服务端的网卡抓包位置问题。
最后再分享一个排查技巧:遇到连接不正常关闭时,先用ss -tan看状态分布,如果大量连接卡在FIN_WAIT_2,先查对端为什么没有调用close;如果卡在LAST_ACK,大概率是最后的ACK丢了或者对端已经不在线;如果卡在CLOSE_WAIT,不用怀疑,就是你自己的代码没关socket。沿着这个思路走,线上90%的四次挥手问题都能在十分钟内定位到根因。