接口偶发超时,竟是 TIME_WAIT 堆积:一次 Linux 网络排查的复盘

HTTP 接口偶发超时,ping 通、curl 好、单次测试全正常,却查不出毛病。直到瞥见一句 Cannot assign requested address。一路挖下来:分层确认连通性、ss 看清连接状态、读懂 TIME_WAIT 与 CLOSE_WAIT、tcpdump 抓包拿铁证,最后定位到短连接滥用耗尽本地端口,改长连接根治。

2024 年我们遇到过一次特别折磨人的故障:一个对外的 HTTP 接口,绝大多数时候都是正常的,但每隔一段时间就会冒出一批超时,客户端那边偶尔报连接失败。它不是彻底挂掉,而是"偶发"——这种半死不活的状态,最难查。我登上服务器,接口测一下又是好的,日志里也看不出应用本身有什么问题。这一次,问题不在应用代码里,而在更底层的网络上。我顺着 Linux 的网络排查工具一层层往下挖,最后定位到是 TIME_WAIT 连接堆积、把本地端口耗尽了。本文复盘这次实战,把 Linux 网络排查这套方法梳理清楚。

问题背景

环境:CentOS 7,一个对外的 Java HTTP 接口服务
     这个服务自己又会频繁去调用下游的另一个 HTTP 服务
事故现象:
- 接口偶发超时,客户端偶尔报"连接失败"
- 不是全挂,大多数请求正常,故障一阵一阵的
- 应用日志里没有明显异常,GC 也正常
- 重启服务后好一阵子,过几小时又复发

现场排查(一开始走的弯路):
# 1. 先怀疑是不是网络不通
$ ping downstream-host        # 通的,延迟也正常
$ curl http://downstream/api  # 手动调一下,也是好的
# —— 单次测试都正常,但偏偏偶发失败

# 2. 怀疑下游服务慢,但下游说他们没问题

# 3. 偶然瞥见一条报错日志:
#    "Cannot assign requested address"
#    —— 这句话是突破口

根因(后来定位到的):
本服务作为客户端频繁调用下游,每次都新建短连接,
连接关闭后留下大量 TIME_WAIT 状态的连接,
把本地可用端口(ephemeral port)耗尽了,
导致新连接无端口可用 -> 偶发性地连不出去。

修复 1:先分层确认——连通性从下往上排

# === 网络排查的第一原则:分层,从底往上 ===
# 一个请求要走通,底下每一层都得通:
#   网线/路由通不通 -> 端口开没开 -> 应用收不收

# === 第一层:ping,测最基础的 IP 可达性 ===
$ ping -c 4 downstream-host
# 通 -> IP 层没问题
# 不通 -> 可能是路由、DNS、对方主机宕机
# 注意:很多服务器禁 ICMP,ping 不通不一定真不通

# === 测 DNS 解析对不对 ===
$ dig downstream-host +short
# 或
$ nslookup downstream-host
# 确认域名解析到的 IP 是你预期的那个
# (我见过解析到一个过期的旧 IP 的坑)

# === 第二层:测【端口】通不通,这才是关键 ===
# ping 通只说明主机活着,不代表那个端口在监听。
# 用 nc(netcat)测端口:
$ nc -zv downstream-host 8080
# -z 只扫描不发数据,-v 显示结果
# Connection to downstream-host 8080 port [tcp] succeeded!

# 没装 nc,用 telnet 也行:
$ telnet downstream-host 8080

# 用 /dev/tcp 这个 bash 内建,什么都不用装:
$ timeout 2 bash -c '</dev/tcp/downstream-host/8080' && echo OK || echo FAIL

# === 第三层:看路径上哪一跳出了问题 ===
$ traceroute downstream-host
# 或更适合排查的 mtr,它持续探测、能看丢包率
$ mtr -r -c 20 downstream-host
# 哪一跳开始延迟飙升 / 丢包,问题就在那一段

修复 2:ss——看清本机的连接状态

# === 排查到这一步,基础连通性都正常,转看本机连接 ===
# ss 是 netstat 的现代替代品,更快,信息更全。
# (netstat 在新系统里已经不推荐了)

# === 看所有 TCP 连接的概况 ===
$ ss -s
Total: 28451
TCP:   28301 (estab 210, closed 27800, timewait 27750/0, ...)
# ★ 一眼就发现问题:timewait 高达 27750!
# 正常建立的连接(estab)才 210 个,
# 但 TIME_WAIT 状态的连接堆了快 3 万 —— 极不正常。

# === 按状态统计连接数(排查必备的一行)===
$ ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
  27750 TIME-WAIT
    210 ESTAB
     45 LISTEN
      8 CLOSE-WAIT
# -a 全部 -n 不解析端口名 -t 只看 TCP
# 这一行能立刻告诉你:连接都堆在哪个状态

# === 看具体是和谁的连接在 TIME_WAIT ===
$ ss -ant state time-wait | awk '{print $5}' | \
    cut -d: -f1 | sort | uniq -c | sort -rn
  27600 10.0.0.50      # 几乎全是连向下游的同一个 IP
# 真相清晰了:是本服务调用下游产生的连接

# === 看某个端口有没有在监听 ===
$ ss -lntp | grep 8080
LISTEN 0  128  *:8080  *:*  users:(("java",pid=12345,fd=88))
# -l 只看 LISTEN,-p 显示是哪个进程占的端口

修复 3:看懂 TIME_WAIT 和 CLOSE_WAIT

=== TCP 连接关闭时会经过一系列状态,两个最常被问到 ===

【TIME_WAIT】—— 出现在【主动关闭】连接的那一方
含义:这一方已经发完了关闭流程,但 TCP 要求它
     再等待 2 倍 MSL 的时间(Linux 上通常 60 秒)
     才彻底释放,目的是确保对方收到了最后的 ACK,
     并防止旧连接的延迟报文串到新连接里。
关键:TIME_WAIT 本身是【正常】的,是 TCP 的设计。
     问题在于【数量】—— 如果你的程序疯狂地新建、
     关闭短连接,60 秒内就能堆出几万个 TIME_WAIT。

【CLOSE_WAIT】—— 出现在【被动关闭】连接的那一方
含义:对方已经关了连接,内核通知了你的程序,
     但你的程序迟迟没有调用 close()。
关键:CLOSE_WAIT 大量堆积几乎一定是【程序 BUG】——
     代码里某个地方拿到连接后忘了关闭(没有 close、
     没有 finally 里释放)。它不会自己消失。

=== 两者的排查方向完全相反 ===
TIME_WAIT 多   -> 看【连接的创建方式】:是不是该用长连接
                  却用了短连接,是不是连接池没复用
CLOSE_WAIT 多  -> 看【代码】:哪里拿了连接/句柄没 close

=== 我们这次:TIME_WAIT 近 3 万 ===
方向明确:本服务调用下游时,用的是"每次请求新建一个
连接、用完就关"的短连接模式。请求量一大,关闭的连接
全变成 TIME_WAIT 在那压着,60 秒才消化一个。
而每个 TIME_WAIT 都【占着一个本地端口】不放 ——
这就引出了下一个问题:本地端口被耗尽。
# === 印证:本地端口范围 vs TIME_WAIT 数量 ===
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999
# 本机做客户端时能用的端口,就这 2.8 万个左右

# 而我们 TIME_WAIT 有 2.7 万 —— 端口几乎被占光!
# 想新建一个对外连接时,分不到端口,就会报:
#   "Cannot assign requested address"
# 这正是日志里那句报错的来源。

修复 4:tcpdump——抓包看真相

# === 当上层工具都说不清时,抓包是最后的"铁证" ===
# tcpdump 直接抓网卡上的数据包,网络世界里发生的
# 一切,它都看得见。

# === 抓和某个主机、某个端口相关的包 ===
$ tcpdump -i eth0 host 10.0.0.50 and port 8080 -nn
# -i 指定网卡  -nn 不解析主机名和端口名(快、清晰)
# host / port 是过滤条件,只抓我们关心的包

# === 抓包存成文件,拿到 Wireshark 里慢慢分析 ===
$ tcpdump -i eth0 host 10.0.0.50 -w /tmp/cap.pcap -c 2000
# -w 写文件  -c 抓够 2000 个包就停
# 生产上一定要加 -c 或控制时间,别把磁盘抓满

# === 只看建连和断连(SYN / FIN / RST)===
$ tcpdump -i eth0 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0' -nn
# 能清楚看到连接是怎么建立、又是被哪一方关闭的
# 我们这次抓出来:每个请求都是
#   SYN -> ... 数据 ... -> FIN(本机主动发起)
# 印证了"短连接、本机主动关"这个模式

# === 抓包常用的过滤表达式 ===
# host 1.2.3.4            和这个 IP 相关的
# src host / dst host     只看源 / 只看目的
# port 80                 和这个端口相关的
# tcp / udp / icmp        只看某种协议
# 'tcp[tcpflags] & tcp-rst != 0'   只看 RST(连接被拒)
# 多个条件用 and / or / not 连接

修复 5:对症下药——根治 TIME_WAIT 堆积

# === 思路:TIME_WAIT 是结果,病根是"短连接滥用" ===
# 治本和治标要分清楚。

# === 治本(最重要):改用长连接 / 连接池 ===
# 我们这次的真正解法:本服务调下游,改成用带连接池
# 的 HTTP 客户端,连接【复用】,不再一请求一连接。
# 以 Java 的 Apache HttpClient 为例:
#   PoolingHttpClientConnectionManager cm =
#       new PoolingHttpClientConnectionManager();
#   cm.setMaxTotal(200);
#   cm.setDefaultMaxPerRoute(50);
# 连接复用后,新建/关闭连接的频率断崖式下降,
# TIME_WAIT 自然就消下去了。
# —— 这一步做完,故障就再没复发。

# === 治标(内核参数,缓解,不能替代治本)===
# 1. 让 TIME_WAIT 的连接能被【复用】(作为客户端时)
$ sysctl -w net.ipv4.tcp_tw_reuse=1
# tcp_tw_reuse:允许把处于 TIME_WAIT 的连接,
# 重新用于新的【对外】连接,安全、推荐。

# 2. 扩大本地可用端口范围,让端口多一点
$ sysctl -w net.ipv4.ip_local_port_range="10000 65000"

# 3. 适当调大 TIME_WAIT 连接数的上限
$ sysctl -w net.ipv4.tcp_max_tw_buckets=50000

# === 注意:tcp_tw_recycle 不要用 ===
# 老文章常推荐 tcp_tw_recycle,但它在 NAT 环境下
# 会导致丢包、连接异常,问题很隐蔽。
# 新内核(4.12+)已经把它移除了。永远别开它。

# === 让参数永久生效 ===
$ vim /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10000 65000
$ sysctl -p      # 重新加载配置

# === 顺带:如果是 CLOSE_WAIT 多,这里没有内核参数能救 ===
# CLOSE_WAIT 唯一的解法就是去改代码,把漏关的连接关上。

修复 6:把网络指标纳入日常监控

# === 这次故障"偶发",本质是因为没人盯着这些指标 ===
# 连接状态、端口使用、丢包重传,都该被持续监控。

# === 一个简单的连接状态自检脚本 ===
$ cat /usr/local/bin/net_check.sh
#!/bin/bash
# 统计各 TCP 状态的连接数,超阈值就告警
tw=$(ss -ant | grep -c TIME-WAIT)
cw=$(ss -ant | grep -c CLOSE-WAIT)
est=$(ss -ant | grep -c ESTAB)
echo "ESTAB=$est TIME_WAIT=$tw CLOSE_WAIT=$cw"
[ "$tw" -gt 20000 ] && echo "[告警] TIME_WAIT 过多: $tw"
[ "$cw" -gt 500 ]   && echo "[告警] CLOSE_WAIT 过多,疑似连接泄漏: $cw"

# crontab 每分钟跑一次
$ crontab -e
* * * * * /usr/local/bin/net_check.sh >> /var/log/net_check.log

# === 看网卡层面的错误和丢包 ===
$ ip -s link show eth0
# 看 RX/TX 的 errors、dropped 计数,持续增长说明
# 网卡或链路有问题

# === 看 TCP 的重传等异常统计 ===
$ netstat -s | grep -i retrans
# 或
$ nstat -az | grep -i retrans
# 重传率持续偏高,说明网络质量差或对端有压力

# === 看 sar 记录的历史网络数据 ===
$ sar -n DEV 1 3       # 实时网卡流量
$ sar -n DEV -f /var/log/sa/sa15   # 历史某天的流量
$ sar -n TCP,ETCP -f /var/log/sa/sa15  # 历史 TCP 指标
# 故障过去了,也能从 sar 里还原当时的网络状况

# === 生产环境 ===
# node_exporter + Prometheus 能采集到上面这些指标,
# 把 TIME_WAIT / CLOSE_WAIT / 重传率画成曲线、设告警,
# 偶发问题就会在曲线上现出原形。

命令速查

排查阶段              命令                          看什么
=============================================================
IP 可达               ping -c 4 host                通不通、延迟
DNS 解析              dig host +short               解析到的 IP 对不对
端口连通              nc -zv host port              端口在不在监听
路径丢包              mtr -r -c 20 host             哪一跳开始丢包
连接概况              ss -s                         estab/timewait 总数
按状态统计连接        ss -ant | awk ... | uniq -c   连接堆在哪个状态
看端口监听            ss -lntp                      端口被哪个进程占
本地端口范围          cat .../ip_local_port_range   客户端可用端口数
抓包                  tcpdump -i eth0 host x -nn    连接到底怎么走的
TCP 重传统计          netstat -s | grep retrans     重传率高不高
网卡错误丢包          ip -s link show eth0          errors/dropped
历史网络数据          sar -n DEV,TCP -f saXX        事后复盘

口诀:分层从下往上 -> ss 看状态 -> TIME_WAIT 查连接方式
      CLOSE_WAIT 查代码 -> tcpdump 拿铁证 -> 长连接根治

避坑清单

  1. 网络排查要分层,从下往上:IP 可达 → 端口连通 → 应用响应,逐层确认
  2. ping 通只代表主机活着,不代表端口在监听,端口连通要用 nc/telnet 单独测
  3. 偶发故障别只做单次测试,单次都正常恰恰是偶发问题的典型特征
  4. 优先用 ss 而不是 netstat,ss 更快信息更全,netstat 在新系统已不推荐
  5. ss -ant 按状态统计连接数是排查第一步,能立刻看出连接堆在哪个状态
  6. TIME_WAIT 本身正常,问题在数量,大量堆积说明短连接被滥用、该用长连接
  7. CLOSE_WAIT 大量堆积几乎一定是代码 BUG,连接拿了没 close,没内核参数能救
  8. 每个 TIME_WAIT 占一个本地端口,端口耗尽会报 Cannot assign requested address
  9. 治本是改长连接/连接池,内核参数 tcp_tw_reuse 只是治标,且绝不要开 tcp_tw_recycle
  10. 把 TIME_WAIT/CLOSE_WAIT/重传率纳入监控,偶发问题会在曲线上现出原形

总结

这次"偶发超时"的排查,给我最深的一个体会是:偶发故障之所以难,难就难在它会不停地骗你。你登上服务器,ping 一下是通的,curl 一下是好的,traceroute 一路也没问题,所有的单次测试都在告诉你"一切正常",可客户端那边的超时却又真真切切地存在。这种时候最容易犯的错,就是被这些"正常"的单次结果安抚住,觉得网络没事、转头去翻应用代码。但其实,单次测试全都正常,恰恰就是偶发问题最典型的特征——它不是一直坏,而是在某个特定的、瞬时的条件被满足时才坏,你随手一测,大概率正好落在它"好"的那一阵子里。所以排查偶发问题,不能靠"测一下看通不通",而要靠去看那些持续累积的、统计性的指标。这次真正帮我捅破窗户纸的,不是某一次测试,而是 ss -s 那一行汇总:正常建立的连接才两百来个,TIME_WAIT 却堆了将近三万个。这个数字一出来,方向立刻就清晰了。这也让我重新理解了 Linux 网络排查的正确姿势:它必须是分层的,而且要从最底下一层往上排。一个请求要走通,底下每一层都得是通的——IP 层要可达,这靠 ping 和 dig 去确认;端口要真的在监听,这一层最容易被忽略,因为 ping 通只能说明那台主机还活着,根本不代表你要连的那个端口有人在听,端口这一层必须用 nc 或 telnet 单独去测;路径上每一跳都不能丢包,这靠 mtr 持续探测才看得出来。把这几层都确认完,如果基础连通性都没问题,排查的重心就该从"网络通不通"转向"本机的连接处在什么状态",而这正是 ss 的主场。ss -ant 按状态统计连接数,是我现在做网络排查雷打不动的第一个动作,因为连接堆在哪个状态,直接决定了你接下来该往哪个方向查,而且这两个方向是完全相反的。如果是 TIME_WAIT 堆积,你要去查的是连接的创建方式——是不是本该用长连接的地方却在用一请求一连接的短连接,是不是连接池根本没起到复用的作用;TIME_WAIT 这个状态本身是 TCP 协议的正常设计,主动关闭连接的一方就是要等上一段时间来确保对端收到了最后的 ACK、并隔离掉旧连接的延迟报文,它本身没有错,错的是数量,当你的程序疯狂地新建又关闭短连接时,短短六十秒内就能堆出几万个,而每一个 TIME_WAIT 都死死占着一个本地端口不放。本机做客户端时能用的端口本来就只有两万八千个左右,被三万个 TIME_WAIT 一占,新连接就分不到端口了,于是报出那句 Cannot assign requested address——这正是我们日志里那句一开始没在意的报错的真正来源。而如果堆积的是 CLOSE_WAIT,排查方向就完全反过来了,你不该去碰任何网络参数,而要直接回到代码里,因为 CLOSE_WAIT 几乎可以百分之百地断定是程序 BUG:对端已经把连接关了、内核也通知了你的程序,但你的代码拿到连接或句柄之后,在某个分支、某个异常路径上忘了调用 close,这种连接不会自己消失,只会越积越多,而且没有任何一个内核参数能救它,唯一的解法就是去把那段漏关的代码补上。当上层工具的判断还需要最后一锤定音的铁证时,就轮到 tcpdump 出场了,它直接抓网卡上的数据包,网络世界里到底发生了什么,它纤毫毕现,这次我们就是靠抓包清清楚楚地看到每一个请求都是建连、传数据、然后由本机主动发起 FIN 关闭,从而坐实了"短连接、本机主动关"这个模式。最后我想强调的是治本和治标的区别:这次故障的病根,从来不是 TIME_WAIT 太多,TIME_WAIT 只是一个结果、一个表象,真正的病根是短连接被滥用。所以真正的解法是把对下游的调用改成带连接池的长连接,让连接被复用起来,新建和关闭连接的频率一降下来,TIME_WAIT 自然就消了——这一步做完,这个偶发故障再也没有复发过。至于 tcp_tw_reuse 这类内核参数,它们能缓解、能争取时间,但终究只是治标,绝不能拿它们当作不去改连接模式的借口;而像 tcp_tw_recycle 这种在 NAT 环境下会引发隐蔽丢包的参数,新内核早已把它移除,任何时候都不要去碰它。这次排查从最初被一连串"正常"的单次测试反复欺骗,到最终理清"分层确认连通性、用 ss 看清连接状态、顺着状态分流到正确方向、用 tcpdump 拿到铁证、再用长连接根治"这样一条清晰的链路,我最大的收获是明白了:排查偶发的网络问题,靠的不是运气好正好抓到它发作的那一下,而是你心里有没有一张分层的地图,以及你愿不愿意去看那些安安静静累积着、却最诚实的统计指标。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
Linux教程

服务器突然变慢:一次 Linux 性能排查的复盘

2026-5-20 17:17:38

Linux教程

服务进程隔几天就消失:一次 Linux 进程与信号排查的复盘

2026-5-20 17:23:36

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索