凌晨被叫起来排查 TIME_WAIT 堆 5 万的故事:从端口耗尽到连接池治理

一台业务网关凌晨开始报 Cannot assign requested address,netstat 看到 5.6 万个 TIME_WAIT。本文复盘从端口耗尽到连接池修复的全过程,涵盖 Java/Go/Python HTTP 客户端正确写法、Nginx keepalive 配置、tcp_tw_recycle 为什么不能开,以及 6 行排查命令。

线上一台业务网关凌晨开始报「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 秒)。这个状态的存在是为了:

  1. 确保最后一个 ACK 到达对端(如果丢了,对端会重发 FIN,本端能正确响应 RST)
  2. 防止延迟的旧报文影响新连接(同样的四元组在 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 个原则

  1. 能复用就复用:HTTP / DB / Redis 客户端都用连接池,单例 + Pool 是基础配置
  2. Nginx 反代必开 keepalive:proxy_http_version 1.1 + proxy_set_header Connection "" 一行不能漏
  3. 别开 tcp_tw_recycle:NAT 场景下会丢包。tcp_tw_reuse 倒是可以开
  4. 临时端口范围扩到 10000-65000:不影响什么,有备无患
  5. 监控 TIME_WAIT 数:Prometheus 加 node_sockstat_TCP_tw 告警阈值

这次事故让我们彻底理解了 TCP 四次挥手的两端含义。下次再看到「Cannot assign requested address」,5 秒钟我就能猜到是 TIME_WAIT 而不是网络。

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

Redis 大 key 让我们整个集群卡了 10 分钟:从排查到拆分的完整复盘

2026-5-19 10:16:43

技术教程

Go goroutine 泄漏:跑了 47 天 OOMKilled 后我们找到的 5 个真实场景

2026-5-19 10:20:56

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