1. 先从一次让我失眠的连接堆积说起
做 Linux 服务排障这些年,TCP 连接监控大概是让我交过最多学费的领域。印象最深的是某次半夜值班,告警平台提示一个内部管理服务的 TCP 连接数超过 10000,我睡眼惺忪地登上去,第一条命令就是netstat -an | grep TIME_WAIT | wc -l,输出接近三万行。当时第一反应是"连接堆积了,端口要耗尽",甚至准备改内核参数。
但冷静下来重新统计,真正处于ESTABLISHED状态的连接只有八百多个,业务进程响应正常,CPU、内存都没有异常。原来那一大批TIME_WAIT只是压测流量结束后正常回收的连接残留,根本不算故障。真正的问题是告警脚本把"连接总数"和"活跃连接数"混淆了,导致误报。而我在那个瞬间差点因为对状态的误读,去改一个可能引发线上事故的内核参数。
那次之后我意识到,TCP 连接监控这件事,光会敲几条命令远远不够。你得知道这些命令读的是内核里的什么数据结构,每种状态意味着什么,工具输出为什么会有差异,以及高并发场景下哪些统计方式会骗人。这篇文章我就把自己踩过的坑和积累的方法完整写一遍,适合刚入门 Linux 网络排障的开发者,也适合被"连接数暴涨"困扰过的运维同学。
2. 内核记账本:/proc/net/tcp 里到底记了什么
2.1 先看内核原生长什么样
Linux 内核会把当前网络命名空间内所有 TCP socket 的状态实时记录在/proc/net/tcp中,IPv6 对应/proc/net/tcp6。不要把它当成普通文件,这是一个伪文件,每次读取时内核现场生成一份快照,所以文件内容是动态的。
用cat /proc/net/tcp可以看到类似下面的输出:
sl local_address rem_address st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 20136 1: 0100007F:9C4C 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 0 0 20138每一个非表头行就是一条连接记录。和netstat输出相比,它没有可读的状态名称,没有域名解析,也没有"监听说明",信息密度很高,但一眼看上去全是十六进制,劝退了不少人。实际上只要把每个字段拆开看,它比任何工具都诚实。
2.2 字段逐个拆解
| 字段 | 示例 | 含义 |
|---|---|---|
| sl | 0 | 内核 socket 哈希表槽位编号,无实际业务含义 |
| local_address | 0100007F:1F90 | 本地 IP 和端口,十六进制表示 |
| rem_address | 00000000:0000 | 远端 IP 和端口,全零代表尚未建立对端 |
| st | 0A | 连接状态,十六进制编码 |
| tx_queue:rx_queue | 00000000:00000000 | 发送队列/接收队列的字节数 |
| tr:tm->when | 00:00000000 | 定时器类型和剩余时间(jiffies 单位) |
| retrnsmt | 00000000 | 当前连接的重传次数 |
| uid | 0 | 创建该 socket 的用户 ID |
| timeout | 0 | 连接的超时时间配置 |
| inode | 20136 | socket 对应的 inode 号,可通过它反查进程 |
这里最有用的是st和inode。st决定连接处于什么阶段,inode是把连接和进程关联起来的桥梁。比如想确认某个连接属于哪个进程,可以ls -l /proc/<PID>/fd,找到指向socket:[20138]的文件描述符,再配合/proc/<PID>/comm看进程名。手写脚本的时候这个思路非常关键。
2.3 十六进制地址转换:别被字节序骗了
/proc/net/tcp里的地址不是我们熟悉的点分十进制,而是小端序十六进制。比如0100007F:1F90,地址部分0100007F要按字节倒过来看:7F 00 00 01,对应127.0.0.1;端口1F90是8080,端口不需要反转,直接十六进制转十进制。
再举一个文档测试地址的例子。假设文件里写的是0F0200C0:C350,解析过程是:0F 02 00 C0倒序后得到C0 00 02 0F,也就是192.0.2.15,端口C350是50000。
IPv6 在/proc/net/tcp6里地址是 32 个十六进制字符,同样按每两个字符一组反转,但处理起来比 IPv4 绕得多。日常排查我基本不会手撕 IPv6 地址,直接用ss更省事。但 IPv4 的转换公式建议背下来,很多最小化环境排查时就靠这个。
2.4 状态码映射:一张表看懂 st 字段
st字段是十六进制数字,和 TCP 状态机一一对应:
| 状态码 | 状态 | 含义 |
|---|---|---|
| 01 | ESTABLISHED | 连接已建立,正在传输数据 |
| 02 | SYN_SENT | 主动连接方发出 SYN,等待 SYN+ACK |
| 03 | SYN_RECV | 服务端收到 SYN,完成握手前的等待 |
| 04 | FIN_WAIT1 | 主动关闭方已发 FIN,等待 ACK |
| 05 | FIN_WAIT2 | 主动关闭方已收到 ACK,等待对端 FIN |
| 06 | TIME_WAIT | 主动关闭方收到对端 FIN,等待 2MSL |
| 07 | CLOSE | 连接关闭,几乎不会出现在监控里 |
| 08 | CLOSE_WAIT | 被动关闭方收到 FIN,等待本地调用 close() |
| 09 | LAST_ACK | 被动关闭方发 FIN 后等待最终 ACK |
| 0A | LISTEN | 监听中的 socket,等待新连接 |
| 0B | CLOSING | 双方同时关闭,正在交换 FIN/ACK |
记住这张表,再去统计连接数时就不会把TIME_WAIT和ESTABLISHED混在一起。后面第 5 章我会详细讲怎么通过状态分布反推故障,这里先建立基础认知。
3. 为什么 netstat、ss、lsof 给出不同答案
3.1 工具的数据来源差异
同样是查连接,三个常见工具的数据来源完全不同:
netstat读取/proc/net/tcp等 proc 文件,然后解析文本输出;ss使用 netlink 套接字与内核通信,通过NETLINK_SOCK_DIAG接口直接查询 socket 信息;lsof遍历/proc/*/fd,把每个进程打开的文件描述符逐个匹配到 socket inode。
打个比方:netstat是翻开账本一页页读,ss是直接问内核"现在有哪些 socket、状态都是什么",lsof则是一个个敲门问"你家有没有这个编号的 socket 文件"。三种方式都能得到结果,但开销差异巨大。
3.2 高连接数下的速度差异
连接数到一两万的时候,netstat -an的耗时明显上升,因为要读取并格式化一大段文本;lsof更夸张,遍历所有进程的所有 fd,在大规模服务器上能卡好几秒。而ss -tan在万级连接下基本是毫秒级返回,这也是为什么新系统都在用ss而不是netstat。
有个实际场景可以说明问题:我之前对一台连接数接近三万的机器做压测,netstat -an光输出就要一秒多,CPU 被拉高好几个百分点。换成ss -tan之后,采集逻辑对系统的影响几乎可以忽略。对于需要高频采集监控数据的场景,工具选型会影响告警系统的稳定。
3.3 统计口径不同会带来误导
netstat -an默认包含LISTEN状态的监听 socket,ss -a同样如此。很多脚本统计"连接数"时把监听端口也算进去,于是每开一个端口就多一个"连接",但这个数字含义完全不同。正确做法是把LISTEN独立看待:端口监听是服务是否启动的标志,而ESTABLISHED才是真正的活跃连接。
另外ss支持按状态过滤,比如ss -tan state established只看建立完成的连接,ss -tan state time-wait只看TIME_WAIT,这在定位问题时非常方便。搞清楚各工具的口径后,不同命令给出"不同答案"就不再奇怪了。
4. 手工解析实验:不靠工具直接统计连接
4.1 用 awk 统计状态分布
在没有ss、netstat也没有 Perl 的极简环境里,/proc/net/tcp是唯一的救命稻草。用一行 awk 就能统计出状态分布:
awk 'NR>1 {cnt[$4]++} END {for (s in cnt) print "状态", s, "数量", cnt[s]}' /proc/net/tcp输出的是十六进制状态码,比如:
状态 0A 数量 3 状态 01 数量 42 状态 06 数量 67对照上一章的状态码表就能知道:3 个监听,42 个活跃连接,67 个TIME_WAIT。如果想直接输出状态名,可以用case或关联数组映射,稍微改一下 awk 就行。
4.2 按端口过滤连接
统计某个端口(比如 8080)的活跃连接数,可以用printf先把端口转成十六进制:
PORT=8080 PORT_HEX=$(printf "%04X" "$PORT") awk -v ph=":${PORT_HEX}" 'NR>1 && index($2, ph)>0 && $4=="01" {n++} END {print n+0}' /proc/net/tcp这里用index($2, ph)判断local_address列是否包含:1F90,再结合$4=="01"筛出ESTABLISHED。print n+0的技巧是为了在没有任何匹配时输出 0 而不是空字符串,避免脚本判断出错。这套组合在我处理过的几个紧急故障里都派上了用场,比装netstat快得多。
4.3 Python 小脚本:实时观察连接变化
如果需要连续观察,写段几十行的 Python 会更舒服。下面这段脚本每隔两秒刷新一次指定端口的连接状态分布:
#!/usr/bin/env python3 import collections import time PORT_HEX = "1F90" # 8080 的十六进制 def decode_ipv4(hex_field): addr_hex, port_hex = hex_field.split(":") # 每两位一组反转字节序 octets = [str(int(addr_hex[i:i+2], 16)) for i in (6, 4, 2, 0)] port = int(port_hex, 16) return ".".join(octets), port def tcp_stats(): states = collections.Counter() with open("/proc/net/tcp") as f: next(f) # 跳过表头 for line in f: parts = line.split() addr = parts[1] st = int(parts[3], 16) local_ip, local_port = decode_ipv4(addr) if local_port == 8080: states[st] += 1 return states while True: stats = tcp_stats() print(time.strftime("%H:%M:%S"), dict(stats)) time.sleep(2)这里decode_ipv4里的(6, 4, 2, 0)就是做字节反转:对0100007F依次取第 6、4、2、0 字节,得到7F 00 00 01。这种基于 proc 文件的轮询方式足够轻量,适合临时排查,但如果要长期采集,频次别太高,每 5 秒一次已经绰绰有余。
4.4 注意 proc 文件快照的成本
/proc/net/tcp每次读取都会现场生成快照,连接的条数越多,读取成本越高。在连接数很大的生产机器上,避免每秒钟cat一次这个文件,否则 CPU 会有可见的上升。我的习惯是:应急排障手动执行没问题,脚本化采集至少间隔 5 秒;如果要做秒级甚至毫秒级监控,老老实实用ss,它的 netlink 机制开销小得多。
5. 用状态分布反推连接生命周期
5.1 一份健康的连接画像长什么样
监控 TCP 状态不是为了看数字,是为了判断连接生命周期是否健康。一个典型的 Web 服务进程,正常情况下应该是:
LISTEN:数量少且稳定,等于服务监听的端口数;ESTABLISHED:与当前并发请求量级相当,有起有伏;TIME_WAIT:跟短连接请求速率正相关,请求结束后上升并逐步回落;CLOSE_WAIT:应该极少,一旦持续堆积几乎肯定是代码问题;SYN_RECV:正常很低,异常飙升需要关注攻击或队列溢出。
当你看到某一类状态异常增长,可以先不看 CPU 不看内存,单凭状态分布就能把问题范围框住。下面几个是我实际排查中最常见到的状态异常,逐个拆开讲。
5.2 CLOSE_WAIT 堆积:代码没关 socket 的典型症状
先说最容易让人崩溃的CLOSE_WAIT。它的产生逻辑是:对方主动关闭连接,发送 FIN 给本机,内核收到 FIN 后回复 ACK,此时连接进入CLOSE_WAIT,等待本地应用程序调用close()来释放 socket。如果应用一直不调用close(),这个连接就会一直停在CLOSE_WAIT。
为什么会出现这种情况?常见原因有几个:代码里创建了 socket 或建立连接后没有走统一的关闭逻辑;线程池里的任务抛异常后 fd 没有归还;阻塞读写没有及时感知对端断开。我在某个模拟项目里见过一次,短连接请求量大时,服务端每漏关一个连接就多一个CLOSE_WAIT,最终把进程的文件描述符耗尽,新连接直接Too many open files。
排查步骤一般是:
ss -tanp state close-wait找到卡在CLOSE_WAIT的连接和对应 PID;ls /proc/<PID>/fd | wc -l看 fd 数是否在持续上涨;strace -f -p <PID> -e trace=close,shutdown观察系统调用,确认哪些 fd 没被关闭。
修复的核心不是改内核参数,而是保证每个连接都有明确的关闭路径,比如在事件循环中统一处理read() 返回 0的情况,及时清理异常分支里的 socket。内核层面的超时参数只是兜底,不能依赖它解决代码问题。
5.3 SYN_RECV 增多:半连接队列与全连接队列
SYN_RECV是服务端收到 SYN、但三次握手还没完成时的状态。如果大量连接停在这个状态,通常有两个方向的原因:一是遭受 SYN 泛洪,伪造源 IP 的 SYN 报文让服务端无法完成握手;二是全连接队列溢出,导致已完成三次握手的连接无法被应用accept()。
排查时最实用的命令是ss -lnt。对处于LISTEN的 socket 来说,输出里的Recv-Q表示当前全连接队列中等待accept()的连接数,Send-Q表示全连接队列的最大长度。如果你的服务Recv-Q长期接近或等于Send-Q,说明应用accept()的速度跟不上新连接涌入的速度。
改善方向通常是:
- 调大
net.core.somaxconn和应用层listen()的 backlog; - 检查应用有没有使用多线程或多进程快速处理
accept(); - 评估是否需要前置负载均衡分散压力。
如果只是单纯调大内核队列而不看应用实际消费能力,问题只会从内存在别处爆发,排查时一定要先分清是"内核队列太小"还是"应用消费太慢"。
5.4 TIME_WAIT 的寿命与端口耗尽计算
TIME_WAIT是主动关闭方发出最后一个 ACK 后进入的状态,目的是让网络上残留的旧报文在 2MSL 内自然消亡,避免污染新连接。Linux 默认把tcp_fin_timeout设为 60 秒,所以一次短连接结束后,主动方会留有约一分钟的TIME_WAIT。
大量TIME_WAIT本身不等于故障,但有一个真实的风险:本地端口耗尽。客户端主动发短连接时,每次连接要占一个本地端口,端口范围由net.ipv4.ip_local_port_range控制,默认是 32768 到 60999,共 28232 个可用端口。如果对同一目标服务器的短连接速率超过28232 / 60约 470 QPS,一分钟积攒的TIME_WAIT就可能把端口占满,新连接直接报Cannot assign requested address。
常见的应对手段:
- 客户端开启
net.ipv4.tcp_tw_reuse=1,并确保开启了tcp_timestamps,允许内核复用仍在TIME_WAIT的端口; - 调大
net.ipv4.ip_local_port_range,比如改成 10240 到 65000,但端口不是越多越好,要评估防火墙规则和资源占用; - 服务端尽量使用长连接或引入连接池,从业务层面降低建连频率;
- 绝对不要开
net.ipv4.tcp_tw_recycle,这个参数在 NAT 场景下会误杀合法连接,内核高版本已经移除。
我之前在排查一次"连接数暴涨"时,差一点就顺手打开tcp_tw_recycle,现在想起来还后怕。TCP 状态这个东西,先理解再动手,比先动手再理解安全得多。
6. 高并发场景下的连接统计大坑
6.1 一个典型的误报场景
假设你在压测一个 Web 服务,写了个脚本每 5 秒执行一次netstat -an | wc -l来统计"连接数"。结果发现连接数曲线一路上涨,但你确认服务没有异常。这个场景我见过不止一次,问题就出在统计口径上。
netstat -an | wc -l统计的是所有 socket 条目,包括LISTEN、TIME_WAIT、CLOSE_WAIT等全部状态。短连接压测会把大量连接推进TIME_WAIT,这些连接既不消耗业务线程也不占用文件描述符,把它们算进"活跃连接数"只会制造虚高告警。
6.2 坑一:把 TIME_WAIT 当活跃连接
正确统计瞬时活跃连接数的方法是只看ESTABLISHED:
ss -tan | awk 'NR>1 && $1=="ESTAB" {n++} END {print n+0}'或者用ss自带的状态过滤:
ss -tan state established | wc -l如果只想看指定端口,可以加sport = :8080条件:
ss -tan state established '( sport = :8080 )'注意ss输出第一列在-a下是State,取值是ESTAB、TIME-WAIT、CLOSE-WAIT这样的写法,和/proc/net/tcp里的十六进制码不一样,不要搞混。
6.3 坑二:忘记算 IPv6
如果服务监听的是 IPv6 socket(例如:::8080),通过 IPv4-mapped 方式接入的连接可能记录在/proc/net/tcp6里,也可能同时影响ss的-6输出。只统计/proc/net/tcp或只执行ss -tan(IPv4+IPv6 其实默认都会显示,但脚本若写死读取 proc 文件就会漏)。
我最推荐的统一做法是用ss -tan,它会同时输出 IPv4 和 IPv6 的 socket,再按状态过滤,就不会漏。如果一定要基于 proc 文件统计,记得把/proc/net/tcp和/proc/net/tcp6两个文件的结果合并。
6.4 坑三:忘了网络命名空间的存在
容器场景下这个问题特别容易踩。每个网络命名空间有自己独立的 socket 表和/proc/net/tcp,你在宿主机上执行ss -tan看到的只是宿主机默认命名空间的内容。容器内进程建立的连接在宿主机上完全不可见,所以"在宿主机上统计不到容器应用的连接数"是正常现象。
排查容器化应用时,进入对应容器的网络命名空间再执行命令:
PID=$(docker inspect -f '{{.State.Pid}}' <container_id>) nsenter -t "$PID" -n ss -tan或者直接在容器内执行ss -tan。做监控采集时,针对容器服务的连接数,采集面要落在容器所在的命名空间里,而不是宿主机。这是在容器平台化之后,连接监控最容易踩的隐藏坑。
6.5 正确的统计姿势总结
经过这些教训,我整理了一个自己的统计标准:
- 瞬时活跃并发看
ESTABLISHED数量,排除掉TIME_WAIT和LISTEN; - 健康画像看五类状态分布:
ESTABLISHED、TIME_WAIT、SYN_RECV、CLOSE_WAIT、FIN_WAIT*; - 采集频率控制在 30 到 60 秒一次,用
ss而不是netstat; - 明确当前监控对象所处的网络命名空间,尤其是容器和宿主机要区分。
这套口径跑下来,告警的误报率会明显下降。
7. conntrack 插一脚后的误判与故障
7.1 连接跟踪是怎么混进来的
除了 socket 表,Linux 系统里还有一张独立的连接跟踪表,也就是 conntrack 表。它由 nf_conntrack 模块维护,主要服务于 NAT、iptables 状态防火墙等场景。只要系统里存在相关 nftables/iptables 规则,或者跑着依赖 NAT 的容器网络,所有 TCP 连接都会在建立时往 conntrack 表里插入条目。
这张表是独立于进程 socket 表的,它会记录连接的方向、状态和超时时间。它本身是防火墙工作的一部分,但因为和连接的生命周期强相关,经常会把监控的人带偏。
7.2 表满时的典型故障
conntrack 表有上限,超过上限后内核会丢弃新的连接跟踪条目,并打印nf_conntrack: table full, dropping packet。表现就是:系统能 ping 通,已经建立的连接暂时正常,但新连接迟迟反复无法建立,因为 SYN 报文在 conntrack 插入阶段就被丢掉了。
排查方法:
# 当前跟踪条目数 cat /proc/sys/net/netfilter/nf_conntrack_count # 表上限 cat /proc/sys/net/netfilter/nf_conntrack_max # 查看 dmesg 日志 dmesg | grep "nf_conntrack"如果确认是表满导致,可以调大上限,同时适当缩短相关超时:
sysctl -w net.netfilter.nf_conntrack_max=1048576 sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=7200 sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30但要注意,每条 conntrack 条目大约占用两三百字节内存,上限调大到百万级别就意味着几百 MB 的内存成本,要结合机器资源配置。
7.3 别把 conntrack 和 socket 表混为一谈
连接监控很容易踩的坑,是把 conntrack 表里的连接数当成应用连接数。在 NAT 场景下,一条 socket 连接可能对应多条 conntrack 条目(原始方向和回复方向),所以两边数字对不上是正常的。
我的建议是:后端应用的活跃连接数,一律以ss或/proc/net/tcp的 socket 表为准;conntrack 表主要用于观察网关、负载均衡器的转发连接规模,以及排查"新连接建立不了"这类防火墙相关故障。两台机器之间如果只有 socket 表而没有 conntrack 症状排查路径,很容易浪费大量时间。
8. 一套能落地的连接监控与排障脚本
8.1 快速查看完整状态画像
日常接到"连接数异常"告警时,我一般会先跑一段这样的命令,拿到全局状态分布:
ss -tan | awk 'NR>1 && $1!="LISTEN" {print $1}' | sort | uniq -c | sort -rn输出类似:
856 ESTAB 2414 TIME-WAIT 21 SYN-RECV 3 CLOSE-WAIT一眼就知道大部分连接是什么状态。如果CLOSE-WAIT有几十个并且持续增加,优先检查应用是否泄漏 fd;如果SYN-RECV很多,看ss -lnt的Recv-Q和Send-Q;如果TIME-WAIT高但业务正常,暂时不用处理,只要关注端口是否耗尽。
8.2 定位指定端口的连接归属
要找出某个端口(比如 8080)上所有连接对应的进程,用ss的-p参数最方便:
ss -tanp '( sport = :8080 )'如果权限不足看不到 PID,需要切换到 root。输出里会有类似users:(("java",pid=12345,fd=28))的信息,直接告诉你进程名和 PID。这个操作在排查"某个端口被连接占满但不知道是谁"的场景里非常高效。
8.3 十秒级的自动排查脚本
下面这个脚本是我常用的一套组合拳,把它放到服务器上,告警时直接执行就能拿到关键信息:
#!/bin/bash echo "===== TCP 状态分布(不含 LISTEN) =====" ss -tan | awk 'NR>1 && $1!="LISTEN" {print $1}' | sort | uniq -c | sort -rn echo "===== 监听队列情况 =====" ss -lnt | awk 'NR==1 || /LISTEN/' echo "===== CLOSE_WAIT 连接(最多 20 条) =====" ss -tanp state close-wait | head -20 echo "===== 当前 ESTABLISHED 总数 =====" ss -tan state established | wc -l echo "===== conntrack 使用量 =====" if [ -f /proc/sys/net/netfilter/nf_conntrack_count ]; then echo "count: $(cat /proc/sys/net/netfilter/nf_conntrack_count)" echo "max: $(cat /proc/sys/net/netfilter/nf_conntrack_max)" else echo "nf_conntrack 模块未加载或不可用" fi这个脚本没有复杂的依赖,任何带 bash 和ss的 Linux 环境都能直接跑。它把我在前面几章讲的排查重点浓缩成了几个输出块,配合操作系统的top、dmesg基本能覆盖绝大多数连接异常场景。
8.4 从监控数字到经验直觉
脚本只是工具,真正值钱的是看到数字之后的判断。我在实际运维里形成了自己的处理顺序:先看状态分布是否异常,再看监听队列是否打满,最后看 conntrack 有没有丢包,三者相互印证,基本能在几分钟内锁定问题方向。内核参数不是不能调,但每次调整都要搞清楚它解决什么问题、会带来什么副作用。
TCP 连接监控给我最大的收获是:工具输出只是内核状态的投影,理解了投影背后的数据结构,才能不被一长串十六进制吓退,也不会被某个脚本统计出的虚高数字牵着鼻子走。如果你现在还在用netstat一把梭,建议从今天起试试ss,然后找个不重要的环境打开/proc/net/tcp对照着看几遍,那种打通底层数据链路的感觉,会在下一次排障时体现出来。