「服务器连不上了」这句话背后可能是很多种故障,但有一种特别常见、也特别容易被误判:端口耗尽与 TIME_WAIT 堆积。现象通常是一段时间内所有对外请求全部超时、日志里出现 Cannot assign requested address 或 connect: connection refused,重启一下服务就好了,过几个小时再犯一次。很多人第一反应是「对方服务挂了」或者「网络抖动」,结果查了两天网络,问题其实出在本机的端口表上。
这篇把我处理这类故障的完整路径写下来:先搞清楚端口是怎么被吃掉的,再用三个命令确认是不是端口问题,最后给出调优参数和真正治本的几种改法。
一、先理解:一条对外连接占用什么
很多人以为「端口耗尽」指的是服务器上的 65535 个端口用完了,其实不完全对。要分两个方向看:
监听端口(服务端):一个进程 listen 在 80 端口,无论来多少连接,占用的都只是这一个本地端口。所以服务端很少因为「端口数量」耗尽,更多是文件描述符或者 backlog 队列满了。
发起连接的端口(客户端):本机作为客户端去连别人时,每条连接需要一个本地临时端口(ephemeral port),范围由 net.ipv4.ip_local_port_range 决定,默认通常是 32768 60999,也就是约 28000 个。这四个要素唯一确定一条连接:本地IP:本地端口 → 远端IP:远端端口。所以理论上一台机器可以建立的连接数上限是「本地IP数 × 本地端口数 × 远端目标数」,同一个目标 IP:端口 最多只有 28000 条。
关键就在最后这句:如果全站所有请求都发往同一个后端(比如同一个数据库、同一个 Redis、同一个上游 API),那么这个目标的连接上限就是 28000 条。而且已经被关闭的连接不会立刻释放端口,会进入 TIME_WAIT 状态,等 2×MSL(Linux 默认 60 秒)才真正归还。
二、什么时候会真的耗尽端口
正常业务不会耗尽,以下五种场景才会:
第一,短连接高频调用。每次请求都新建连接、用完就关,没有连接池。假设每个请求 20ms,单机 200 QPS 全打同一个后端,就是每秒 200 条新连接;每条连接关闭后占用 60 秒 TIME_WAIT,稳态下 TIME_WAIT 数量 = 200 × 60 = 12000 条。再翻一倍 QPS 就逼近上限了。
第二,上游超时但连接没关干净。很多客户端库的「超时」只作用于读,不作用于连接建立或者写。请求超时抛异常后,socket 还挂在 TIME_WAIT 或者更糟的 CLOSE_WAIT。我在一篇关于文件描述符的文章里写过,CLOSE_WAIT 堆积是「漏」,TIME_WAIT 堆积是「正常但量大」,两者的修法完全不同。
第三,DNS 或健康检查短连接。Nginx 的 proxy_pass 如果直接写域名而不配 upstream,每次请求都要解析一次 DNS,且很多老版本配置下不会复用连接。健康检查探针每 5 秒一次、目标多的时候也会占掉一部分端口。
第四,容器与宿主共用网络命名空间。Docker 默认 bridge 模式下每个容器的出网流量都会经过宿主 SNAT,占用宿主的临时端口。宿主机上跑了二十个容器,每个容器都在调外部 API,端口表是共享的,比单机更容易耗尽。
第五,源端口范围被配置改小了。有些安全基线文档会建议把 ip_local_port_range 收窄到 40000 60000,看着更「规范」,实际把可用端口砍掉一半。这类改动往往几个月后才以故障的形式暴露出来。
三、三个命令确认是不是端口问题
不要一上来就调参数。先确认身份。
# 1) 看 TIME_WAIT 总量
ss -s
# 输出里的 TIME-WAIT 一行就是当前数量
# 2) 按「本地端口 → 目标」聚合,看是不是全压在一个后端上
ss -tan state time-wait | awk 'NR>1 {print $4}' \
| awk -F: '{print $1}' | sort | uniq -c | sort -rn | head
# 3) 统计当前已用的临时端口数
cat /proc/net/sockstat
# 关注 TCP: inuse / orphan / tw 三个数
判断标准:tw 数量接近 ip_local_port_range 的区间宽度一半以上,而且集中在少数几个目标 IP 上,基本可以确认是端口压力而不是网络问题。
另一个快速判据是看内核日志与业务日志的时间点是否重合。dmesg -T | grep -i "out of ephemeral" 如果出现 TCP: out of memory -- consider tuning tcp_mem 或者 Failed to create socket,那就是系统层面已经发不出新连接了。业务侧的典型表现是连接建立阶段直接失败,而不是建立成功但很慢 —— 这个区别很重要:连不上 = 端口或队列问题,连上但慢 = 带宽或后端处理问题。
四、五个参数的取舍
确认是端口问题之后,能调的参数其实不多,而且每个都有代价。
# 扩大临时端口范围(默认 32768-60999)
net.ipv4.ip_local_port_range = 10000 65000
# 允许有限复用 TIME_WAIT 端口发起新连接(对客户端有效)
net.ipv4.tcp_tw_reuse = 1
# TIME_WAIT 上限,超过直接回收
net.ipv4.tcp_max_tw_buckets = 30000
# 加快 FIN 回收
net.ipv4.tcp_fin_timeout = 15
# 本地端口不足时,允许重用处于 TIME_WAIT 的端口
net.ipv4.tcp_tw_recycle = 0
关于 tcp_tw_recycle:一定不要开。这个参数从内核 4.10 起就被移除了,因为它在 NAT 环境下会因为时间戳判断导致连接随机失败,尤其是移动网络用户。网上大量「优化方案」还在推荐它,照抄会制造更诡异的故障。
关于 tcp_tw_reuse:只在客户端方向有意义。它作用的角色是「本机主动发起连接」,让内核跳过 TIME_WAIT 直接复用端口,条件是时间戳单调递增。如果本机是对外提供服务的,开它没有副作用;如果本机同时有大量 NAT 出网流量,要确认中间没有关掉时间戳。
关于 tmp_max_tw_buckets:节流不是解药。超过上限后内核会打印 TCP: time wait bucket table overflow 并强制回收,看着「问题解决了」,实际上是把 TIME_WAIT 的保护作用拿掉了 —— 迟到的重复报文可能被新连接接收,出现数据错乱。紧急止血可以,长期不能靠它。
把参数放进 /etc/sysctl.d/99-net.conf,然后 sysctl --system 生效。不要直接改 /etc/sysctl.conf —— 那个文件通常被基础镜像或配置管理工具管着,下次部署就把你的改动覆盖了。
五、真正治本的三件事
调参数只能把天花板抬高一点,根子在代码和架构上。
第一,把短连接改成连接池 / 长连接。数据库、Redis、HTTP 上游都一样。PHP 场景下 MySQL 用持久连接或者中间件代理;Go、Java、Node 里连接池的 max_idle_conns 一定要设,默认值往往是 2 或者无穷大,两个极端都会出问题。判断连接池是否生效,看 ss -tan state established | wc -l 是否稳定在一个不随时间暴涨的值。
第二,Nginx 上游显式配 keepalive。
upstream backend {
server 10.0.0.11:8080;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend;
}
三个地方缺一不可:keepalive 定义池大小、proxy_http_version 1.1 让上游支持复用、Connection "" 清掉默认的 close。少配任何一个,连接照样是一次性的。这个坑我在 upstream keepalive 那篇文章里专门拆过,发布时 502 排查的思路可以对照着看。
第三,按目标拆分出口。如果确实需要海量短连接打到同一个外部 API,可以在本机配多个源 IP(多张网卡或者 ip addr add 加别名),配合源地址轮询,等于把 28000 的端口池扩成 N 倍。这是很多做第三方对接的团队最后都会走的一步。
六、容器环境的特殊注意
容器里跑 ss -s 看到的数字和宿主机不一样,因为网络命名空间不同。要查真正的端口压力,得进宿主机命名空间看:
# 从容器内看宿主机端口表
nsenter -t 1 -n ss -tan state time-wait | wc -l
# 查看某个容器的网络命名空间
docker inspect -f '{{.State.Pid}}' container_name
nsenter -t <pid> -n ss -s
另外 Docker 的 --ulimit nofile 和宿主 LimitNOFILE 的关系也容易搞错:容器内的限制来自 docker daemon 的默认值,不是宿主 systemd 服务的限制。默认 1024 在连接密集的场景下会先撞文件描述符上限,表现和端口耗尽几乎一样,区别在日志里是 Too many open files 而不是 Cannot assign requested address。这两个错误码要分清楚,否则会往错误的方向调参数。
七、小结
端口耗尽这类故障最大的价值不在于「会调哪几个参数」,而在于区分它是谁的问题。连不上要看本机端口表和 backlog,连上慢要看后端与带宽;CLOSE_WAIT 多说明代码漏关连接,TIME_WAIT 多说明连接生命周期太短。参数调优只抬高天花板,连接复用(连接池、upstream keepalive、多源 IP)才是真正减少天花板压力的做法。下次再遇到「过几个小时就全站超时」,先跑 ss -s 看 TIME-WAIT 那一行,比翻网络设备日志快得多。