线上一台业务网关凌晨开始报「Cannot assign requested address」,过 20 分钟扩散到整个集群,所有出向调用全部失败。netstat | wc -l 看了一眼 —— 56000 个 TIME_WAIT。这个数字超过了系统可用的临时端口范围,新连接根本建立不起来。本文讲我们怎么从「赶紧加机器」到「真正理解 TIME_WAIT」的过程。
故障现场:出向连接全挂
表象是业务网关调用下游订单服务突然全部 timeout。日志里疯狂打:
2024-09-23 02:14:51 ERROR [http-out] connect to order-svc:8080 failed
java.net.NoRouteToHostException: Cannot assign requested address
at sun.nio.ch.Net.connect0(Native Method)
at sun.nio.ch.Net.connect(Net.java:579)
...
「Cannot assign requested address」是个误导性错误信息。一开始所有人都以为是网络问题,排查了 30 分钟才意识到这是本机临时端口耗尽。
登上机器看:
$ ss -s
Total: 87234
TCP: 58921 (estab 1023, closed 56892, orphaned 12, timewait 56789)
Transport Total IP IPv6
RAW 0 0 0
UDP 3 3 0
TCP 58921 58904 17
INET 58924 58907 17
FRAG 0 0 0
# timewait 56789 —— 罪魁祸首
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999
# 可用临时端口数 = 60999 - 32768 = 28231
# 但 TIME_WAIT 已经 56789,远超可用范围
# 看是哪些目标 IP 占着 TIME_WAIT
$ ss -ant state time-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
52341 10.0.5.27 # order-svc 的某台机器
2103 10.0.5.28
1689 10.0.5.29
312 redis.internal
...
52341 个 TIME_WAIT 全集中在 order-svc 的一台机器上。意味着我们的网关在跟这一台 order-svc 之间反复建立短连接,而且全是主动关闭方(只有主动关方才进 TIME_WAIT 状态)。
原理:TIME_WAIT 为什么存在 + 它的代价
TCP 四次挥手结束后,主动关闭一方会进入 TIME_WAIT 状态,持续 2 * MSL(Linux 上默认 60 秒)。这个状态的存在是为了:
- 确保最后一个 ACK 到达对端(如果丢了,对端会重发 FIN,本端能正确响应 RST)
- 防止延迟的旧报文影响新连接(同样的四元组在 2*MSL 内不能复用)
正常情况下 TIME_WAIT 不是问题。问题出在:
- 临时端口范围有限:默认 32768-60999,约 28000 个端口
- 四元组定义连接唯一性:(src_ip, src_port, dst_ip, dst_port)
- 当 dst_ip + dst_port 固定时(比如总是连同一个 order-svc:8080),src_port 是唯一变量,TIME_WAIT 会占着 src_port 60 秒
所以问题本质:客户端到同一个服务端,每秒新建超过 470 个短连接,持续 60 秒以上,就会耗尽端口。
第一招:确认是不是没启用连接池
主动关闭方进 TIME_WAIT 是 TCP 协议规定的,改不了。能改的是不要主动关闭那么多连接—— 用连接池复用。
检查我们的 HTTP 客户端配置:
// 错误代码:每次请求新建一个 client,用完就 close
public String callOrderSvc(String orderId) {
CloseableHttpClient client = HttpClients.createDefault(); // 每次新建
try {
HttpGet get = new HttpGet("http://order-svc:8080/orders/" + orderId);
try (CloseableHttpResponse resp = client.execute(get)) {
return EntityUtils.toString(resp.getEntity());
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try { client.close(); } catch (IOException ignored) {} // 主动关闭!
}
}
这段代码是 3 年前实习生写的。每次调用都新建 client + 用完 close,所以每次请求都进 TIME_WAIT。低 QPS 下没事,高峰期端口立刻耗尽。
修复:用单例 + 连接池:
// 正确代码:单例 client + 连接池
@Configuration
public class HttpClientConfig {
@Bean(destroyMethod = "close")
public CloseableHttpClient httpClient() {
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(500); // 总连接数
cm.setDefaultMaxPerRoute(100); // 每个目标主机最多 100 连接
cm.setValidateAfterInactivity(2000); // 复用前校验
RequestConfig reqCfg = RequestConfig.custom()
.setConnectTimeout(3000)
.setSocketTimeout(10000)
.setConnectionRequestTimeout(1000)
.build();
return HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(reqCfg)
.evictIdleConnections(60L, TimeUnit.SECONDS) // 60 秒空闲就回收
.evictExpiredConnections() // 服务端超时的连接清掉
.setKeepAliveStrategy((response, ctx) -> 30000L) // keepalive 30 秒
.build();
}
}
@Service
public class OrderClient {
@Autowired private CloseableHttpClient client;
public String callOrderSvc(String orderId) {
HttpGet get = new HttpGet("http://order-svc:8080/orders/" + orderId);
try (CloseableHttpResponse resp = client.execute(get)) {
return EntityUtils.toString(resp.getEntity());
} catch (IOException e) {
throw new RuntimeException(e);
}
// 注意:不要 close client,它是单例
}
}
上线后 TIME_WAIT 数从 56000 降到 800,完全消除问题。但这只是冰山一角。
第二招:tcp_tw_recycle 是个陷阱
网上很多教程会说「打开 tcp_tw_recycle 加速回收 TIME_WAIT」。这是个 5 年前就该淘汰的建议,Linux 4.12 已经移除这个参数。
原因:tcp_tw_recycle 在配合 tcp_timestamps 时,会拒绝来自同一个 IP 但时间戳更小的包。如果客户端在 NAT 后面(多个客户端共用一个出口 IP,但各自的 timestamp 是独立的),就会出现「同一个公网 IP 来的包,有些被服务端丢」的诡异现象。
# 别开!
sysctl -w net.ipv4.tcp_tw_recycle=1 # Linux 4.12+ 直接没这个参数了
# 可以开:tcp_tw_reuse —— 安全得多
# 它只在【客户端】发起新连接时,允许复用处于 TIME_WAIT 的端口
# 前提:对端时间戳合理,且我们是主动方
sysctl -w net.ipv4.tcp_tw_reuse=1
# 同时确保启用了 timestamps
sysctl -w net.ipv4.tcp_timestamps=1
# 写到 /etc/sysctl.conf 持久化
echo 'net.ipv4.tcp_tw_reuse = 1' >> /etc/sysctl.conf
sysctl -p
tcp_tw_reuse 对客户端有效。但服务端的 TIME_WAIT(对端发起 close 时,但本端正确进入 TIME_WAIT)它管不了。
第三招:扩大临时端口范围 + 缩短 TIME_WAIT
万一服务确实需要高 QPS 短连接(比如某些遗留接口无法改长连接),可以调系统参数兜底:
# 扩大可用端口
echo '10000 65000' > /proc/sys/net/ipv4/ip_local_port_range
# 现在有 55000 个端口可用,翻一倍
# 缩短 TIME_WAIT(默认 60 秒,改成 30 秒)
# 注意:这个是 Linux 编译期常量,只能通过修改 net.ipv4.tcp_fin_timeout 间接影响 FIN_WAIT_2
# 真正的 TIME_WAIT 时长 60s 在内核里写死(2*MSL=60s)
echo 30 > /proc/sys/net/ipv4/tcp_fin_timeout
# 限制 TIME_WAIT 总数(超过就直接 RST,有副作用!)
echo 5000 > /proc/sys/net/ipv4/tcp_max_tw_buckets
# 持久化到 /etc/sysctl.conf
cat >> /etc/sysctl.conf <<EOF
net.ipv4.ip_local_port_range = 10000 65000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_max_tw_buckets = 5000
EOF
sysctl -p
但要明白:这些参数都是缓解,不是根治。根治方法是改成长连接 + 连接池。
第四招:Nginx 反代场景的特殊问题
如果你用 Nginx 反代后端服务,默认配置下 Nginx 到上游的连接是短连接,每个请求建一个 TCP 连接然后关掉,全是 TIME_WAIT。
upstream backend {
server 10.0.5.27:8080;
server 10.0.5.28:8080;
server 10.0.5.29:8080;
# 关键:启用 keepalive 连接池
keepalive 64; # 每个 worker 跟每个上游保留 64 个空闲连接
keepalive_timeout 60s;
keepalive_requests 10000; # 单个连接最多复用 10000 次
}
server {
location /api/ {
proxy_pass http://backend;
# 关键 3 行:必须是 HTTP/1.1 + 清空 Connection 头
# 否则 nginx 默认走 HTTP/1.0,服务端立刻关连接
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
}
}
这三行配置漏掉一行,keepalive 就完全不生效。我们测过:加上 keepalive 之后 Nginx → 上游的 TIME_WAIT 从每秒新增 800 个降到每秒 < 5 个。
第五招:Go / Python 的 HTTP 客户端默认坑
不只 Java,几乎所有语言的 HTTP 库默认都有「不复用连接」的坑。
// Go: 错误用法 —— 每次新建 client
func badCall(url string) (string, error) {
client := &http.Client{Timeout: 5 * time.Second} // 每次新建,内部默认 Transport
resp, err := client.Get(url)
if err != nil { return "", err }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
// Go: 正确用法 —— 全局共享 client + 显式配置 Transport
var sharedClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 500,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false, // 默认就是 false,确保不是 true
ResponseHeaderTimeout: 10 * time.Second,
},
}
func goodCall(url string) (string, error) {
resp, err := sharedClient.Get(url)
if err != nil { return "", err }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
# Python requests 也有类似问题
import requests
# 错:每次新建 Session
def bad_call(url):
r = requests.get(url, timeout=5) # 内部隐式创建新 Session
return r.text
# 对:全局 Session + Adapter 控制连接数
from requests.adapters import HTTPAdapter
session = requests.Session()
adapter = HTTPAdapter(
pool_connections=100, # 不同目标 host 的连接池数
pool_maxsize=100, # 单个目标 host 的最大连接数
max_retries=3,
)
session.mount('http://', adapter)
session.mount('https://', adapter)
def good_call(url):
r = session.get(url, timeout=5)
return r.text
第六招:可观测性 —— 监控 TIME_WAIT
我们后来在所有应用机器上加了 Prometheus exporter,采集 TIME_WAIT 指标:
# node_exporter 自带的 netstat collector 会暴露:
# node_netstat_Tcp_CurrEstab
# node_sockstat_TCP_tw
# Prometheus 告警规则
groups:
- name: tcp-timewait
rules:
- alert: TimeWaitHigh
expr: node_sockstat_TCP_tw > 10000
for: 5m
labels: { severity: warning }
annotations:
summary: '{{ $labels.instance }} TIME_WAIT > 10000'
description: '当前值 {{ $value }},超过阈值可能引发端口耗尽'
- alert: TimeWaitCritical
expr: node_sockstat_TCP_tw > 25000
for: 1m
labels: { severity: critical }
annotations:
summary: '{{ $labels.instance }} TIME_WAIT > 25000 —— 接近端口耗尽'
runbook: 'https://wiki/runbook/timewait'
排查 TIME_WAIT 的 6 行命令
# 1. 总览
ss -s
# 2. 看 TIME_WAIT 数量
ss -ant state time-wait | wc -l
# 3. 看 TIME_WAIT 集中到哪些目标
ss -ant state time-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
# 4. 看本地端口范围
sysctl net.ipv4.ip_local_port_range
# 5. 看 tcp_tw_reuse 是否启用
sysctl net.ipv4.tcp_tw_reuse
# 6. 看每个进程占了多少 TIME_WAIT
lsof -i -P -n | grep TIME_WAIT | awk '{print $1}' | sort | uniq -c | sort -rn
事后总结的 5 个原则
- 能复用就复用:HTTP / DB / Redis 客户端都用连接池,单例 + Pool 是基础配置
- Nginx 反代必开 keepalive:
proxy_http_version 1.1+proxy_set_header Connection ""一行不能漏 - 别开 tcp_tw_recycle:NAT 场景下会丢包。
tcp_tw_reuse倒是可以开 - 临时端口范围扩到 10000-65000:不影响什么,有备无患
- 监控 TIME_WAIT 数:Prometheus 加 node_sockstat_TCP_tw 告警阈值
这次事故让我们彻底理解了 TCP 四次挥手的两端含义。下次再看到「Cannot assign requested address」,5 秒钟我就能猜到是 TIME_WAIT 而不是网络。
—— 别看了 · 2026