一、先从一次让我头疼的故障说起

那天下午,服务刚上线,压测工具一开始跑,客户端日志里唰唰地冒出“Cannot assign requested address”。同事的第一反应是服务器挂了,结果服务端一切正常。我盯着报错看了十分钟,猛然想起以前看过的一篇文章:这是客户端把端口用光了。

打个比方。一个小区有很多栋楼,每栋楼有很多房间。IP 地址是小区地址,端口是房间号。你要给住在另一栋楼的人发消息,必须写清对方的“小区+房间号”,还要用自己小区的“房间号”作为回信地址。这个自己的“房间号”就是本地端口。房间号一共只有 65535 个,听上去很多,但 Linux 默认给你用来发信的房间只有 32768 到 60999 这一小段。一旦同时占用的房间超过这个数,新来的消息就没房间可用了,于是直接报错。

这只是一个引子,真正的问题是:为什么端口会不够用?怎么才能安全地让它够用?这篇文章会一步一步拆开讲。

二、端口到底是什么?为什么说“耗尽”?

2.1 端口就像门牌号

TCP 连接要建立一条虚拟“信道”,需要四个信息:源 IP、源端口、目标 IP、目标端口。这四个合起来叫“四元组”。只要是不同的四元组,就是两条不同的连接。想象两个小区之间有几条道路,你需要指定从哪个小区哪栋楼出发,到哪个小区哪栋楼。楼号可以一样,但小区不一样就没问题。

2.2 真正的稀缺资源是本地端口

在一台机器上,目标 IP 和目标端口通常是固定的(比如你访问的同一个服务)。那么唯一的变量就是源 IP 和源端口。如果源 IP 也只有一个,那真正能区分连接的就只剩下源端口了。所以,一次最多能建立的连接数,基本等于可用的本地端口数量。

Linux 用 ip_local_port_range 这个内核参数来规定“临时端口”的范围。不同的发行版默认值不太一样,常见的是 32768 到 60999。我们来亲眼看看(见代码块1)。

# 技术栈:Bash
# 查看当前操作系统分配的临时端口范围
sysctl net.ipv4.ip_local_port_range

输出一般长这样:

# 技术栈:Bash
net.ipv4.ip_local_port_range = 32768 60999

这表示从 32768 到 60999,一共可以拿出来的端口号有 28232 个。看起来挺多,但高并发短连接场景下,很快就能消耗完。

三、端口耗尽的深层根源

3.1 TIME_WAIT 是头号“钉子户”

TCP 协议要保证可靠通信。当客户端主动关闭一条连接时,它要进入一个叫做 TIME_WAIT 的状态,而且要等 2 个 MSL(最长报文段寿命)之后才肯彻底离开。这个时间通常长则 2 分钟,短则 30 秒。在这段时间里,刚才用过的那个端口一直在“占着茅坑不拉屎”,新连接无法使用它。

想象你经常在一个酒店房间办完事就退房,但酒店规定退房后两小时内不能再把这个房间卖给任何人。你的预订系统就只好不断开新房间。一旦酒店的房间总数有限,而你开房速度特别快,没过多久就满房了。

3.2 内核分配端口的方式很“憨”

内核分配临时端口时,会在这个范围内寻找一个“当前没有被占用”的端口。什么算占用?正在连接、监听、TIME_WAIT、FIN_WAIT 等等,都算。所以,即使很多连接已经关闭,只要还处在 TIME_WAIT,端口就仍然被标记为不可用。如果连续创建的连接特别多,而且都是主动关闭,那么 TIME_WAIT 连接会像滚雪球一样积累,直到把端口号耗尽。

用一条命令就能看到 TIME_WAIT 到底有多少:

# 技术栈:Bash
# 统计当前各种 TCP 连接状态的数量
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c

输出中如果 TIME_WAIT 数量特别多,说明端口正在被大量占用。

3.3 还有一个容易忽略的限制:源 IP 多不多

如果你的客户端机器上有多个网卡、多个 IP,那么每个 IP 都有自己独立的一套端口空间。也就是说,如果你有两个 IP,可用端口数可以翻倍。但大多数云服务器只有一个内网 IP,所以这个红利享受不到。

四、最直接的解法:把端口范围调大

4.1 怎么调?

既然端口不够,那就把门牌号的可用范围扩大。Linux 端口号是 16 位的,最大只能到 65535。所以最多也就是把范围设成从 1024 到 65535。注意不要设成从 1 开始,因为 0 到 1023 之间有很多特权端口,可能被系统服务占用,强行分配容易冲突。

下面是修改办法(见代码块3)。

# 技术栈:Bash
# 临时修改端口范围(立即生效,重启失效)
sysctl -w net.ipv4.ip_local_port_range="1024 65535"

# 永久修改:写入/etc/sysctl.conf,之后执行sysctl -p生效
echo "net.ipv4.ip_local_port_range = 1024 65535" >> /etc/sysctl.conf
sysctl -p

4.2 扩大的代价

范围变大的好处是临时端口多了,但代价是每个端口号都可能被占用,系统在查找空闲端口时需要扫描更多数字。这个开销在高频建连时会略微增加。不过相比断连造成的影响,这点损耗通常可以接受。

然而,扩大范围只是“缓兵之计”。如果短连接速率极高,两分钟之内的建连数超过端口总数,依然会耗尽。所以还需要更聪明的办法。

五、更优雅的解法:让端口可以被“复用”

5.1 安全地复用 TIME_WAIT 端口

Linux 提供了一个参数 net.ipv4.tcp_tw_reuse,打开它之后,内核会允许把处于 TIME_WAIT 状态的连接重新分配给新连接。但是有一个前提:新连接的初始序列号要比 TIME_WAIT 连接记录里的最后序列号大。这样能保证不会跟旧连接的数据包混淆。

开启方式很简单(见代码块4)。

# 技术栈:Bash
# 开启tcp_tw_reuse,让处于TIME_WAIT的端口可以被新连接复用
sysctl -w net.ipv4.tcp_tw_reuse=1

# 同样可以持久化
echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf
sysctl -p

5.2 千万不要碰 tcp_tw_recycle

在旧版本 Linux 还有一个参数 tcp_tw_recycle,很多博客会推荐开启。这个参数在 NAT 环境下会出大问题,因为多个内网设备共用同一个公网出口 IP,内核无法正确判断时间戳,会导致很多连接被误杀。而且从 Linux 4.12 开始,这个参数已经被移除了。所以如果你用的是新内核,根本不用纠结,千万不要去打开它。

5.3 tcp_tw_reuse 也不是万能的

tcp_tw_reuse 有一个使用限制:它只对主动发起连接的一端有效。也就是说,客户端的出站连接可以受益,服务端因为不主动关闭,所以影响不大。而且,如果你的客户端连接要经过 NAT 或负载均衡,这个参数有可能引发一些奇怪的问题,因为公网 IP 背后是多台机器,时间戳或序列号条件可能不可靠。因此,在云服务器上开启之前,最好先做一轮小流量验证。

5.4 关联技术:SO_REUSEADDR 和 SO_REUSEPORT

在应用层,还有两个套接字选项也容易混淆。SO_REUSEADDR 允许一个端口在 TIME_WAIT 状态下被重新绑定,主要用于服务器重启;SO_REUSEPORT 允许多个进程同时监听同一个端口,用于负载均衡。它们和 tcp_tw_reuse 不是一回事。但它们的名字里都有 REUSE,很多朋友会搞混。

六、一个完整的实战示例

下面我用 Bash 写一个小实验,模拟“快速创建短连接导致端口耗尽”的现象。先说明环境:本地 Linux 机器,需要 nc 命令(netcat)作为临时服务器。如果你没有,可以用 apt install netcat-openbsd 或 yum install nc 来安装。

脚本的思路是:先在 8080 端口启动一个监听服务,然后用循环不断向它发起连接,每次连接建立后立即关闭。由于客户端主动关闭,会产生大量 TIME_WAIT,端口会被慢慢占满。

下面是完整的测试脚本(见代码块5)。

#!/bin/bash
# 技术栈:Bash
# 用途:模拟客户端本地端口耗尽
# 注意:脚本需要在有netcat的Linux环境执行

SERVER_IP="127.0.0.1"
SERVER_PORT=8080

# 1. 启动一个临时TCP服务器(后台运行)
nc -l $SERVER_PORT &
NC_PID=$!
# 等服务器就绪
sleep 1

# 2. 初始化计数器
success=0
fail=0

echo "=== 开始连接测试 ==="

# 3. 尝试连接 30000 次
for i in $(seq 1 30000); do
    # 建立连接,如果失败则重定向错误到黑洞
    if exec 3<>/dev/tcp/$SERVER_IP/$SERVER_PORT 2>/dev/null; then
        # 连接成功,立刻关闭,触发TIME_WAIT
        exec 3<&-
        exec 3>&-
        success=$((success + 1))
    else
        fail=$((fail + 1))
    fi

    # 每 1000 次打印一次进度
    if (( i % 1000 == 0 )); then
        echo "进度: $i, 成功: $success, 失败: $fail"
    fi
done

echo "=== 测试结束 ==="
echo "总共成功: $success, 失败: $fail"

# 4. 清理后台服务器
kill $NC_PID 2>/dev/null

在默认端口范围(32768-60999)下运行,大概率会在某个地方开始报失败。接下来,我们先把端口范围扩大,再开启 tcp_tw_reuse,重新跑一遍,看看结果有什么不同(见代码块6)。

# 技术栈:Bash
# 调整内核参数:扩大端口范围 + 开启TIME_WAIT复用
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1

# 再次运行刚才的测试脚本
./port_exhaust_test.sh

你会发现,同样的 3 万次连接,失败次数变成了 0。这就是调优的威力。

当然,如果连接数继续加大,比如 7 万、10 万,即使调大范围也还是会失败。因为端口总数不超过 65535。要支持更大的连接数,就得靠多 IP,或者从架构上避免那么多短连接。

七、应用场景与技术优缺点

7.1 适合什么场景

  • 高并发短连接,比如 HTTP/1.1 老式长轮询、压测工具、爬虫、消息推送等。
  • 客户端机器 IP 数量有限,但需要大量出站连接。
  • 服务端主动发起的回源连接等。

7.2 优点

  • 操作简单,只需要改两个内核参数。
  • 不需要重启服务,能够立即缓解故障。
  • tcp_tw_reuse 可以显著减少 TIME_WAIT 数量,让端口周转更快。

7.3 缺点和风险

  • 端口范围不是无限扩展,65535 是硬上限。
  • tcp_tw_reuse 在 NAT 或虚拟化环境中可能有副作用,需要小范围验证。
  • 开启后,如果客户端和服务端系统内核行为不同,有可能出现连接异常,比如握手超时、数据错乱。虽然概率低,但要注意。

八、注意事项大汇总

第一,修改内核参数前先备份原值。可以用 sysctl 命令先记录旧值,方便回滚。

第二,ip_local_port_range 不能设置成包含已经被大量监听的端口,比如 80、443。虽然内核会自动避开监听端口,但为了避免奇怪问题,建议从 1024 开始。

第三,tcp_tw_reuse 和 tcp_tw_recycle 不要同时开启,尤其是后者已经被内核废弃。

第四,在容器环境里,有些参数是 namespace 隔离的,修改宿主机的参数可能不生效,需要进入容器的命名空间改。可以用 sysctl net.ipv4.ip_local_port_range 检查一下。

第五,如果连接数需求真的很大,从客户端侧可以绑定多个 IP,每个 IP 都是独立的端口池。也可以使用连接池等应用层手段,减少短连接数量。

第六,开启 tcp_tw_reuse 之后,一定要用 ss 命令观察 TIME_WAIT 的数量变化。如果发现异常连接增多,赶紧回滚。

九、最后总结

客户端本地端口耗尽这个问题,表面上看是端口号不够用,深层根源是 TIME_WAIT 占用了大量端口,而默认的临时端口范围又太小。解决思路也清晰:一是扩大端口范围,让池子更大;二是开启 tcp_tw_reuse,提高端口复用效率。但任何调优都要权衡安全和兼容性,尤其是 NAT 环境下要格外谨慎。

编程世界里很多故障都像这次一样,看起来是网络问题,实质是内核资源管理问题。理解端口、四元组和 TCP 状态机,遇到类似问题就不会慌。希望这篇文章能帮你少走一些弯路。