端口耗尽与 TIME_WAIT 堆积
01 前置理论与核心机制¶
1. 三种网络报错的底层定界¶
在反向代理网关(如 Nginx)向后端业务节点转发请求时,底层 curl 或错误日志中的不同报错对应完全不同的故障边界:
- Connection refused (连接拒绝):后端服务器网络可达,但目标端口无进程监听(服务未启动或端口异常退出)。
- Connection timed out (连接超时):网络链路不通或报文被丢弃,通常由防火墙拦截、路由不可达或后端 TCP 队列溢出所致。
- Cannot assign requested address (无法分配请求地址):故障出在客户端(发起连接的一方,如网关)本机。代表本机用于向外建立新 TCP 连接的本地临时端口(Ephemeral Ports)已被全部耗尽,内核无法为新连接分配源端口。
2. TIME_WAIT 堆积的产生机制¶
- 主动关闭连接的代价:根据 TCP 协议状态机规范,主动发起关闭连接的一方(在短连接反向代理架构中通常为网关侧),在发送最后一个 ACK 报文后,连接不会立即销毁,而是必须进入 TIME_WAIT 状态并持续等待 2MSL 时间(Linux 默认固定为 60 秒)。
- 设计目的:保留 TIME_WAIT 状态主要有两个作用:一是确保对端能收到最后的 ACK(若丢失可重传);二是确保网络中滞留的旧连接报文在链路上自然过期消亡,防止旧报文混入后续复用相同四元组的新连接中造成数据错乱。
- 端口耗尽触发 502:当面临高并发请求,且网关向后端转发采用短连接模式时,网关本机每秒会主动关闭大量连接,产生海量的 TIME_WAIT 记录。一旦积压数量占满 ip_local_port_range 定义的临时端口池上限,后续新请求将无法分配本地源端口,导致网关直接向客户端返回 502 Bad Gateway。
02 故障排查与恢复闭环 SOP¶
当线上接口突发大面积 502 报错,而后端业务节点 CPU、内存及负载均处于低位时,按以下链路排查网关侧的端口状态:
步骤一:使用 curl -v 定界底层连接报错¶
登录网关服务器,手动向后端业务节点发起请求测试。必须添加 -v 参数以输出底层套接字的连接详情:
# 在网关节点 (192.168.0.88) 上,请求后端业务节点 (192.168.0.106)
curl -v [http://192.168.0.106:8080/api/test](http://192.168.0.106:8080/api/test)
- 观测点:若终端明确输出 Cannot assign requested address,即可确认网关本机的本地临时端口已耗尽。
步骤二:核查系统 TCP 连接状态分布¶
统计当前网关服务器上各状态的 TCP 连接数量:
# 统计当前所有 TCP 连接状态的分布数量 (或使用更高效的 ss -s)
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
- 观测点:若发现 TIME_WAIT 状态的连接数高达数万个(接近本机 ip_local_port_range 的可用总数),即可确认为高并发短连接引发的本地端口池耗尽。
步骤三:应急止损 (开启 TIME_WAIT 端口复用)¶
生产环境优先恢复业务流量。确认端口被 TIME_WAIT 占满后,立即通过修改内核参数开启端口安全复用:
- 效果验证:参数生效后(Linux 内核要求同时开启 tcp_timestamps=1,默认即为开启),处于 TIME_WAIT 状态超过 1 秒的连接端口即可被新发起的出站连接安全复用。再次在网关执行 curl 测试后端接口,Cannot assign requested address 报错消失,接口立即恢复 200 OK。
步骤四:根源治理 (启用长连接与连接池)¶
调整内核参数仅能缓解端口耗尽表象,高频三次握手依然会消耗额外的 CPU 与网络开销。彻底解决需将短连接改造为长连接复用: - Nginx 网关侧:在 upstream 模块中配置 keepalive 保持空闲长连接池,并在 location 中显式指定 proxy_http_version 1.1 及清空 Connection 头。 - 应用代码侧:核查业务代码中的 HttpClient、Redis 客户端及数据库连接驱动,确保使用全局单例的连接池(Connection Pool),严禁在单次请求生命周期内频繁创建和销毁连接实例。
03 真实生产故障现象与案例库¶
案例 1:Nginx 反向代理未配置长连接导致大促 502¶
- 故障现象:某促销活动开始后,前置 Nginx 网关大面积返回 502,而后端的 Java 微服务集群 CPU 使用率不足 20%。查看 Nginx error.log 发现大量 Cannot assign requested address 报错。
- 排查与处置:Nginx 作为反向代理转发请求给后端 upstream 时,默认采用的是 HTTP/1.0 短连接(即每次请求转发完成后 Nginx 都会主动断开与后端的 TCP 连接),导致高并发下网关自身产生数万个 TIME_WAIT 连接耗尽了本地端口。
- 根治配置:修改 nginx.conf,开启网关到后端的长连接池并执行 nginx -s reload:
upstream backend_pool {
server 192.168.0.106:8080;
keepalive 64; # 每个 Worker 进程保留的空闲长连接数上限
}
server {
location /api/ {
proxy_pass http://backend_pool;
proxy_http_version 1.1; # 必须显式指定使用 HTTP/1.1 协议
proxy_set_header Connection ""; # 清除默认的 "close" 头,启用长连接复用
}
}
案例 2:本地临时端口范围 (ip_local_port_range) 配置过窄¶
- 故障现象:使用单台压测节点对目标网关进行并发压测时,QPS 达到一定阈值后无法继续上升,压测机本地大量报错端口无法分配。
- 排查与处置:执行 cat /proc/sys/net/ipv4/ip_local_port_range 检查压测机的临时端口范围,发现被配置为 32768 40000(仅提供了 7233 个可用源端口)。
- 根治配置:执行 sysctl -w net.ipv4.ip_local_port_range="10240 65535" 将可用临时端口池扩大至 5.5 万个,并写入 /etc/sysctl.conf 持久化。
04 生产排障速查表(TCP 连通性定界)¶
报错现象 / 状态特征故障发生位置定性诊断核心处置方向Connection refused服务端节点握手报文已达服务端,但目标端口无进程监听检查后端服务进程状态及监听绑定的 IP 地址Connection timed out中间链路/服务端握手报文在传输途中被丢弃,未收到回包排查安全组、防火墙规则、路由表或服务端半连接队列Cannot assign ... address客户端/网关本机本机无可用动态源端口,无法向外发起连接执行 sysctl -w net.ipv4.tcp_tw_reuse=1 应急恢复存在海量 TIME_WAIT主动关闭连接方高并发短连接导致本地端口进入 60s 等待回收期开启 Nginx keepalive 或业务层连接池(Connection Pool)存在海量 CLOSE_WAIT被动关闭连接方对端已发送 FIN 断开,但本端应用未调用 close()排查本端业务代码是否存在 I/O 流或连接未关闭的代码泄漏
05 面试应答参考话术¶
面试官问:“线上接口突然大面积报 502,网关服务器 CPU 正常,但在网关上用 curl 请求后端时报错 Cannot assign requested address,这说明什么问题?怎么处理?”
可以这样回答:看到 Cannot assign requested address 报错,这与常见的 Refused 或 Timeout 有本质区别。它说明网络链路和后端服务本身没有问题,故障出在网关本机已经无法向外建立新的 TCP 连接了,原因是本机用于发起出站连接的本地临时端口(Ephemeral Ports)已被彻底耗尽。这种情况通常是由高并发短连接引发的。我会先在网关上执行 ss -s 或 netstat 统计连接状态,通常会看到存在数万个 TIME_WAIT 状态的连接。 根据 TCP 协议机制,主动关闭连接的一方会进入 TIME_WAIT 状态并等待 2MSL(默认 60 秒)后才释放端口。如果 Nginx 网关向后端转发采用的是默认的 HTTP/1.0 短连接,高并发下网关频繁主动断开连接,就会导致本地临时端口全部被 TIME_WAIT 占满,后续新请求分配不到源端口,从而直接向前端返回 502。在故障处理上,我分为应急止损和根源治理两步: 第一步是应急止损。 立即执行 sysctl -w net.ipv4.tcp_tw_reuse=1 开启内核的 TIME_WAIT 端口复用功能(同时可适当调宽 ip_local_port_range 的端口范围)。开启后,处于 TIME_WAIT 状态超过 1 秒的端口即可被新的出站连接安全复用,线上流量可以立即恢复正常。第二步是根源治理。 内核参数只是缓解表象,高频新建连接依然损耗性能。待业务稳定后,我会修改 Nginx 的 upstream 配置,加入 keepalive 连接池,并在 location 中配置 proxy_http_version 1.1 和清空 Connection 头,将网关到后端的通信改为长连接复用,从根源上消除 TIME_WAIT 的产生。
06 面试高频追问与应答预案¶
追问 1:为什么只开启 tcp_tw_reuse,而不开启 tcp_tw_recycle 参数?听说后者回收端口更快?¶
- 回答要点:
- 在 NAT 网络环境下会导致严重丢包:tcp_tw_recycle 开启后,内核会启用针对对端 IP 的 TCP 时间戳(Timestamp)严格递增校验(PAWS 机制扩展)。在公网 NAT、云服务器负载均衡(SLB/ALB)或容器网络架构下,多个不同客户端会共用同一个出口 IP 访问服务端,但各台客户端系统的时钟启动时间(时间戳)并不一致。
- 故障后果:开启 tcp_tw_recycle 后,服务端会把来自同一 NAT IP 但时间戳较小的正常客户端 SYN 握手包直接当成过期报文静默丢弃,导致大量处于 NAT 后端的用户无法建立连接。
- 演进结论:由于该设计缺陷在现代云网络中危害极大,自 Linux 4.12 内核起,tcp_tw_recycle 参数已被官方彻底移除。生产环境中严禁启用该参数。
追问 2:既然 TIME_WAIT 状态这么占用端口,为什么协议设计上不直接取消 TIME_WAIT,或者把等待时间改成 1 秒?¶
- 回答要点:
- TIME_WAIT 等待 2MSL(两倍最大报文段生存时间)是保障 TCP 可靠传输的核心机制,主要解决两个底层问题:
- 确保四次挥手可靠关闭:如果主动关闭方发送的最后一个 ACK 在网络中丢失,被动关闭方在超时后会重传 FIN 报文。维持 TIME_WAIT 状态能确保本端收到重传的 FIN 并再次回复 ACK;若直接销毁连接,本端收到重传的 FIN 时会直接回发 RST 报错。
- 防止历史迷途报文污染新连接:若不等待直接复用相同的四元组(源IP、源端口、目的IP、目的端口)建立新连接,网络中此前因路由抖动而延迟到达的旧连接数据包,一旦序列号恰好落在窗口内,就会被新连接误接收,造成应用层数据错乱。
07 本地临时端口水位与 TIME_WAIT 巡检脚本(check_time_wait_ports.sh)¶
该脚本用于快速统计本机动态临时端口池容量、TIME_WAIT 占用比例,并核查内核端口复用与危险参数的开启状态。
#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_time_wait_ports.sh
# 功能描述: 本机可用动态端口水位诊断与 TIME_WAIT 堆积安全巡检 (纯只读)
# 使用方式: 在网关或客户端宿主机以 root 权限执行
# ==============================================================================
set -o pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'
echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE} 系统临时端口水位与 TIME_WAIT 拥塞自动化巡检 ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"
# 1. 提取当前系统配置的临时端口范围
PORT_RANGE=$(cat /proc/sys/net/ipv4/ip_local_port_range 2>/dev/null)
PORT_MIN=$(echo "$PORT_RANGE" | awk '{print $1}')
PORT_MAX=$(echo "$PORT_RANGE" | awk '{print $2}')
TOTAL_PORTS=$((PORT_MAX - PORT_MIN + 1))
echo -e "\n${YELLOW}[1/3] 系统动态临时端口池 (Ephemeral Ports) 容量分析...${PLAIN}"
echo -e " -> 端口分配范围: ${PORT_MIN} - ${PORT_MAX}"
echo -e " -> 可用端口总数: ${GREEN}${TOTAL_PORTS}${PLAIN} 个"
if [ "$TOTAL_PORTS" -lt 20000 ]; then
echo -e " -> ${RED}[WARN] 临时端口池范围较窄 (/dev/null)
# 准确提取 ss -s 中的 estab 与 timewait 数值
ESTAB_COUNT=$(echo "$SS_OUT" | grep -o 'estab [0-9]*' | awk '{print $2}')
TW_COUNT=$(echo "$SS_OUT" | grep -o 'timewait [0-9]*' | awk '{print $2}')
ESTAB_COUNT=${ESTAB_COUNT:-0}
TW_COUNT=${TW_COUNT:-0}
echo -e " -> 当前活跃连接 (ESTABLISHED): ${GREEN}${ESTAB_COUNT}${PLAIN}"
echo -e " -> 等待回收连接 (TIME_WAIT) : ${YELLOW}${TW_COUNT}${PLAIN}"
if command -v bc &>/dev/null; then
USAGE_PCT=$(echo "scale=2; ($TW_COUNT / $TOTAL_PORTS) * 100" | bc 2>/dev/null)
# 修复了百分号转义乱码
echo -e " -> TIME_WAIT 占临时端口池比例: ${YELLOW}${USAGE_PCT}%${PLAIN}"
if (( $(echo "$USAGE_PCT > 80.0" | bc -l) )); then
# 修复了百分号转义乱码
echo -e " -> ${RED}[ALERT] TIME_WAIT 占用已超过临时端口总量的 80%!${PLAIN}"
echo -e " -> ${RED}[FATAL] 极易触发 Cannot assign requested address 报错导致 502!${PLAIN}"
else
echo -e " -> ${GREEN}[OK] 当前临时端口使用率处于安全水位。${PLAIN}"
fi
fi
# 3. 检查内核端口复用参数
echo -e "\n${YELLOW}[3/3] 检查内核层 TIME_WAIT 端口复用与安全参数...${PLAIN}"
REUSE_STATUS=$(cat /proc/sys/net/ipv4/tcp_tw_reuse 2>/dev/null)
# 修复了逻辑或乱码
if [ "$REUSE_STATUS" == "1" ] || [ "$REUSE_STATUS" == "2" ]; then
echo -e " -> tcp_tw_reuse : ${GREEN}已开启 (值为 ${REUSE_STATUS})${PLAIN}"
echo -e " -> ${GREEN}[OK] 系统支持在发起出站连接时安全复用 TIME_WAIT 端口。${PLAIN}"
else
echo -e " -> tcp_tw_reuse : ${RED}未开启 (值为 ${REUSE_STATUS:-0})${PLAIN}"
echo -e " -> ${YELLOW}[建议] 若发生端口耗尽告警,可执行 sysctl -w net.ipv4.tcp_tw_reuse=1 应急恢复。${PLAIN}"
fi
RECYCLE_STATUS=$(cat /proc/sys/net/ipv4/tcp_tw_recycle 2>/dev/null || echo "not_exist")
if [ "$RECYCLE_STATUS" == "1" ]; then
echo -e " -> tcp_tw_recycle : ${RED}已开启 (高危配置!)${PLAIN}"
echo -e " -> ${RED}[DANGER] tcp_tw_recycle 在 NAT 环境下会导致严重丢包,请立即执行 sysctl -w net.ipv4.tcp_tw_recycle=0 关闭!${PLAIN}"
fi
echo -e "\n${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}巡检完毕。若需根治 TIME_WAIT 堆积,请核查 Nginx upstream keepalive 及应用层连接池配置。${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"