一个爬取任务跑到第十万次请求时开始报 Cannot assign requested address。机器负载很低,内存充足,带宽也只用了三成。真正用尽的是可用端口:每次发起连接要占用一个本地端口,而源 IP、源端口、目标 IP、目标端口构成一个四元组——四元组满了,新连接就建不出来。
这不是"连接太多",而是四元组的组合被用尽。理解这一点,才能分清三种看起来一样的报错:端口耗尽、文件描述符耗尽、连接池耗尽。
1. 三个"耗尽"的区别
| 报错/现象 | 真正耗尽的东西 | 典型数量级 |
|---|---|---|
Cannot assign requested address |
可用本地端口 | 约 2.8 万到 6.4 万 |
Too many open files |
进程文件描述符 | 由 ulimit -n 决定 |
| 连接等待超时、队列堆积 | 连接池容量 | 由池配置决定 |
Connection reset / 建连慢 |
对端 accept 队列 | 由 listen backlog 决定 |
第一种与第二种完全不同:端口是全局资源(按源 IP 计),描述符是进程资源。所以"加大 ulimit"能解决第二种,对第一种毫无帮助。
三种耗尽在报错文本上也可能混淆,因为它们最终都表现为"连接拿不到"。区分方法是看同一时刻的进程指标:端口耗尽时进程打开的描述符数不高、CPU 空闲;描述符耗尽时 lsof | wc -l 接近上限;池耗尽时池的等待队列有长度。
第三种最容易被误判成第一种:应用侧连接池满了,表现同样是"拿不到连接",但它发生在应用内部,与内核无关。
2. 四元组怎么被用尽
一个出站连接占用一个 (源IP, 源端口, 目标IP, 目标端口) 组合。可变的部分只有源端口。
| 条件 | 可用端口数 |
|---|---|
| 单个目标 IP + 单个目标端口 | 约 3.9 万(取决于内核参数) |
| N 个目标 IP(同目标端口) | 约 3.9 万 × N |
| 单个目标 IP + M 个目标端口 | 约 3.9 万 × M |
| 多源 IP(多网卡/多出口) | 按源 IP 数倍增 |
可以粗算一次:如果每秒新建 500 个连接、TIME_WAIT 保留 60 秒,那么稳态下被占用的端口约 3 万个——已经接近单个目标可用的上限。这个估算不需要工具,只需要两个数。
// 端口占用的稳态估算
占用端口数 ≈ 每秒新建连接数 × TIME_WAIT 秒数
例:500/s × 60s = 30000 → 接近上限,必须复用连接
50/s × 60s = 3000 → 安全
关键结论:可用组合数取决于"目标端有多少种"。所以访问单一服务时最容易耗尽——所有连接的目标 IP 与端口都相同,只有源端口可变。
// 看当前端口占用:哪个目标占得最多
ss -tan state all | awk 'NR>1 {print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
// 看 TIME_WAIT 的集中度(同一目标大量 TIME_WAIT 就是典型瓶颈)
ss -tan state time-wait | awk 'NR>1 {print $5}' | sort | uniq -c | sort -rn | head
3. TIME_WAIT 是不是元凶
TIME_WAIT 常被当成罪魁祸首,但它只是"端口被占用一段时间"的表现,不是根因。
| 状态 | 是否占用端口 | 持续时间 |
|---|---|---|
| ESTABLISHED | 是 | 连接存活期 |
| FIN_WAIT / CLOSE_WAIT | 是 | 关闭握手期间 |
| TIME_WAIT | 是 | 约 2 倍 MSL(典型 60 秒) |
| CLOSED | 否 | — |
TIME_WAIT 的作用是保证最后那个 ACK 能被重传,以及防止旧连接的延迟报文被新连接误收。把它缩短或消除会引入别的风险,所以正确做法不是"清理 TIME_WAIT",而是让连接不要这么快地反复建立和关闭。
// 三组参数,各自管不同的事,不要混调
net.ipv4.ip_local_port_range = 10240 65000 // 扩大可用端口范围
net.ipv4.tcp_tw_reuse = 1 // 允许复用 TIME_WAIT 于出站连接(仅客户端有效)
net.ipv4.tcp_max_tw_buckets = 20000 // 上限,超过则直接丢弃 TIME_WAIT
// 注意:tcp_tw_recycle 在新内核已移除,不要再用
4. 复用连接才是主要手段
端口耗尽的根因几乎总是"连接建立得太频繁",所以最有效的手段是复用。
| 手段 | 效果 | 前提 |
|---|---|---|
| 连接池(HTTP keep-alive) | 端口占用与并发数成正比而非请求数 | 服务端支持长连接 |
| 连接池上限调优 | 控制并发上限 | 池要按目标分池 |
| 多出口 IP | 倍增可用组合 | 需要网络配置 |
| 减少短连接(批量接口) | 减少建连次数 | 服务端提供批量能力 |
复用还带来一个附带收益:避免建连风暴。每次新建连接都要 TCP 三次握手 + 可能的 TLS 握手,后者的开销是前者的数倍。在高峰期,这部分开销常常比业务处理本身还大。
第一项的效果最直接:一个连接可以承载成百上千次请求。很多"端口耗尽"的场景,实际是每个请求都新建了连接——常见于没配 keep-alive 的客户端、或者中间件强制关闭连接。
// 客户端连接复用的三个要点
var pool = new ConnectionPool(target)
.maxTotal(200) // 总连接上限,按目标容量定
.maxPerRoute(50) // 单目标上限,防单目标打满
.keepAlive(Duration.ofSeconds(60))// 复用窗口
.validateAfter(Duration.ofSeconds(30)); // 空闲校验,避免用到已被对端关闭的连接
// 关键:maxPerRoute × 目标数 应远小于端口上限,否则仍可能耗尽
5. 什么时候端口耗尽反而说明设计问题
| 症状 | 含义 | 改法 |
|---|---|---|
| 每秒建连数极高 | 请求粒度太细 | 合并请求或加批量接口 |
| 连接建立与关闭 1:1 | 未复用 | 配 keep-alive |
| 单目标占满所有端口 | 单点依赖 | 加目标分散或缓存 |
| 多进程各自建连 | 进程级池无法共享 | 集中出口代理 |
这里有一个容易忽略的事实:端口是按源 IP 计的。宿主机上的多个容器共享同一个出口 IP 时,它们争的是同一批端口;而如果每个容器有自己的出口 IP(如某些 CNI 配置),可用组合数就按容器数倍增。这条差异决定了"多副本能不能缓解端口压力"。
第四项在容器化环境里很常见:十个副本各开 200 个连接,合计 2000 个出站连接指向同一目标。单个副本看起来"池没满",合计已经接近端口或对端上限。端口是按宿主机与源 IP 计算的,不是按进程。
6. 排错:五种"连不上但不是网络问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
Cannot assign requested address |
端口耗尽 | 复用连接、扩端口范围、多出口 |
| 建连偶发超时 | 对端 accept 队列满 | 调大 backlog 或降建连速率 |
| 大量 CLOSE_WAIT | 应用没关连接 | 检查代码中未关闭的响应流 |
Too many open files |
描述符耗尽 | 调 ulimit 与池上限 |
| 长连接被中断 | 中间设备空闲超时 | 缩短空闲时间或加心跳 |
// 五种状态的分布,一眼看出问题在哪
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
// TIME_WAIT 占绝大多数 → 建连过频,去复用连接
// CLOSE_WAIT 持续增长 → 应用没关连接,改代码
// SYN-SENT 堆积 → 对端不可达或丢包,查网络
第三项与端口耗尽方向相反:CLOSE_WAIT 堆积说明连接被对端关闭了,而本地没调用 close——这是应用缺陷,调内核参数无效。
7. 边界:什么情况不必关心端口
| 场景 | 为什么 | 做法 |
|---|---|---|
| 服务端(入站连接) | 入站不占本地端口,只占描述符 | 关注 backlog 与描述符 |
| 长连接为主的服务 | 连接的建立频率极低 | 关注连接数与内存 |
| 有统一出口代理 | 端口由代理承担 | 关注代理侧容量 |
| 请求量小(每秒几十) | 组合数远远够用 | 不必优化 |
还有一条与容量相关:端口耗尽往往先在某个副本上出现,然后扩散。因为分流不均时,某一个副本承担的建连数更高,它先打满端口,随后流量被重试推向其它副本,逐个打满。看到"一个副本报错、其它正常"时,不要以为是单机故障。
判断口径一句话:每秒新建多少连接。这个数字乘以 TIME_WAIT 时长,就是同时被占用的端口数;它接近端口范围时,问题一定会来。
8. 自测
- 端口耗尽、描述符耗尽、连接池耗尽分别耗尽的是什么?为什么"加大 ulimit"对第一种无效?
- 四元组里哪一部分可变?为什么访问单一服务时最容易耗尽?
- TIME_WAIT 占不占端口?它存在的目的是什么,为什么不该简单消除?
- 为什么"十个副本各 200 连接"可能比"一个副本 2000 连接"更危险?
- CLOSE_WAIT 堆积说明什么?为什么调内核参数无效?
- 用哪两个数字可以估算"端口是否会耗尽"?