2026/10/26 2:43:30

Linux FIN_WAIT1 大量堆积与孤儿 Socket 排查治理

Linux FIN_WAIT1 大量堆积与孤儿 Socket 排查治理 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_MAX120 秒封顶然后按 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_conntracktoo many orphaned sockets说明孤儿数触顶已经在 RST 连接了nf_conntrack: table full, dropping packet说明连接跟踪表满了新包直接被丢——这个后面会单独展开讲它是FIN_WAIT1的一个高频帮凶。2.2 按端口和对端地址做二维聚合光知道总数没用得知道是谁在制造这些连接。我习惯先用本地端口做一次聚合看看是不是集中在某一个服务上ss -tan state fin-wait-1 \ | awk NR1{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:Portsplit之后取最后一段对 IPv4 就是端口号。如果发现某 8 成以上的FIN_WAIT1都集中在同一个端口那问题基本就锁定在这个服务上了。接着按对端地址聚合判断是不是某个下游集群整体异常ss -tan state fin-wait-1 \ | awk NR1{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-Qss输出的第三列通常是 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 NR1{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_retries3 sysctl -w net.ipv4.tcp_max_orphans65536想持久化就写进/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.RawConnraw, 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 NN 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 HikariCPidleTimeout3000005 分钟Java HttpClientevictIdleConnections300 秒Go database/sqlSetConnMaxIdleTime5 分钟Go database/sqlSetConnMaxLifetime30 分钟Nginx upstreamkeepalive_timeout60 秒除了空闲回收还有两个配套配置值得一起加上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(i1;iNF;i) if($iorphan) print $(i1)} /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是ESTABLISHED04是FIN_WAIT106是TIME_WAITawk NR1{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这两个关键字也做成告警。这两类日志一旦出现基本就意味着已经到了故障阶段比任何计数指标都更直接、更不容误判。