你是否遇到过:
- 服务端端口开了,客户端就是连不上,返回
Connection timed out? - 进程退了,端口却一直占着,报
Address already in use? - 线上系统莫名其妙卡死,
netstat看到一堆CLOSE_WAIT或TIME_WAIT?
这些十有八九是TCP状态机出了岔子。我干了十年运维,这种问题少说碰了上百次。今天不扯虚的,直接拿三个真实场景,教你用tcpdump+ss把协议栈的底裤扒干净。
读完本文你将能:
- 用一条命令抓出三次握手失败的根本原因
- 区分
CLOSE_WAIT和TIME_WAIT的代码层与内核层解法 - 快速定位端口耗尽和连接队列溢出
先备知识 & 工具
你需要:
- 一台Linux机器(我用的Ubuntu 22.04,CentOS 7/8命令也通用)
- root或sudo权限(抓包和修改内核参数需要)
- 基础网络知识:知道TCP flag(SYN, ACK, FIN, RST)
工具清单(全系统自带,不用额外装):
|-----------|----------------|-------------------------------|
| 工具 | 作用 | 常用参数 |
| tcpdump | 抓包看细节 | -i eth0 host 1.2.3.4 -nn -S |
| ss | 替代netstat,更准更快 | -tnop -tan |
| sysctl | 读/改内核TCP参数 | net.ipv4.tcp_tw_reuse 等 |
| nc | 快速起服务/客户端 | -l -p 8080 |
场景一:三次握手失败 -- 连不上?抓个包就知道了
典型症状 :
客户端telnet server_ip 8080卡住,直到超时报Connection timed out。服务端ss -lnt明明显示LISTEN。
我踩过的坑 :有一次新上线一个Go服务,端口8080,本地curl通,外部机器死活连不上。查了三天防火墙,最后发现是云平台的安全组没放开。但更多时候是协议栈内部问题。
操作步骤
第一步:在服务端抓包(同时让客户端重试连接)
sudo tcpdump -i eth0 port 8080 -nn -S -v
参数解释:-i指定网卡,-nn不解析主机名/端口名,-S打印绝对TCP序号,-v给点详细信息。
第二步:在客户端发起连接
telnet 192.168.1.100 8080 # 换成你的服务端IP
预期输出(正常时):
14:32:01.123456 IP 192.168.1.50.54321 > 192.168.1.100.8080: Flags [S], seq 1234567890
14:32:01.123478 IP 192.168.1.100.8080 > 192.168.1.50.54321: Flags [S.], seq 9876543210, ack 1234567891
14:32:01.123489 IP 192.168.1.50.54321 > 192.168.1.100.8080: Flags [.], ack 9876543211
三次握手:SYN → SYN+ACK → ACK。
异常时的抓包结果(你大概率会见到下面某一种):
|----------------------------|--------------------------------------|---------------------------------------------|
| 抓到的包 | 含义 | 根本原因 |
| 只有客户端发出[S],没有任何回复 | 包根本没到服务端或被内核丢弃 | 防火墙/安全组/路由不通;或者服务端net.ipv4.tcp_syn_rcv队列满 |
| 服务端回复[S.],但客户端不回应最后一个ACK | 客户端的ACK包丢失或服务端没收到 | 对称路由问题或客户端内核丢弃了SYN+ACK |
| 服务端直接回复[R.] | 服务端对应端口没有进程监听,或监听在127.0.0.1而非0.0.0.0 | 检查ss -lnt看端口绑定的IP |
第三,如果服务端完全没收到SYN,检查防火墙:
sudo iptables -L -n -v | grep 8080
sudo nft list ruleset # 如果你用nftables的话
常见错误1: net.ipv4.tcp_syn_rcv队列溢出
服务端内核为每个LISTEN端口维护一个半连接队列(SYN Queue)。如果瞬间大量SYN涌来(比如被SYN Flood攻击),队列满了就会丢包。你会在/proc/net/netstat看到ListenOverflows和ListenDrops涨了。
查看方法:
grep -E "TcpExtListenOverflows|TcpExtListenDrops" /proc/net/netstat
数值持续增长 -> 调大队列:sysctl -w net.ipv4.tcp_max_syn_backlog=4096(默认通常是1024)
彩蛋 :如果你在抓包时看到服务端回了[S.],但客户端居然回了[R]而不是[.],那是客户端收到了意料之外的SYN+ACK(比如源端口冲突),直接reset。这种情况通常客户端代码用了SO_REUSEADDR并且残留了TIME_WAIT的旧连接。我上次排查了四小时才发现是客户端的一个库偷偷开了端口复用。
场景二:四次挥手异常 -- 为什么有那么多CLOSE_WAIT?
典型症状 :
ss -tan一看,一堆CLOSE_WAIT状态的连接,进程打开的文件描述符快爆了(lsof -p PID | wc -l飙到几万)。
先搞清楚CLOSE_WAIT怎么来的:
正常四次挥手:
- 对端发FIN(想关连接)→ 本端内核回复ACK,应用层收到EOF,连接进入
CLOSE_WAIT - 本端应用调用
close()→ 内核发FIN → 进入LAST_ACK - 对端回ACK → 彻底关闭
坑爹的是 :很多开发只调了read()发现返回0,忘了close()套接字。结果连接就一直挂在CLOSE_WAIT,直到进程退出。这就是典型的代码bug。
如何定位是哪个文件描述符没关?
ss -tnop | grep CLOSE_WAIT
输出示例:
CLOSE-WAIT 1 0 192.168.1.100:8080 192.168.1.50:54321 users:(("nginx",pid=1234,fd=17))
看到了吗?users:那一列直接告诉你进程名、PID、文件描述符号。然后你可以去查代码里这个fd为什么没关。
快速修复(临时) :
如果是小业务,直接重启进程就释放了。但线上不能随便重启?那得改代码。我一般建议开发在read()返回0或errno == ECONNRESET时,务必close()或shutdown()。
顺便提一嘴 :CLOSE_WAIT本身不是内核参数能调的,它完全由用户态代码控制。别去改什么tcp_keepalive_time,没用的。
场景三:TIME_WAIT太多导致端口耗尽
典型症状 :
高并发短连接服务(比如压测工具、代理),跑一会儿就报Cannot assign requested address。ss -tan看到大量TIME_WAIT。
原因 :
主动关闭连接的那一端(通常是客户端)会进入TIME_WAIT,持续2MSL(默认60秒)。期间这个四元组(源IP, 源端口, 目标IP, 目标端口)不能被复用。如果你每秒新建几万连接,源端口范围只有约3万个(net.ipv4.ip_local_port_range),一分钟内就会耗尽。
先看看你当前端口范围
sysctl net.ipv4.ip_local_port_range
通常输出:32768 60999(只有28232个端口)。
解决方法(按推荐顺序)
1. 启用tcp_tw_reuse(安全且有效)
tcp_tw_reuse允许内核在TIME_WAIT状态下复用连接,前提是新连接的时间戳比旧的大(依赖TCP timestamps)。不会导致数据错乱。
sysctl -w net.ipv4.tcp_tw_reuse=1
# 同时必须打开timestamps(默认就是开的)
sysctl net.ipv4.tcp_timestamps # 应该输出1
限制 :这个参数只在客户端 (发起connect的那端)生效,服务端没用。并且不能和tcp_tw_recycle混用(tcp_tw_recycle在Linux 4.12后已移除,别问了)。
2. 调大本地端口范围
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
范围拉大,端口数变6万多。但注意1024以下端口是系统保留的,别从1开始。
3. 改代码,让服务端主动关闭
谁主动关闭谁进入TIME_WAIT。如果能让服务端先关,客户端就不背这个锅了。比如HTTP/1.1的keepalive,服务端空闲超时后主动发FIN。
实测对比(我用wrk压测本地nginx):
- 默认配置:压10秒后开始报
address already in use,TIME_WAIT数量积压到2.8万 - 开启
tcp_tw_reuse后:同一压测脚本跑1分钟,无报错,TIME_WAIT依然很多但都被复用
警告 :不要同时开启tcp_tw_reuse和tcp_tw_recycle。后者在NAT环境会灾难性丢包(因为它依赖每包的时间戳,NAT后多个客户端的时间戳会乱)。幸好新内核已经删了这个参数。
附:快速排查命令清单(存个备忘)
# 看当前所有TCP状态计数
ss -tan | awk '{print $1}' | sort | uniq -c
# 看半连接队列满没满
ss -lnt | grep -E 'Listen|Recv-Q'
# 如果Recv-Q > 0 且 Send-Q 很大(比如128),说明全连接队列满了
# 修改全连接队列大小(backlog)
# 应用代码里listen(fd, backlog) 那个backlog,同时内核限制 net.core.somaxconn
sysctl net.core.somaxconn # 默认128,调大到4096
sysctl -w net.core.somaxconn=4096
# 抓指定IP和端口的完整TCP流(存文件后用wireshark看)
sudo tcpdump -i eth0 host 10.0.0.1 and port 3306 -w mysql_trace.pcap
总结一下(实在话)
- 连接超时先抓包,看有没有SYN+ACK。没回复查防火墙/队列;回了RST查监听IP和端口。
- CLOSE_WAIT是应用层忘了close,ss -tnop直接定位到进程和fd,改代码是唯一解。
- TIME_WAIT太多在客户端开tcp_tw_reuse+调大端口范围,瞬间舒服了。
- 别迷信netstat,ss更快更准,tcpdump才是终极真相。
你还有没有遇到过更诡异的TCP问题?比如SYN_RECV状态堆积、或者FIN_WAIT2一直不消失?评论区甩出来,我看看能不能帮你排。
如果觉得这篇对你有用,欢迎分享给身边被网络问题折磨的朋友。下篇我准备写《TCP拥塞控制从入门到改参数》,想看的点个赞告诉我。