tcp服务器端未完全断开时,直接重连通常会被系统拒绝,核心解决办法是调整socket选项、主动清理半开连接,必要时修改内核参数,而不是盲目重启服务。
先搞清楚:什么叫“未完全断开”
很多人遇到的情况是:客户端程序崩了、手机突然切Wi-Fi、网络断了几秒,然后服务器端那条连接还挂在ESTABLISHED状态,但数据已经发不出去了,这时候客户端想重新建立连接,却卡在SYN_SENT等不到回应,或者直接收到Connection refused。
这不是服务器没在工作,而是TCP协议的状态机没有走完关闭流程,正常情况下断开要四次挥手,但异常情况下的断开往往只有三次、两次,甚至一次挥手都没有,服务器不知道对端已经走了,还在傻等,这在行业里有个说法叫半开连接(half-open connection),属于大多数TCP服务器都会遇到的经典问题。
常用的四个连接状态
ESTABLISHED:连接活着,双方还能通信FIN_WAIT_2:主动关闭方发了FIN,已经收到ACK,等对端也发FINCLOSE_WAIT:被动关闭方收到了FIN,但应用程序还没调用close()TIME_WAIT:主动关闭方已经发了最后一次ACK,等着2MSL超时
如果你的服务器上有大量CLOSE_WAIT或FIN_WAIT_2,基本可以断定连接没断开干净。
怎么判断tcp服务器端连接未完全断开
直接用命令看,不用猜。
先看端口状态
netstat -anp | grep 8080
或者用ss,比netstat快,而且新版Linux发行版默认自带:
ss -tanp | grep 8080
重点关注State这一列,如果发现大量连接卡在CLOSE_WAIT,那就是服务器端应用程序没有主动释放fd,如果卡在FIN_WAIT_2,说明对端发了FIN但没继续收,或者你的应用没有及时处理。
行业共识认为,CLOSE_WAIT数量异常增多,九成以上是代码层面的问题,不是系统配置问题。
统计各种状态的数量
ss -tan | awk '{print $1}' | sort | uniq -c
输出结果里如果CLOSE_WAIT有几十上百个,那就不是偶发现象了。
看有没有孤儿连接
ss -tanp | grep -i orphan
孤儿连接就是内核里还挂着、但已经没有任何进程引用的socket,这类连接占着端口和内存,却完全不可用,是最典型的未完全断开。
tcp服务器端未完全断开时的三个处理方向
修改socket选项(应用代码层面)
这也是最推荐的方案,既然服务器端是被动方,那就让被动方主动兜底,在服务端创建socket之后,立刻设置几个关键选项:
int keepalive = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); int keepidle = 30; // 30秒没有数据就开始探测 setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); int keepintvl = 5; // 每次探测间隔5秒 setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); int keepcnt = 3; // 连续3次探测无响应就断开 setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
这段话的意思是:如果连接30秒内没有数据来往,就每隔5秒发一个探测包,连续3次对端不回应,内核直接判定连接死亡并释放资源,这样客户端即使悄无声息地消失了,服务器端最多45秒就能把僵尸连接清掉。
在Java里对应的是:
socket.setKeepAlive(true); socket.setSoTimeout(5000);
在Node.js里可以这样:
const server = net.createServer(); server.setKeepAlive(true, 30000);
需要强调的是,SO_KEEPALIVE不是银弹,它依赖内核参数默认值,老版本Linux默认要两小时才启动探测,太慢了,所以一定要显式改掉TCP_KEEPIDLE。
开启端口重用和地址重用
如果服务器需要快速重启、快速接管旧连接,还有一个很实用的选项。
int reuse = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
SO_REUSEADDR允许新启动的进程绑定到还处于TIME_WAIT状态的端口,这里解释一下,TIME_WAIT状态的连接是主动关闭方才会有的,多数场景下服务端是被动关闭方,但如果服务端主动关了连接,比如踢掉一个超时用户,那服务端就会进入TIME_WAIT,然后重启服务就报“Address already in use”。
调内核参数,主动放手
如果服务器本身不忙,但又积累了大量半开连接,可以直接让内核帮你兜底,修改/etc/sysctl.conf:
net.ipv4.tcp_keepalive_time = 30
net.ipv4.tcp_keepalive_intvl = 5
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_max_tw_buckets = 5000
net.ipv4.tcp_tw_reuse = 1
生效:
sysctl -p
这里逐条解释一下:
tcp_keepalive_time = 30:全局探测空闲连接的时间阈值,从2小时缩小到30秒tcp_fin_timeout = 10:FIN_WAIT_2状态最多保持10秒,超时直接释放tcp_max_tw_buckets = 5000:限制TIME_WAIT连接总数,超过就尽快回收tcp_tw_reuse = 1:允许新连接重用处于TIME_WAIT状态的连接,这个选项在客户端场景下尤其有用
注意:tcp_tw_reuse只对出站连接有效,不能用它解决服务端接受新连接时遇到的TIME_WAIT问题,服务端场景请交给SO_REUSEADDR处理。
每种状态对应的实操办法
卡在CLOSE_WAIT怎么办
这说明对端已经关闭了,但你的服务器进程还在傻傻地持有文件描述符,多数情况下是应用没有处理对端关闭通知,比如Java的InputStream.read()返回了-1,但代码里没判断,或者忘记触发close()。
排查办法:
- 找出卡住
CLOSE_WAIT的进程PID - 用
lsof -p PID | grep TCP查看具体是哪些连接 - 检查代码里所有
read/recv调用,是否处理了返回值为0或-1的情况 - 确保所有socket最终都进入了
try-finally或try-with-resources
实战经验:排查CLOSE_WAIT最忌主观猜测,一定要实际用strace -p PID跟踪系统调用,看进程到底阻塞在哪个read上,这一步能直接定位到代码行号。
卡在FIN_WAIT_2怎么办
这是在等对端发FIN,正常情况下几秒内就会收到,但客户端可能已经消失了,内核的tcp_fin_timeout就是管这个的,降到10秒即可,没有太多玄学。
大量TIME_WAIT怎么办
这个状态对于高并发短连接服务器来说是很常见的,属于TCP协议的正常结果。TIME_WAIT不是洪水猛兽,设计它就是为了防止旧连接的迟到数据包干扰新连接,真正要关心的是TIME_WAIT连接数量是否超出了可用端口范围。
服务器端快速处理的核心思路:
- 开启
SO_REUSEADDR - 设置
tcp_max_tw_buckets做上限限制,避免极端情况打满fd - 不要轻易开启
tcp_tw_recycle,这个选项在多个客户端位于同一NAT后面时会严重误杀连接,在Linux 4.12之后已经被移除了
从负载均衡器角度看这个问题
如果你的服务前面挂着Nginx、HAProxy或SLB,还有一层逻辑要处理。
Nginx对上游服务器的连接默认也会做keepalive配置:
upstream backend {
server 10.0.0.1:8080;
keepalive 32;
keepalive_timeout 30s;
}
keepalive_timeout决定了Nginx与上游之间空闲连接保持多久,调短它,能加速让上游释放闲置连接。
同时检查Nginx本身的代理超时:
proxy_connect_timeout 5s; proxy_send_timeout 10s; proxy_read_timeout 10s;
这几个值如果设得太大,原本已经断开的连接在Nginx看来还“活着”,就会一直转发请求到已经半死的后端连接上。
怎么避免出现这个问题
比起修复,更重要的是预防,以下是几个实操节点:
- 业务协议层加心跳:
SO_KEEPALIVE只管网络层,业务心跳才能感知到应用层的假死,推荐30秒一次ping。 - 超时时间分级:常规请求10秒超时,长轮询30秒超时,超过阈值的连接直接断开。
- 定期巡检连接池:对使用数据库连接池、Redis连接池的服务,定期验证池内连接是否可用,不要拿一个已断开的连接去执行查询。
- 服务启动时自动清理旧socket文件:如果用的是Unix domain socket,重启前先删掉
.sock文件。
业内专家经常提到的调试技巧
当怀疑连接没完全断开时,最快的排查路径是:
# 查看哪些连接处于异常状态 ss -tanp | grep -E 'CLOSE_WAIT|FIN_WAIT_2|SYN_RECV' # 结合进程看 ss -tanp | grep <pid> # 抓包看连接是否还在通信 tcpdump -i eth0 port 8080 -nn -c 100
tcpdump的结果里如果只有客户端发来的SYN,但没有服务器回应的SYN-ACK,那大概率是半连接队列满了,检查ss -lnt输出里的Recv-Q和Send-Q,Recv-Q超过net.core.somaxconn就说明backlog太小。
一个典型场景的完整处理流程
假设你管理一台简米云ECS,跑的是Java后端,端口8080,某天用户反馈连不上服务,但进程还在,你该这样做:
ss -tanp | grep 8080查看连接状态分布- 发现大量
SYN_RECV,说明并发连接握手没能完成 netstat -s | grep -i listen查看是否有socket溢出- 检查
/proc/sys/net/ipv4/tcp_max_syn_backlog,发现只有256 - 调大到4096,同时把
net.core.somaxconn调大到1024 - 在Java端设置
server.acceptCount()或backlog参数,保证两者匹配 - 之后再看
ss -lnt,Send-Q不再积压
这类问题在实际运维中很常见,按照这个顺序排查基本10分钟内能定位。
相关Q&A
TCP连接未正常关闭会不会影响服务器性能?
会,每一条未关闭的连接都要占用一个文件描述符和一小块内核内存,当异常连接多到逼近ulimit -n上限时,新来的正常连接会直接被拒绝,表现为Too many open files,大量半开连接还会导致/proc/net/tcp等内核结构体遍历变慢,间接影响整体转发性能。
设置了SO_KEEPALIVE后连接多久能被检测到断开?
述参数为例:空闲30秒开始探测,每隔5秒探测一次,连续3次无响应即断开,总耗时约45秒,需要注意的是,TCP_KEEPIDLE的计时基于最后一次数据交互,而非上一次探测;且该计时在发送数据后会重新开始计算,在内核默认配置下这个过程长达7200秒,所以必须显式调整。
客户端重连时收到Connection refused是怎么回事?
服务器端进程活着,但监听socket的accept队列已经满了,或者服务器根本没有在监听该端口,排查时用ss -lnt确认LISTEN状态是否正常,再用ss -lnt | grep 你的端口查看Recv-Q是否长时间非零,若是则说明有连接积压未调用accept()处理,这时需要检查业务代码接收连接的速度是否匹配得上并发量。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/787034.html


