问题背景
我们有一个订单聚合服务(Go 编写,跑在 8 核 16G 的云主机上),它的职责是接收前端下单请求后,再去调用下游的库存、优惠券、风控三个内部 HTTP 接口,把结果聚合返回。日常 QPS 在 3000 左右,一直很稳。
某天上午做了一次营销活动,流量涨到平时的 3~4 倍。活动开始约 20 分钟后,监控告警开始刷屏:订单服务的下游调用成功率从 99.9% 掉到 70% 以下,前端下单大面积超时。奇怪的是,被调用的库存、优惠券、风控三个服务自身指标都正常,CPU、内存、响应时间没有任何异常——问题明显出在"调用方"这一侧。
故障现象
登录订单服务所在主机,第一时间看应用日志,发现海量报错,几乎每秒都在刷:
|
|
三个下游 IP 全部中招,且都是同一个报错:cannot assign requested address。这个报错对应的 errno 是 EADDRNOTAVAIL。
几个关键现象:
- 报错是间歇性爆发的:有时候一批请求全部成功,紧接着一批又全部失败,呈现"一阵一阵"的规律。
- 服务自身的 CPU 只有 40% 左右,内存充足,GC 正常,完全不是资源打满的样子。
- 下游服务端全程健康,网络到下游的 ping/traceroute 也没有丢包和延迟抖动。
- 重启订单服务后,报错会消失几分钟,然后卷土重来。
cannot assign requested address 这个错误很有指向性:它不是"连不上对端"(那是 connection refused 或 timeout),而是本机在发起连接、准备分配一个本地端口时就失败了。也就是说,本机的可用源端口(ephemeral port)不够用了。
排查过程
第一步:确认端口占用情况
TCP 主动发起连接时,内核会从本地临时端口范围里挑一个空闲端口作为源端口。先看这个范围:
|
|
也就是可用临时端口约 60999 - 32768 = 28231 个。看起来不算少,但在高并发短连接场景下并不够。
再统计当前 TCP 连接状态分布:
|
|
问题一下就清晰了:TIME-WAIT 高达 2.6 万条,几乎把 28231 个临时端口范围占满了。剩下能分配的端口寥寥无几,内核挑不到空闲端口,就直接返回 EADDRNOTAVAIL。
第二步:搞清楚 TIME_WAIT 是谁产生的
进一步看这些 TIME_WAIT 连的是谁:
|
|
全部指向三个下游服务的 IP。也就是说,是订单服务作为客户端,主动关闭了与下游的连接,从而在本机堆积了大量 TIME_WAIT(TIME_WAIT 始终出现在主动关闭连接的一方)。
为什么会有这么多短连接?拉出这个服务调用下游的 HTTP 客户端代码看,发现问题所在:
|
|
每次请求都 new 一个 http.Client,用完即弃。Go 的连接复用(keep-alive)依赖于复用同一个 Transport/Client;每次新建 Client 会创建全新的 Transport,请求结束后连接无法进入连接池被复用,直接被关闭,于是每一次下游调用都变成一条"建连—请求—主动关闭"的短连接。
流量放大 3~4 倍后,短连接产生速率超过了 TIME_WAIT 的回收速率(默认 2*MSL = 60s),端口于是被迅速耗尽。这也解释了为什么报错是"一阵一阵"的:端口耗尽 → 报错 → 部分 TIME_WAIT 到期释放 → 又能分配一批 → 再次耗尽。
第三步:排除内核参数误配
顺手确认了两个常被误用的内核参数:
|
|
tcp_tw_recycle 在 NAT 环境下会导致丢包,早已在 4.12 内核被删除,不能用。tcp_tw_reuse 是安全的,允许在协议安全的前提下复用处于 TIME_WAIT 的端口给新的对外连接使用,但当时是关闭状态。
解决方案
分两步走:先止血,再根治。
1. 紧急止血(内核层面,无需改代码即可缓解)
|
|
改完后端口从约 2.8 万可用扩到约 5.5 万,加上 tw_reuse 复用,报错率立刻从 30% 降到 2% 以下,先把活动扛过去。写入 /etc/sysctl.conf 持久化:
|
|
2. 根治(应用层面,复用连接)
真正的病根是短连接。把 HTTP 客户端改成全局单例并配置好连接池:
|
|
重点是 MaxIdleConnsPerHost:Go 默认每个 host 只保留 2 个空闲连接,超出的连接用完即关,同样会退化成短连接。调大后,与每个下游之间维持一个稳定的长连接池,请求走 keep-alive 复用,几乎不再新建/关闭连接。
改造上线后再看:
|
|
TIME_WAIT 从 2.6 万降到 200 出头,端口耗尽问题彻底消失,下游调用成功率回到 99.99%。
根因分析
根因是应用层错误地使用 HTTP 客户端,导致对下游全部退化为短连接,在流量翻倍后短连接产生速率超过 TIME_WAIT 回收速率,本地临时端口被 TIME_WAIT 占满,内核无法为新连接分配源端口,抛出 EADDRNOTAVAIL(cannot assign requested address)。
内核默认的临时端口范围偏小、tcp_tw_reuse 未开启,是放大问题的次要因素;但即使不调内核,只要连接正确复用,也不会触发此问题。所以内核调优是"缓解",连接复用才是"根治"。
预防措施
- HTTP/DB/RPC 客户端一律复用:使用全局单例 Client,显式配置连接池(尤其
MaxIdleConnsPerHost),杜绝"每次请求 new 一个 client"。 - 监控 TIME_WAIT 与端口水位:把
ss -ant state time-wait | wc -l和临时端口范围利用率纳入监控,设置阈值告警(如占用 > 70% 报警),做到端口耗尽前就发现。 - 合理设置内核参数:客户端主机开启
net.ipv4.tcp_tw_reuse=1、适当扩大ip_local_port_range;绝不启用tcp_tw_recycle(NAT 下会丢包,新内核已移除)。 - 压测覆盖连接维度:上线前的压测不仅看 QPS 和延迟,还要观察连接状态分布,提前暴露短连接问题。
- 区分错误语义:把
connection refused(对端拒绝)、timeout(超时/丢包)、cannot assign requested address(本机端口耗尽)在告警里分类,减少误判方向的时间。
总结
cannot assign requested address 是个非常"点题"的错误——它几乎总是在告诉你:不是网络问题,也不是对端问题,而是本机的临时端口不够用了。这次故障的表象是下游调用失败,容易让人往网络和被调用方去查,但真正的病根在调用方自己的连接管理上。
一句话经验:高并发服务,连接一定要复用。TIME_WAIT 堆积、端口耗尽这类问题,内核参数只能缓解,正确复用连接才是根治之道。排查时先用 ss -ant | sort | uniq -c 看一眼连接状态分布,往往几秒钟就能锁定方向。