一次服务本地端口耗尽导致对外调用大量 Cannot assign requested address 的排查记录

订单服务突发大面积调用失败,日志刷屏 Cannot assign requested address,最终定位到本地临时端口被 TIME_WAIT 堆积耗尽。

问题背景

我们有一个订单聚合服务(Go 编写,跑在 8 核 16G 的云主机上),它的职责是接收前端下单请求后,再去调用下游的库存、优惠券、风控三个内部 HTTP 接口,把结果聚合返回。日常 QPS 在 3000 左右,一直很稳。

某天上午做了一次营销活动,流量涨到平时的 3~4 倍。活动开始约 20 分钟后,监控告警开始刷屏:订单服务的下游调用成功率从 99.9% 掉到 70% 以下,前端下单大面积超时。奇怪的是,被调用的库存、优惠券、风控三个服务自身指标都正常,CPU、内存、响应时间没有任何异常——问题明显出在"调用方"这一侧。

故障现象

登录订单服务所在主机,第一时间看应用日志,发现海量报错,几乎每秒都在刷:

1
2
3
dial tcp 10.20.3.11:8080: connect: cannot assign requested address
dial tcp 10.20.3.12:8080: connect: cannot assign requested address
dial tcp 10.20.3.13:8080: connect: cannot assign requested address

三个下游 IP 全部中招,且都是同一个报错:cannot assign requested address。这个报错对应的 errno 是 EADDRNOTAVAIL

几个关键现象:

  • 报错是间歇性爆发的:有时候一批请求全部成功,紧接着一批又全部失败,呈现"一阵一阵"的规律。
  • 服务自身的 CPU 只有 40% 左右,内存充足,GC 正常,完全不是资源打满的样子。
  • 下游服务端全程健康,网络到下游的 ping/traceroute 也没有丢包和延迟抖动。
  • 重启订单服务后,报错会消失几分钟,然后卷土重来。

cannot assign requested address 这个错误很有指向性:它不是"连不上对端"(那是 connection refusedtimeout),而是本机在发起连接、准备分配一个本地端口时就失败了。也就是说,本机的可用源端口(ephemeral port)不够用了。

排查过程

第一步:确认端口占用情况

TCP 主动发起连接时,内核会从本地临时端口范围里挑一个空闲端口作为源端口。先看这个范围:

1
2
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999

也就是可用临时端口约 60999 - 32768 = 28231 个。看起来不算少,但在高并发短连接场景下并不够。

再统计当前 TCP 连接状态分布:

1
2
3
4
5
$ ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
  26841 TIME-WAIT
   2013 ESTAB
     47 LISTEN
     12 SYN-SENT

问题一下就清晰了:TIME-WAIT 高达 2.6 万条,几乎把 28231 个临时端口范围占满了。剩下能分配的端口寥寥无几,内核挑不到空闲端口,就直接返回 EADDRNOTAVAIL

第二步:搞清楚 TIME_WAIT 是谁产生的

进一步看这些 TIME_WAIT 连的是谁:

1
2
3
4
$ ss -ant state time-wait | awk '{print $4}' | sed 's/:[0-9]*$//' | sort | uniq -c | sort -rn
   9021 10.20.3.11
   8890 10.20.3.12
   8720 10.20.3.13

全部指向三个下游服务的 IP。也就是说,是订单服务作为客户端,主动关闭了与下游的连接,从而在本机堆积了大量 TIME_WAIT(TIME_WAIT 始终出现在主动关闭连接的一方)。

为什么会有这么多短连接?拉出这个服务调用下游的 HTTP 客户端代码看,发现问题所在:

1
2
3
4
5
6
// 有问题的写法:每次调用都新建 client
func callInventory(req Req) (*Resp, error) {
    client := &http.Client{Timeout: 2 * time.Second}
    resp, err := client.Post(url, "application/json", body)
    ...
}

每次请求都 new 一个 http.Client,用完即弃。Go 的连接复用(keep-alive)依赖于复用同一个 Transport/Client;每次新建 Client 会创建全新的 Transport,请求结束后连接无法进入连接池被复用,直接被关闭,于是每一次下游调用都变成一条"建连—请求—主动关闭"的短连接。

流量放大 3~4 倍后,短连接产生速率超过了 TIME_WAIT 的回收速率(默认 2*MSL = 60s),端口于是被迅速耗尽。这也解释了为什么报错是"一阵一阵"的:端口耗尽 → 报错 → 部分 TIME_WAIT 到期释放 → 又能分配一批 → 再次耗尽。

第三步:排除内核参数误配

顺手确认了两个常被误用的内核参数:

1
2
3
4
$ sysctl net.ipv4.tcp_tw_reuse
net.ipv4.tcp_tw_reuse = 0
$ sysctl net.ipv4.tcp_tw_recycle
# 无输出,高版本内核已移除该参数

tcp_tw_recycle 在 NAT 环境下会导致丢包,早已在 4.12 内核被删除,不能用。tcp_tw_reuse 是安全的,允许在协议安全的前提下复用处于 TIME_WAIT 的端口给新的对外连接使用,但当时是关闭状态。

解决方案

分两步走:先止血,再根治。

1. 紧急止血(内核层面,无需改代码即可缓解)

1
2
3
4
# 允许复用 TIME_WAIT 端口用于新的对外连接(客户端安全)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 适当扩大临时端口范围
sysctl -w net.ipv4.ip_local_port_range="10000 65535"

改完后端口从约 2.8 万可用扩到约 5.5 万,加上 tw_reuse 复用,报错率立刻从 30% 降到 2% 以下,先把活动扛过去。写入 /etc/sysctl.conf 持久化:

1
2
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10000 65535

2. 根治(应用层面,复用连接)

真正的病根是短连接。把 HTTP 客户端改成全局单例并配置好连接池:

1
2
3
4
5
6
7
8
var httpClient = &http.Client{
    Timeout: 2 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 100, // 关键:默认只有 2,必须调大
        IdleConnTimeout:     90 * time.Second,
    },
}

重点是 MaxIdleConnsPerHost:Go 默认每个 host 只保留 2 个空闲连接,超出的连接用完即关,同样会退化成短连接。调大后,与每个下游之间维持一个稳定的长连接池,请求走 keep-alive 复用,几乎不再新建/关闭连接。

改造上线后再看:

1
2
3
4
$ ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
   1980 ESTAB
    210 TIME-WAIT
     47 LISTEN

TIME_WAIT 从 2.6 万降到 200 出头,端口耗尽问题彻底消失,下游调用成功率回到 99.99%。

根因分析

根因是应用层错误地使用 HTTP 客户端,导致对下游全部退化为短连接,在流量翻倍后短连接产生速率超过 TIME_WAIT 回收速率,本地临时端口被 TIME_WAIT 占满,内核无法为新连接分配源端口,抛出 EADDRNOTAVAILcannot assign requested address)。

内核默认的临时端口范围偏小、tcp_tw_reuse 未开启,是放大问题的次要因素;但即使不调内核,只要连接正确复用,也不会触发此问题。所以内核调优是"缓解",连接复用才是"根治"。

预防措施

  1. HTTP/DB/RPC 客户端一律复用:使用全局单例 Client,显式配置连接池(尤其 MaxIdleConnsPerHost),杜绝"每次请求 new 一个 client"。
  2. 监控 TIME_WAIT 与端口水位:把 ss -ant state time-wait | wc -l 和临时端口范围利用率纳入监控,设置阈值告警(如占用 > 70% 报警),做到端口耗尽前就发现。
  3. 合理设置内核参数:客户端主机开启 net.ipv4.tcp_tw_reuse=1、适当扩大 ip_local_port_range绝不启用 tcp_tw_recycle(NAT 下会丢包,新内核已移除)。
  4. 压测覆盖连接维度:上线前的压测不仅看 QPS 和延迟,还要观察连接状态分布,提前暴露短连接问题。
  5. 区分错误语义:把 connection refused(对端拒绝)、timeout(超时/丢包)、cannot assign requested address(本机端口耗尽)在告警里分类,减少误判方向的时间。

总结

cannot assign requested address 是个非常"点题"的错误——它几乎总是在告诉你:不是网络问题,也不是对端问题,而是本机的临时端口不够用了。这次故障的表象是下游调用失败,容易让人往网络和被调用方去查,但真正的病根在调用方自己的连接管理上。

一句话经验:高并发服务,连接一定要复用。TIME_WAIT 堆积、端口耗尽这类问题,内核参数只能缓解,正确复用连接才是根治之道。排查时先用 ss -ant | sort | uniq -c 看一眼连接状态分布,往往几秒钟就能锁定方向。

使用 Hugo 构建
主题 StackJimmy 设计