FIN_WAIT1这个状态,我在几个不同业务线的机器上都遇到过——第一次看到的时候,一台 8 核机器上ss -tan state fin-wait-1的输出滚了好几屏,最多的时候接近两万条,而当时业务 QPS 根本不高。更诡异的是,用netstat看ESTABLISHED反而很干净。真正让我警觉的是cat /proc/net/sockstat里那行TCP: ... orphan 19xxx——孤儿 socket 逼近上限,一旦打满,新的连接会被内核直接 RST 掉,表现出来就是零星的、毫无规律的连接失败,排查起来极其折磨人。
很多人第一次碰到这个问题会往TIME_WAIT上靠,因为那是被讲烂的老话题。但FIN_WAIT1的成因、危害和处理思路跟TIME_WAIT完全是两码事:TIME_WAIT是优雅关闭的必然代价,量大一般只是端口紧张;FIN_WAIT1卡住则说明本端已经主动发了 FIN,但对方连一个 ACK 都没回,或者回了但在半路被吞掉了。这意味着连接的两端至少有一方处于异常状态。
下面这些内容,是我从"发现告警—采样定位—抓包定性—参数调整—应用层改造"这条完整链路上攒下来的东西,适合手上管着 Linux 服务器、写网络服务端代码、或者正在被"连接数异常"告警骚扰的同学参考。即使你只懂一点 TCP,跟着思路走也能复现。
1. FIN_WAIT1 卡住的本质:FIN 发出去了,回执没回来
1.1 主动关闭方的状态是怎么一步步走的
先把四次挥手在主动关闭方这边的状态迁移捋清楚。应用层调用close()之后,内核如果发送缓冲区里还有数据,会先把数据发完,然后发一个 FIN 报文,同时 socket 状态从ESTABLISHED切到FIN_WAIT1。此时内核启动的是重传定时器,等待对方回两样东西:对 FIN 的 ACK,以及对方自己的 FIN。
如果 ACK 到了、对方的 FIN 还没到,状态进入FIN_WAIT2,这时候内核挂上的是FIN_WAIT2 定时器,时长由net.ipv4.tcp_fin_timeout控制(默认 60 秒),超时后直接销毁 socket。如果 ACK 和 FIN 一起来了,那就是最理想的情况,直接进入TIME_WAIT,挂 2MSL 定时器。
问题就出在第一个环节:FIN_WAIT1阶段等待的是重传定时器,而这个定时器的退避策略非常"有耐心"。它走的是 TCP 标准的指数退避,重传间隔从 200ms 开始翻倍,一直涨到TCP_RTO_MAX(120 秒)封顶,然后按 120 秒的间隔一直重试。具体重试多少次,孤儿 socket 场景下由tcp_orphan_retries决定,而它的默认值是 0,含义是沿用tcp_retries2那套算法。默认tcp_retries2 = 15,按标准退避公式算下来,总时长在 900 秒以上,也就是十几分钟。
十几分钟是什么概念?一台机器如果每秒有 100 个连接因为对端异常而卡在FIN_WAIT1,那稳定状态下就会有六万多个 socket 挂在FIN_WAIT1,内存和端口被大量占用,同时tcp_orphan_count一路狂飙。这就是"大量FIN_WAIT1"的形成机制——不是某一次关闭出了问题,而是每一个卡住的连接都要占用十几分钟的时间窗口,再加上业务侧持续产生新连接,两个因素叠乘,量级很快就上去了。
1.2 为什么它比 TIME_WAIT 更值得单独盯
我见过不少团队把TIME_WAIT的治理经验直接套过来,tcp_tw_reuse、tcp_max_tw_buckets一通改,结果对FIN_WAIT1毫无作用,白折腾一场。这两个状态在本质上有三点不同,理解清楚这三点,后面的排查方向才不会跑偏。
第一是责任方不同。TIME_WAIT是主动关闭方为了更好地处理延迟报文而刻意保留的,属于协议的正常设计,本端完全可控;FIN_WAIT1卡住则说明对方的协议栈或者中间链路没能完成应答,问题在本端之外。你改本端参数只能缩短"等待的耐心",改不了"对方不回"这个事实。
第二是参数作用域不同。很多人以为tcp_fin_timeout能管住FIN_WAIT1,这是个非常普遍的误解。翻一下内核源码就知道,tcp_fin_timeout是在tcp_fin_timeout()函数里被读取的,而这个函数只在进入FIN_WAIT2状态、需要设置 FIN_WAIT2 定时器时才会被调用。FIN_WAIT1阶段根本走不到那个分支,改它纯粹是心理安慰。
第三是危害路径不同。TIME_WAIT堆积主要消耗本地端口和一小块内存,对服务端来说影响有限;FIN_WAIT1堆积除了占端口,还会持续占用文件描述符残留的内核对象、计入tcp_orphan_count,一旦触到tcp_max_orphans上限,内核会对新的孤儿 socket 直接发 RST,同时dmesg里开始刷TCP: too many orphaned sockets。这时候你的服务就开始出现"莫名其妙的连接被重置",而这种错误在应用层日志里通常表现为Connection reset by peer,非常容易误判成对端服务挂了。
一个判断小技巧:
FIN_WAIT1里如果对应 socket 还有文件描述符被应用持有(ss -tanp能看到 pid),那它还没变成孤儿,不计入 orphan 计数;只有应用已经close()、ss -tanp看不到进程信息的,才是真孤儿。这两类要分开统计,处理手段也不一样。
2. 现场采样:先分清"瞬时波动"还是"粘住不走"
2.1 三条命令搞定状态分布与存活时长
冲到机器上第一件事不是改参数,而是判断这些FIN_WAIT1到底活了多久。这个判断直接决定后续方向:如果每条只活几秒,那是业务高峰期的正常波动,几十上百条完全不用管;如果一条能活好几分钟甚至十几分钟,那才是真问题。
第一条命令看全局状态分布:
ss -s输出里会给出TCP:后面的总连接数、各状态计数。我更习惯用/proc/net/sockstat,因为它是一行文本,方便脚本解析:
cat /proc/net/sockstat输出形如TCP: inuse 1024 orphan 856 tw 3200 alloc 2048 mem 12。这里的orphan字段就是当前孤儿 socket 总数,所有处于FIN_WAIT1、FIN_WAIT2、LAST_ACK、CLOSING且应用已关闭 fd 的 socket 都算在内。这个数字如果长期维持在几千以上,就得认真对待了。
第二条命令看具体的FIN_WAIT1明细,关键是加-o参数把定时器信息打出来:
ss -tan state fin-wait-1 -o-o会在最后多出一列timer:(...),格式类似timer:(on,1.234ms,3)。第一个字段on表示定时器正在运行,第二个是距离下次重传还剩多少时间,第三个是已重传次数。如果重传次数那一列已经涨到 5、6 甚至更高,同时剩余时间显示成1min以上,那基本可以认定这条连接的对端是彻底失联了,不用再指望它回 ACK。
如果ss的 state 写法在你的发行版上不认,用最朴素的过滤也行:
ss -tan | grep FIN-WAIT-1第三条命令看内核有没有开始抱怨:
dmesg -T | grep -Ei 'orphan|conntrack|dropping|nf_conntrack'too many orphaned sockets说明孤儿数触顶,已经在 RST 连接了;nf_conntrack: table full, dropping packet说明连接跟踪表满了,新包直接被丢——这个后面会单独展开讲,它是FIN_WAIT1的一个高频帮凶。
2.2 按端口和对端地址做二维聚合
光知道总数没用,得知道是谁在制造这些连接。我习惯先用本地端口做一次聚合,看看是不是集中在某一个服务上:
ss -tan state fin-wait-1 \ | awk 'NR>1{split($4,a,":"); print a[length(a)]}' \ | sort | uniq -c | sort -rn | head -20这里的$4是ss输出里的本地地址端口列(列序为 State、Recv-Q、Send-Q、Local Address:Port、Peer Address:Port),split之后取最后一段,对 IPv4 就是端口号。如果发现某 8 成以上的FIN_WAIT1都集中在同一个端口,那问题基本就锁定在这个服务上了。
接着按对端地址聚合,判断是不是某个下游集群整体异常:
ss -tan state fin-wait-1 \ | awk 'NR>1{print $5}' | sed 's/:[0-9]*$//' \ | sort | uniq -c | sort -rn | head -20用sed去端口而不是awk -F:,是为了兼容 IPv6 地址里带冒号的情况——这个小细节我踩过一次,当时用awk -F:处理 IPv6,结果统计出来全是::1之类的碎片,白忙活半小时才反应过来。
如果发现对端地址高度集中在几台机器上,那大概率是那几台机器出了问题:进程挂死、内核丢包、网卡异常、或者干脆已经被容器编排系统干掉了但连接表还没清。这时候直接去对端查,比在本端猜效率高得多。
还有一个更细的口径——按"本端 IP + 对端 IP" 组合看,能快速区分是"某条链路整体不通"还是"某个服务端普遍不回"。如果只走某一块网卡出去的连接全卡住了,那多半是这块网卡的队列、路由或者上游交换机的问题,而不是应用代码。
3. 五类真实成因,对端不回 ACK 只是其中一种
3.1 应用 close 之后 socket 变孤儿,内核替它死扛
这是最常见的一类。应用层代码里调了close(fd),但发送缓冲区里还有数据没发完,内核会把 fd 对应的 file 结构释放掉,但 socket 本身要继续把数据发完、把 FIN 发出去、等对方回应,于是变成"孤儿"。此时ss -tanp看不到任何进程信息,tcp_orphan_count加一。
孤儿状态下,内核的重传会一直重试到tcp_orphan_retries规定的次数。前面算过,默认配置下这个时间窗口在十几分钟量级。也就是说,只要对端在这十几分钟内始终不回 ACK,这个 socket 就会一直占着资源。
这种情况的典型特征是:FIN_WAIT1的发送队列Send-Q(ss输出的第三列)通常是 0,因为 FIN 之后没有业务数据了;重传次数持续增长;对端地址分散但集中在某个集群。
要验证是不是真的"对端不回",最直接的办法是抓包,看本端发出的 FIN 有没有对应的 ACK 回来:
tcpdump -i any -nn 'tcp[tcpflags] & tcp-fin != 0 and host 10.0.0.12'如果要精确定位某一条连接,可以带端口:
tcpdump -i any -nn 'host 10.0.0.12 and port 8080 and tcp[tcpflags] & tcp-fin != 0'如果看到本端反复发 FIN(间隔呈 200ms、400ms、800ms……的指数增长),而对端一个包都没有,就坐实了。如果看到对端回了 RST,那说明对端根本不认识这条连接了——这种情况通常出现在对端进程重启、连接状态丢失之后,属于对端的锅,本端只能靠缩短重传次数尽快放手。
3.2 对端主机已经消失:宕机、失联、容器被驱逐
第二类是物理层面的"对端没了"。裸机上表现为宕机、断网、机房抖动;容器环境下更常见——Pod 被驱逐或者 OOM Kill,节点上的 veth 被回收,原来的连接瞬间变成黑洞。
这类场景有个很迷惑人的特征:连接并不是一下子全断,而是零星地开始卡。因为容器编排系统的下线有延迟,节点上的路由、ARP 表项、连接跟踪表项需要时间老化,在这个时间差里,已经建立的连接就像断线的风筝一样飘在FIN_WAIT1里。
我在一个跑在容器平台上的服务上见过一次,某个下游实例被滚动更新干掉之后,上游机器上有将近 3000 条连接卡在FIN_WAIT1,而且这些连接的重传定时器全都跑满了退避,每两分钟往外发一个 FIN,白白占着 fd 和内存。后来查下来,是因为这个服务的客户端连接池没有做实例健康检查,池子里还留着一堆指向已下线实例的连接。
这里给一个可操作的验证方法:把所有FIN_WAIT1的对端 IP 提取出来,跟当前的下游实例列表做比对。
ss -tan state fin-wait-1 | awk 'NR>1{print $5}' | sed 's/:[0-9]*$//' | sort -u如果这串地址里有相当一部分已经查不到对应的实例,或者ping/nc都不通,那八成就是这一类。
3.3 conntrack 表满与中间设备表项老化,ACK 在半路被吃掉
这一类是我个人觉得最有意思、也最容易漏掉的。本端和对端其实都正常,FIN 发出去了,对端的 ACK 也确实回了,但ACK 在中间某一跳被静默丢弃了,导致本端一直以为对方没收到。
最常见的两个"凶手"是连接跟踪表和中间网络设备。
连接跟踪表满的情况,在内核日志里会有非常明确的记录:
dmesg -T | grep -i conntrack # nf_conntrack: table full, dropping packetnet.netfilter.nf_conntrack_max默认值跟内存相关,常见是 65536 或者按内存/16K计算。一旦并发连接数超过它,新包直接被DROP。注意是DROP不是REJECT,所以对端完全不知道自己发的包没到,本端也就只能傻等重传。这种情况改tcp_*参数毫无用处,必须调nf_conntrack_max或者缩短nf_conntrack_tcp_timeout_established(默认值高得离谱,有些版本是 432000 秒)。
中间设备的情况更隐蔽:负载均衡、NAT 网关、云上的虚拟网络设备,为了节省表项会设置连接空闲老化时间。如果一条连接空闲太久,设备悄悄把表项删了,之后的包就直接被丢掉,既不发 RST 也不发 ICMP。这在本端看就是"连接还在,但发什么都石沉大海"。
判断方法:看FIN_WAIT1的连接是不是集中在某一段 IP 网段,或者是不是只在跨可用区、跨网段的调用上出现。如果同机房的调用一切正常,跨网段的调用大量卡FIN_WAIT1,那基本可以断定是中间链路上的问题。
3.4 孤儿 socket 数量触顶之后的连锁反应
net.ipv4.tcp_max_orphans这个参数默认值在不同内核版本上差异很大,老一些的版本是 8192,新版本会按内存大小动态计算,可能到十几万。但不管多少,只要被填满,内核的行为是:对新的孤儿 socket 直接发 RST 并回收。
这个动作会引发一个恶性循环。原本一条连接如果卡在FIN_WAIT1,十几分钟后重传耗尽,自然回收;现在因为数量触顶,内核提前 RST 掉新的孤儿 socket,导致对端收到 RST 后可能触发重连,重连后又可能因为同样的原因再次关闭、再次进FIN_WAIT1、再次被 RST……连接在两端之间反复拉扯,FIN_WAIT1的计数始终降不下来。
此时dmesg里会持续刷:
TCP: too many orphaned sockets看到这行日志,说明已经不是"预防"阶段了,是在救火。这时候正确的动作是:先把tcp_max_orphans临时调高,给系统争取时间,然后立刻定位是哪个服务在产生孤儿,从源头掐断。直接调高只是止血,不解决根本问题,因为孤儿数还会继续涨,只是从"立刻 RST"变成"晚点再 RST"。
3.5 长连接池没有半开检测,把僵死连接一直留在表里
最后一类跟应用架构相关,也是很多"大厂规范"里反复强调但总有人忽略的点。
长连接池为了减少握手开销,会把连接缓存起来复用。如果池子里某条连接对应的对端已经悄无声息地消失了(比如被中间设备老化、对端进程无响应),而池子又没有做有效性检测,那么这条连接会一直躺在池子里。等到某个请求真的复用它、往里写数据时,才会发现写不出去——这时候触发关闭流程,进入FIN_WAIT1,然后又是漫长的重传等待。
更糟的是,如果请求本身超时了,应用层调用close()放弃这条连接,那它就正式变成孤儿。一个连接池里如果有几十条这样的僵死连接,再加上请求高峰期频繁复用,就能稳定地制造出成百上千条FIN_WAIT1。
判断办法很直接:看你服务的连接池配置里有没有idle timeout、max lifetime、test on borrow或者心跳检测。如果这几项全是默认值或者干脆没配,那几乎可以确定问题就出在这里。
4. 内核参数怎么调才不反噬
4.1 别再对 tcp_fin_timeout 下手了
这一点值得单独拎出来说,因为改错旋钮的代价是零收益加上心理上的假安心。
net.ipv4.tcp_fin_timeout的作用范围只有一个:当 socket 处于FIN_WAIT2且已成为孤儿时,决定这个状态能停留多久。默认 60 秒,指的是从进入FIN_WAIT2到被销毁的时间。
FIN_WAIT1阶段根本不看这个值。你把它改成 5 秒,FIN_WAIT1的数量一个都不会少。虽然FIN_WAIT2也是孤儿状态、也计入tcp_orphan_count,但通常FIN_WAIT2的量远小于FIN_WAIT1,因为它 60 秒就没了,而且进入FIN_WAIT2需要对端至少回了 ACK——这就已经说明链路是通的。
我的建议是:tcp_fin_timeout保持默认值即可,如果确实要缩短,最多调到 30,再小就可能误杀正常的半关闭连接(有些协议就是靠半关闭传递 EOF)。
4.2 tcp_orphan_retries 与 tcp_max_orphans 的配合
真正管FIN_WAIT1时长的是net.ipv4.tcp_orphan_retries。
它的默认值是 0,含义是"使用tcp_retries2那套退避算法"。你可以显式设成 2 或 3,效果是:孤儿 socket 在FIN_WAIT1阶段重传满 2 到 3 次后就会被强制关闭。按指数退避算,3 次大约对应 1.4 秒左右的等待(200ms + 400ms + 800ms),加上初始的一次发送,整体停止等待的时间会大幅缩短。
这里有个权衡必须说清楚:调小这个值意味着"更快放弃"。如果对端只是短暂拥塞,本来重传第 4 次就能成功,你把重试次数砍到 2,那这条连接就会被过早关闭,可能出现数据丢失或者业务报错。所以这个参数适合的场景是:你已经确认大量FIN_WAIT1的对端都是真的失联了,宁可快速失败也不要长时间挂着。
我的实际取法是:
net.ipv4.tcp_orphan_retries = 3 net.ipv4.tcp_max_orphans = 65536tcp_orphan_retries = 3把最长等待从十几分钟压到 1-2 秒量级,效果立竿见影;tcp_max_orphans调到 6 万多,给高峰期留足缓冲,避免因为瞬时堆积触发 RST。
修改方式:
sysctl -w net.ipv4.tcp_orphan_retries=3 sysctl -w net.ipv4.tcp_max_orphans=65536想持久化就写进/etc/sysctl.d/99-tcp.conf,然后sysctl -p /etc/sysctl.d/99-tcp.conf生效。注意这两项都可以在线改,不需要重启,对存量连接的影响是下一次重传定时器触发时按新值判断,所以改完要等一小会儿才能看到效果。
参数对比速查:
tcp_fin_timeout管FIN_WAIT2淘汰;tcp_orphan_retries管FIN_WAIT1的重传次数;tcp_max_orphans是孤儿总数的硬上限;tcp_max_tw_buckets只管TIME_WAIT。四个旋钮各管各的,别搞混。
4.3 TCP_USER_TIMEOUT:应用层最直接的一刀
如果应用代码可控,我更推荐用TCP_USER_TIMEOUT这个 socket 选项。它由应用在建立连接时设置,语义是"一个已发送但未被确认的数据(包括 FIN)最多可以等多久",超时后内核直接强关连接。
这个选项的好处是精准。它不像内核全局参数那样影响所有 socket,而是可以按业务分开设置:对延迟敏感的服务设 5 秒,对可靠性要求高的设 30 秒,互不干扰。
C 语言里的用法:
#include <netinet/tcp.h> int timeout_ms = 5000; if (setsockopt(fd, IPPROTO_TCP, TCP_USER_TIMEOUT, &timeout_ms, sizeof(timeout_ms)) < 0) { perror("setsockopt TCP_USER_TIMEOUT"); }Go 语言可以用net.TCPConn加syscall.RawConn:
raw, err := conn.SyscallConn() if err != nil { return err } raw.Control(func(fd uintptr) { syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, syscall.TCP_USER_TIMEOUT, 5000) })Java 这边没有原生的标准 API,得靠反射或者 JNI 调setsockopt,一般要么用 Netty 提供的EpollChannelOption.TCP_USER_TIMEOUT,要么直接放弃改用连接池层面的超时控制。
需要特别注意一点:这个选项必须在连接还活着的时候设置。如果 socket 已经close()变成孤儿了,再想设也设不上——那时候内核已经不再接受用户态的任何操作。所以它必须在建连完成的钩子里做,不能等到发现问题才补。
另一个坑是:TCP_USER_TIMEOUT和 keepalive 是两套独立机制,两者同时开启时,谁先触发谁生效。keepalive 检测的是"连接空闲了多久没数据",TCP_USER_TIMEOUT检测的是"发出去的数据多久没被确认",语义不同,别指望配一个就万事大吉。
5. 应用侧才是根治点:关闭姿势与连接池治理
5.1 shutdown 与 close 的先后顺序
很多代码里只有close(fd),这本身没错,但如果业务上需要先告诉对端"我不再发送数据了",那用shutdown(fd, SHUT_WR)会比直接close更优雅。
区别在哪儿?shutdown(SHUT_WR)会立即发送 FIN,但保留接收方向,应用还能继续读对方发来的数据;close则是释放整个 fd,如果有其他线程还在read这个 fd,行为就变得不确定了。
在需要处理长连接半关闭协议的场景里(比如某些自定义的 RPC 协议、文件传输协议),正确姿势是:
// 1. 告诉对端我不发了 shutdown(fd, SHUT_WR); // 2. 继续读,直到读到 EOF(对端也发完并关闭) while ((n = read(fd, buf, sizeof(buf))) > 0) { // 处理对端发来的剩余数据 } // 3. 最后才真正释放 close(fd);这样做的好处是,shutdown之后 socket没有变成孤儿,因为它还被应用持有,理论上内核不会把它算进tcp_orphan_count,也不受tcp_orphan_retries的提前放弃策略约束。当然代价是应用要自己控制读循环的超时,否则对端一直不发 FIN,你的线程就阻塞在那儿了。
反过来说,如果业务上根本不关心对方还会不会发数据,那直接close也没问题,反正数据都发完了,早关晚关一个样。关键是不要在没有读超时的循环里直接close之后继续持有引用,那种情况最容易出内存泄漏和 fd 泄漏。
5.2 SO_LINGER 的两种极端取值和它们的代价
SO_LINGER是另一个常被误用的选项。它有三个典型取值,代价各不相同。
第一种,l_onoff = 1, l_linger = 0。这是"立刻关闭"模式,close()时内核不发 FIN,直接发 RST 并把连接扔掉。好处是 socket 立刻消失,不进FIN_WAIT1也不进TIME_WAIT;坏处是发送缓冲区里未发出的数据全部丢弃,而且对端会收到 RST,可能认为这是异常断开。
第二种,l_onoff = 1, l_linger = N(N > 0)。close()会阻塞最多 N 秒,等待数据发完。这能保证数据完整性,但会阻塞调用线程,在高并发场景下是灾难——一个线程卡住,整个连接池的吞吐就掉下来了。
第三种,l_onoff = 0。这是默认行为,close()立即返回,内核在后台异步完成发送和关闭。这也是我推荐的配置。
用SO_LINGER = {1, 0}来"消灭FIN_WAIT1"是个非常诱人的捷径,网上也确实有很多文章这么推荐。但我个人的看法是:除非你明确知道丢数据可以接受,否则不要用。RST 强行断开会让对端把这次调用标记为失败,如果对方有重试机制,就会引发一波重试风暴;而且 RST 会绕过所有优雅关闭的保障,在涉及事务、幂等性的场景里风险极高。
5.3 连接池的空闲回收时间必须短于中间设备的空闲老化
这条是我认为性价比最高的一条应用层改造,因为它不需要改任何代码逻辑,只是配个参数。
原理很简单:中间设备(NAT 网关、负载均衡、云网络)会把长时间没有数据往来的连接表项删掉,但删的时候不会通知两端。如果连接池里的空闲连接存活时间比设备的老化时间还长,那么连接池里就会出现一批"看起来是活的、实际上是死的"连接。等到业务高峰期复用时,写进去的数据石沉大海,然后触发关闭流程,进FIN_WAIT1。
常见的设备空闲老化时间在 300 秒到 900 秒之间,不同云厂商、不同设备差异很大,得实测确认。稳妥的做法是:连接池的 idle timeout 设为设备老化时间的一半。比如设备是 600 秒,那连接池就设 300 秒。
主要框架的配置项大致是这些:
| 框架/语言 | 关键配置项 | 建议值 |
|---|---|---|
| Java HikariCP | idleTimeout | 300000(5 分钟) |
| Java HttpClient | evictIdleConnections | 300 秒 |
| Go database/sql | SetConnMaxIdleTime | 5 分钟 |
| Go database/sql | SetConnMaxLifetime | 30 分钟 |
| Nginx upstream | keepalive_timeout | 60 秒 |
除了空闲回收,还有两个配套配置值得一起加上:
- max lifetime:即使连接一直有流量,也强制在 N 分钟后重建一次。这一条能兜住"某些设备按连接总时长而非空闲时长老化"的情况。
- test on borrow / 借用前校验:从池里取连接时先做一次轻量探测。代价是每次多一次系统调用,但能避免"借到死连接"这种最坏情况。对于数据库连接池,很多驱动支持
validationQuery,配个SELECT 1就行。
5.4 keepalive 和业务心跳的分工
TCP keepalive 和业务层心跳经常被混为一谈,其实它俩解决的是不同层面的问题。
TCP keepalive 由内核发起,默认要空闲7200 秒(2 小时)才开始探测,这个默认值在实际业务里几乎没有意义。可以通过net.ipv4.tcp_keepalive_time调整,也可以按 socket 单独设置:
int keepalive = 1; int keepidle = 60; int keepintvl = 10; int keepcnt = 3; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));这一段的意思是:空闲 60 秒后开始探测,每隔 10 秒发一次,连续 3 次没响应就判定连接死亡。总耗时 90 秒,比默认的两小时靠谱多了。
但要注意,keepalive 只能探测到"对端内核是否响应",探测不到"对端应用是否还能处理业务"。如果对端进程 hang 死但内核还在,keepalive 依然会得到正常的 ACK 响应,连接看起来是好的。
所以业务层心跳是有必要的,尤其是长连接场景。心跳报文要带上业务语义(比如一个轻量的PING请求,期待一个特定格式的PONG响应),并且要有超时。超时之后主动close,让连接尽快进入正常的关闭流程,而不是等到真正发业务请求时才发现写不出去。
6. 让问题提前暴露:监控口径与告警阈值
6.1 采集脚本和关键指标
等dmesg开始刷日志才反应,那就太晚了。我习惯在每台机器上放一个轻量的采集脚本,把关键指标按分钟打到日志或者推给监控系统。
#!/bin/bash TS=$(date '+%F %T') FW1=$(ss -tan state fin-wait-1 2>/dev/null | tail -n +2 | wc -l) ORPHAN=$(awk '/^TCP:/{for(i=1;i<=NF;i++) if($i=="orphan") print $(i+1)}' /proc/net/sockstat) ESTAB=$(ss -tan state established 2>/dev/null | tail -n +2 | wc -l) TW=$(ss -tan state time-wait 2>/dev/null | tail -n +2 | wc -l) echo "$TS fin_wait1=$FW1 orphan=$ORPHAN established=$ESTAB time_wait=$TW"这段脚本有三个细节值得说。第一,tail -n +2是为了去掉ss输出的表头行,否则每次统计都会多 1。第二,/proc/net/sockstat里orphan字段的位置会随内核版本变化(有的版本没有sockets:行),所以用for循环按字段名找,比硬编码$5稳。第三,ss在连接数特别多的时候会慢(它要遍历整个 socket 表),所以采集频率别太高,一分钟一次足够,高峰期可以适当降频。
如果机器上的连接数在十万量级,ss的遍历可能耗几百毫秒甚至几秒,这时候可以考虑直接读/proc/net/tcp自己解析状态码。状态码01是ESTABLISHED,04是FIN_WAIT1,06是TIME_WAIT:
awk 'NR>1{c[$4]++} END{for(s in c) print s, c[s]}' /proc/net/tcp这个方式快得多,但需要自己做状态码映射,可读性差一些。我的做法是日常用ss,做高频采集时用/proc/net/tcp。
6.2 阈值怎么定才不误报
很多人一开始就把告警阈值设成"FIN_WAIT1> 100 就告警",结果一天被叫醒十几次,最后干脆把这个告警静音了。阈值必须结合持续时长来定,而不是单看瞬时数量。
我的经验口径是这样的:
| 场景 | 瞬时数量 | 持续时长 | 处理建议 |
|---|---|---|---|
| 正常短连接业务 | 几十到几百 | 秒级 | 忽略 |
| 高峰波动 | 上千 | 1-2 分钟 | 观察,不告警 |
| 疑似异常 | 上千 | 持续 5 分钟以上 | 触发告警 |
| 明确故障 | 超过tcp_max_orphans的 50% | 持续 2 分钟 | 立即介入 |
关键是要取连续 N 个采样点都超标才告警。比如每 30 秒采一次,连续 10 次(5 分钟)都超过 2000 就告警。这个"连续"条件能过滤掉绝大部分高峰期抖动。
另外,orphan计数比FIN_WAIT1计数更值得告警。因为orphan是所有已关闭但未完全销毁的 socket 总和,它直接反映内核对资源的上限压力,而且这个数字不受状态分类口径影响,跨内核版本都可比较。我会给orphan设两条线:超过 30% 是警告,超过 50% 是严重。
还有一个特别容易被忽略的指标——FIN_WAIT1的增速。如果每分钟新增几百条,即使当前总数还不高,也说明后端有大量连接在异常关闭,值得提前看一眼。这个指标可以从前面的脚本里做差值算出来,实现成本很低。
最后分享一个小技巧:把dmesg里出现too many orphaned sockets或nf_conntrack: table full这两个关键字也做成告警。这两类日志一旦出现,基本就意味着已经到了故障阶段,比任何计数指标都更直接、更不容误判。