2024 年的一个晚上,我上线一个服务的新版本。流程很常规:停掉老进程,启动新进程。我 kill 掉老进程,确认它没了,然后启动新版本——结果新进程起来不到一秒就退了,报错只有一行:bind: Address already in use,端口 8080 已被占用。我心里第一个念头是:老进程没杀干净。于是我 ps 一看,老进程确确实实不在了;我又 ss -tlnp 看 8080 端口上有没有人在监听,结果是空的——没有任何进程在 LISTEN 这个端口。这就矛盾了:没有任何进程占着 8080,可内核就是不让我的新进程绑上去,一口咬定"地址已被使用"。我又试着重启了几次,每次都是同样的报错;诡异的是,我去吃了个饭、过了一两分钟回来再启动,它居然又能起来了。一个时好时坏、还跟"等多久"有关的端口占用——这说明占着端口的,根本不是某个"进程",而是某种我看不见的、会随时间自己消失的东西。我盯着这个现象想了很久,最后才意识到:占着 8080 的,不是一个进程,是一堆处于 TIME_WAIT 状态的旧连接。这件事逼着我把 TCP 连接的状态机、TIME_WAIT、SO_REUSEADDR 这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个监听 8080 端口的服务,要发布新版本
事故现象:
- kill 掉老进程后,启动新进程,报 bind: Address already in use
- ★ ps 看老进程确实没了,ss -tlnp 看 8080 也没人在 LISTEN
- 等一两分钟后再启动,又能起来了
现场排查:
# 1. 老进程真的没了
$ ps -ef | grep myapp | grep -v grep
#(空) # ★ 老进程确实不在了
# 2. ★ 8080 端口上没有任何进程在 LISTEN
$ ss -tlnp | grep 8080
#(空) # ★ 没人监听,可还是绑不上
# 3. ★ 换个看法:看 8080 上【所有状态】的连接
$ ss -tan | grep 8080
TIME-WAIT 0 0 10.0.0.5:8080 10.0.0.9:51324
TIME-WAIT 0 0 10.0.0.5:8080 10.0.0.9:51330
TIME-WAIT 0 0 10.0.0.5:8080 10.0.0.9:51338
... (几十上百条 TIME-WAIT) # ★ 端口被一堆 TIME-WAIT 占着
# 4. ★ 数一下到底有多少
$ ss -tan | grep 8080 | grep TIME-WAIT | wc -l
217 # ★ 217 个 TIME-WAIT
根因(后来想清楚的):
1. ★ "端口被占用",占着它的【不一定是一个进程】。
一个 TCP 连接走完生命周期、关闭之后,会进入一个
叫 TIME_WAIT 的状态,在那里【再停留一段时间】才
彻底消失。停留期间,它仍然占着那一组地址端口。
2. 我 kill 老进程时,它身上还连着几百个客户端连接。
进程一死,这几百个连接被关闭,全部进入 TIME_WAIT。
3. ★ 我的新进程想 bind 0.0.0.0:8080 —— 内核一看,
这个端口上还挂着几百个 TIME_WAIT 连接,默认情况下
就【不允许】新进程绑这个端口,于是报 Address
already in use。
4. 我"吃个饭回来又能起了",是因为那段时间里
TIME_WAIT 自己到点、自然消失了,端口腾出来了。
5. ★ 真正干净的解法,不是等、不是杀,是让程序在
bind 之前设置 SO_REUSEADDR 这个 socket 选项。
"端口被占用"不等于"有进程占着它",TIME_WAIT 也会占着它。
修复 1:"端口被占用",占着它的到底是什么
# === ★ 纠正核心误解:占端口的不一定是"进程" ===
# === 我以为的 vs 真实的 ===
# 我以为:端口被占用,就一定有一个【活着的进程】
# 正在用它。我只要找到那个进程、杀掉它,端口就空了。
# ★ 真相:端口能被占用的情况不止"有进程在 LISTEN"。
# 一个进程已经退出了,它留下的【连接】仍可能在
# TIME_WAIT 状态里占着这个端口 —— 此时没有任何
# 进程,但端口确实"被占用"。
# === ★ ss -tlnp 只看 LISTEN,会漏掉真相 ===
# 我一开始用的命令:
$ ss -tlnp | grep 8080
# -t = TCP -l = 只看 LISTEN 状态 -n = 不解析名字
# -p = 显示进程
# ★ 问题就出在 -l:它【只列正在监听的 socket】。
# TIME_WAIT 不是 LISTEN 状态,所以被它过滤掉了 ——
# 我当然"什么都没看到"。
# === ★ 正确的看法:看这个端口上【所有状态】的连接 ===
$ ss -tan | grep 8080
# -a = all,★ 列出所有状态(LISTEN/ESTAB/TIME-WAIT...)
# 去掉了 -l,这才能看见 TIME_WAIT。
LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*
ESTAB 0 0 10.0.0.5:8080 10.0.0.9:51200
TIME-WAIT 0 0 10.0.0.5:8080 10.0.0.9:51324 # ★ 它也占着端口
# ★ 一个 TCP 连接由【四元组】唯一确定:
# 本地IP:本地端口 <-> 对端IP:对端端口
# 只要这个连接还没彻底消失(哪怕在 TIME_WAIT),
# 这个四元组、这个本地端口,就还"有主"。
# === 按状态统计这个端口上的连接 ===
$ ss -tan | grep 8080 | awk '{print $1}' | sort | uniq -c
217 TIME-WAIT # ★ 真相:217 个 TIME-WAIT 占着 8080
1 LISTEN
# ★ 这一条命令就把"端口到底被什么占着"看清楚了。
# === 认知 ===
# ★ 报 Address already in use 时,别只 ss -tlnp 找进程。
# 要 ss -tan 看这个端口上【所有状态】的连接 ——
# 占着它的,很可能是一堆没有进程的 TIME_WAIT。
修复 2:TIME_WAIT 是什么——它为什么要赖着不走
# === ★ 看懂 TIME_WAIT:它不是 bug,是 TCP 故意的设计 ===
# === 一个 TCP 连接是怎么"死"的 ===
# TCP 连接关闭要走"四次挥手"。挥手完成后,★ 主动
# 关闭连接的那一方,不会立刻消失,而是进入一个
# 特殊状态:TIME_WAIT,在这里停留一段时间。
# 这个停留时长 = 2 * MSL(报文最大生存时间),
# Linux 上通常固定是 60 秒。
# === ★ 为什么非要停留这 60 秒 ===
# TIME_WAIT 不是"忘了清理",是 TCP【故意】留的,
# 主要为两件事:
# 1. ★ 防止"上一个连接的迷路报文"污染新连接。
# 网络里可能还飘着这条旧连接的延迟数据包。如果
# 端口立刻被新连接复用,那个迟到的旧包就可能被
# 新连接错收 —— TIME_WAIT 等 60 秒,等这些
# 迷路包在网络里全部过期消失。
# 2. 保证对端能可靠地关闭。万一最后一个 ACK 丢了,
# 对端会重发 FIN,主动关闭方还停在 TIME_WAIT,
# 就能再回它一个 ACK,让对端正常关闭。
# === ★ 为什么"重启服务"特别容易撞上它 ===
# 关键点:谁【主动】关闭连接,TIME_WAIT 就堆在谁那边。
# - 一个服务端,正连着几百个客户端。
# - 你重启服务 -> 进程退出 -> 这几百个连接由【服务端
# 这边】主动关闭 -> ★ 几百个 TIME_WAIT 全压在
# 服务器的 8080 端口上。
# - 你立刻启动新进程要 bind 8080 —— 撞上了。
# ★ 所以"刚 kill 完就重启"这个动作,几乎是 TIME_WAIT
# 占端口问题的标准触发场景。
# === 看看 TIME_WAIT 总量 ===
$ ss -tan state time-wait | wc -l
$ cat /proc/net/sockstat | grep -i tw
# ★ 高并发短连接的服务器,TIME_WAIT 成千上万很常见,
# 本身不一定是问题 —— 它们 60 秒后会自己消失。
# === ★ 一个常见的错误认知 ===
# 很多人一看到大量 TIME_WAIT 就慌,想方设法"消灭"它。
# 其实它是正常的、会自愈的。真正要解决的,是
# "重启时端口绑不上"这个具体问题 —— 而那靠的是
# 下一节的 SO_REUSEADDR,不是去消灭 TIME_WAIT。
修复 3:SO_REUSEADDR——为什么有的程序能秒重启
# === ★ 关键钥匙:bind 之前设 SO_REUSEADDR ===
# === 一个对比:同样重启,为什么待遇不同 ===
# 你可能注意过:有些服务你 kill 完立刻重启,毫无问题;
# 有些服务就报 Address already in use。代码一样写的
# 网络程序,差别在哪?
# ★ 差别就在:它在 bind 之前,有没有给 socket 设置
# 一个叫 SO_REUSEADDR 的选项。
# === SO_REUSEADDR 是干什么的 ===
# 默认情况下,内核为了安全(防迷路报文),不允许新
# socket 去 bind 一个"还挂着 TIME_WAIT 连接"的端口。
# ★ SO_REUSEADDR 这个选项,就是程序对内核说:
# "我知道这个端口上还有 TIME_WAIT,我【明确允许】
# 自己 bind 上去,后果我担。"
# 设了它,内核就放行 —— 重启时哪怕一堆 TIME_WAIT,
# 新进程也能立刻绑上 8080。
# === ★ 代码里怎么设(以 C 为例,就在 bind 前)===
int fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(fd, ...); # ★ 设完选项再 bind,就不会被 TIME_WAIT 挡
# === ★ 各语言/框架其实大多默认帮你设了 ===
# - Java:new ServerSocket() 默认就开了 SO_REUSEADDR;
# Netty 也默认设。所以 Java 服务一般没这个困扰。
# - Python:socketserver 要手动 allow_reuse_address = True。
# - Nginx:自己设好了。
# ★ 如果你的服务"重启就撞 Address already in use",
# 多半是它【没有】设这个选项 —— 这是程序的问题。
# === 验证某个监听 socket 有没有开 REUSEADDR ===
$ ss -tlnp -o | grep 8080
# ★ 真正确认还是看代码。如果改不了代码(第三方程序),
# 就只能用下一节的临时办法绕过。
# === ★ 一个重要区别:SO_REUSEADDR vs SO_REUSEPORT ===
# - SO_REUSEADDR:解决"重启时被 TIME_WAIT 挡"的问题。
# - SO_REUSEPORT:★ 是另一回事 —— 允许【多个进程】
# 同时 bind 监听【同一个端口】,内核自动把新连接
# 分摊给它们,用来做多进程负载均衡。别混淆。
修复 4:端口冲突的其他常见情况
# === ★ 除了 TIME_WAIT,Address already in use 还有这些 ===
# === 情况 1:真有另一个进程占着这个端口 ===
# 最朴素的一种:就是有别的进程在 LISTEN 这个端口。
$ ss -tlnp | grep 8080
LISTEN 0 128 0.0.0.0:8080 users:(("java",pid=8123,fd=44))
# ★ 这里能看到 pid,说明真有进程占着。可能是:
# - 老进程没杀干净(kill 后没确认就重启)
# - 另一个不相干的服务,恰好也配了 8080
$ lsof -i :8080 # 另一个查端口占用的命令
# === ★ 情况 2:老进程没真退出(还在,只是没响应)===
# 你以为 kill 掉了,其实:
# - kill 默认发 SIGTERM,进程可能卡在关闭流程里没退。
# - 或者进程变成了 D 状态(不可中断),kill 不掉。
$ ps -ef | grep myapp # ★ kill 之后【一定要确认】它真没了
$ kill -9 # 普通 kill 不掉,才用 -9 强杀
# === 情况 3:端口被另一个协议占了 ===
# TCP 的 8080 和 UDP 的 8080 是两个独立的东西。
$ ss -ulnp | grep 8080 # -u 看 UDP
# ★ 排查时别忘了 UDP。
# === ★ 情况 4:bind 的是 0.0.0.0,和已有的某网卡 IP 冲突 ===
# 一个进程 bind 了 127.0.0.1:8080,你的新进程要 bind
# 0.0.0.0:8080 —— 0.0.0.0 包含 127.0.0.1,冲突。
$ ss -tlnp | grep :8080 # ★ 看清已有的是 bind 在哪个 IP 上
# === 情况 5:端口在系统保留范围 / 被 ip_local_port_range 占 ===
# 你的服务想固定用某端口,但它落在内核分配临时端口的
# 范围里,被某个出站连接临时占用了。
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999
# ★ 服务的固定监听端口,最好选在这个范围【之外】。
# === ★ 通用排查:一条龙看清 8080 ===
$ ss -tanp | grep 8080 # 看所有 TCP 状态 + 进程
$ ss -ulnp | grep 8080 # 看 UDP
$ lsof -i :8080 # 交叉验证
# ★ 有 pid -> 真进程占用;只有 TIME-WAIT -> 是本文主角。
修复 5:正确解法——让服务能干净地秒重启
# === ★ 解法:分"能改代码"和"不能改代码"两种 ===
# === ★ 解法 1(根治):程序设置 SO_REUSEADDR ===
# 如果是你自己的服务,这是【唯一正确的根治】:
# 在 bind 之前,给监听 socket 设上 SO_REUSEADDR。
# 见修复 3 的代码。设了它,服务就能"秒重启",
# 无视端口上那些 TIME_WAIT。
# ★ Java/Netty/Nginx 等大多默认设好了;如果你的
# 服务没设,加上这一行,问题从此根除。
# === 解法 2:用第三方程序、改不了代码 —— 那就等 ===
# TIME_WAIT 在 Linux 上固定 60 秒,到点自己消失。
# ★ 实在没别的办法时:kill 老进程后,等 60 秒以上,
# 再启动新进程。土,但有效。
$ ss -tan | grep 8080 | grep TIME-WAIT | wc -l # 等到这个数归零
# === ★ 解法 3:发布流程上,让客户端主动关连接 ===
# TIME_WAIT 堆在【主动关闭方】。重启服务时几百个
# TIME_WAIT 压在服务器,正是因为是【服务器】主动
# 关闭了所有连接。
# ★ 更优雅的发布:配合负载均衡,先把流量从这台机器
# 摘掉、让客户端那边自然断开(TIME_WAIT 就堆到
# 客户端/LB 去了),再重启服务进程。
# 服务端这边就几乎没有 TIME_WAIT 了。
# === ★ 解法 4:tcp_tw_reuse(谨慎,且只对【出站】有效)===
$ cat /proc/sys/net/ipv4/tcp_tw_reuse
0
# tcp_tw_reuse=1:允许把 TIME_WAIT 的端口【复用】给
# 新的【出站连接】。
# ★ 重要澄清:它帮的是"作为客户端发起大量连接、
# 本地端口不够用"的场景,对"服务端 bind 监听端口
# 被挡"基本【没用】。别指望它解决本文的问题。
# ★ 另外:tcp_tw_recycle 在新内核已被【移除】,
# 它在 NAT 环境下会丢包,千万别再用、别再抄旧文章。
# === ★ 解法 5:规范发布脚本 —— kill 后必须确认 ===
#!/bin/bash
kill "$OLD_PID"
for i in $(seq 1 30); do # ★ 等它真的退出
ps -p "$OLD_PID" > /dev/null || break
sleep 1
done
ps -p "$OLD_PID" > /dev/null && kill -9 "$OLD_PID" # 还在就强杀
# 确认端口干净(没有 LISTEN 占用)再启动新进程:
ss -tlnp | grep -q ':8080 ' && { echo "端口仍被监听"; exit 1; }
nohup ./myapp &
# === 验证 ===
$ ss -tlnp | grep 8080 # 新进程正常 LISTEN
$ ss -tan | grep 8080 | grep TIME-WAIT | wc -l # TIME_WAIT 慢慢归零
# ★ 设了 SO_REUSEADDR 后,哪怕还有 TIME_WAIT,
# 新进程也能立刻起来 —— 这才算真正解决。
修复 6:端口占用排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ "端口被占用" ≠ "有进程占着它" ===
# TIME_WAIT 状态的旧连接,没有进程,也占着端口。
# === 2. ★ 别只用 ss -tlnp,-l 会漏掉 TIME_WAIT ===
$ ss -tan | grep 端口 # 用 -a 看所有状态才全
# === 3. 按状态统计,一眼看清端口被什么占着 ===
$ ss -tan | grep 端口 | awk '{print $1}' | sort | uniq -c
# === 4. ★ TIME_WAIT 是 TCP 故意设计的,会自愈,别去"消灭"它 ===
# Linux 上固定 60 秒,到点自己消失。
# === 5. ★ 重启服务撞端口,根治靠程序设 SO_REUSEADDR ===
# 不是去调内核参数,是程序在 bind 前设这个 socket 选项。
# === 6. tcp_tw_reuse 只帮出站连接,对监听端口没用;
# tcp_tw_recycle 已被移除,千万别再开 ===
# === 7. kill 老进程后,必须确认它真退出、端口真干净,再启动 ===
# === 8. ★ 优雅发布:先摘流量让客户端断开,TIME_WAIT 就不压服务端 ===
# === 9. 排查"Address already in use"的步骤链 ===
$ ss -tanp | grep 端口 # ① 看所有状态:有 pid 还是只有 TIME_WAIT
$ ss -ulnp | grep 端口 # ② 别忘了 UDP
$ 有 pid -> 处理那个进程 # ③ 真进程占用,kill 或换端口
$ 只有 TIME_WAIT -> 设 SO_REUSEADDR / 等 60 秒 # ④ 对症
# 按这个顺序,端口冲突基本能定位、能根治。
命令速查
需求 命令
=============================================================
看端口上所有状态的连接 ss -tan | grep 端口
看端口监听 + 进程 ss -tlnp | grep 端口
看端口所有状态 + 进程 ss -tanp | grep 端口
看 UDP 端口占用 ss -ulnp | grep 端口
按连接状态统计 ss -tan | grep 端口 | awk '{print $1}' | sort | uniq -c
只看 TIME_WAIT 连接 ss -tan state time-wait
查端口被谁占(交叉验证) lsof -i :端口
看 TIME_WAIT 总量 cat /proc/net/sockstat
看临时端口范围 cat /proc/sys/net/ipv4/ip_local_port_range
看 tcp_tw_reuse 设置 cat /proc/sys/net/ipv4/tcp_tw_reuse
口诀:端口被占用不等于有进程占着,TIME_WAIT 没进程也占端口,ss -tan 看所有状态
TIME_WAIT 是 TCP 故意设计 60 秒自愈,重启撞端口根治靠程序设 SO_REUSEADDR
避坑清单
- 端口被占用不等于有一个活着的进程占着它,TIME_WAIT 状态的旧连接没有进程也占着端口
- ss -tlnp 的 -l 只列 LISTEN 状态会漏掉 TIME_WAIT,要用 ss -tan 看端口上所有状态的连接
- 一个 TCP 连接由本地和对端的 IP 端口四元组唯一确定,连接没彻底消失这个本地端口就有主
- TIME_WAIT 是 TCP 故意的设计不是 bug,为了让网络里的迷路旧报文过期和保证对端可靠关闭
- TIME_WAIT 堆在主动关闭连接的一方,重启服务时进程关掉几百个连接它们全压在服务端端口
- TIME_WAIT 在 Linux 上固定 60 秒到点自愈,大量 TIME_WAIT 通常正常不必想方设法消灭它
- 重启撞 Address already in use 的根治办法是程序在 bind 前设 SO_REUSEADDR,不是调内核参数
- Java 的 ServerSocket 和 Netty 默认开了 SO_REUSEADDR,重启撞端口多半是程序没设这个选项
- tcp_tw_reuse 只帮作为客户端的出站连接复用端口,对服务端监听端口被挡基本没用
- tcp_tw_recycle 在 NAT 环境会丢包且新内核已移除,千万别再开启也别抄旧文章的方案
总结
这次"端口明明没人占、却死活绑不上"的事故,纠正了我一个关于"占用"的、想当然到我从未怀疑过的认知。在我的脑子里,"端口被占用"这件事的含义,简单得不容置疑:占用,意味着【有一个进程】,此时此刻,正活生生地把着这个端口。占用是一种"有主"的状态,而"主",在我的理解里,必然是一个进程——一个有 PID、能在 ps 里查到、能被我 kill 掉的实体。正因为这个等式在我心里太牢固了,所以我整套排查动作,都是围着"找那个进程"打转的:我 ps 找它,没找到;我 ss -tlnp 找它,还是没找到。当"占用"明明白白地存在、而"进程"却怎么也找不到时,我陷入的不是困惑,而是一种近乎荒谬的不信任——我开始怀疑是不是 ss 这个命令出了问题,是不是有什么进程能"隐身"。我宁可怀疑工具在骗我,也没有怀疑过我那个"占用必有进程"的等式本身。我从来没想过,占着这个端口的,可能压根就不是一个进程。复盘到根上,我才明白,我对一个 TCP 连接的"生命"理解得太潦草了。在我的想象里,一个连接的一生干净利落:它被建立,它传输数据,然后进程一关,它就【当场死透、立刻消失】。可真实的 TCP 连接,它的死亡不是一个瞬间,而是一段【过程】。当一个连接被主动关闭后,它不会立刻人间蒸发,而是要走进一个叫 TIME_WAIT 的房间,在那里安安静静地再待满 60 秒,然后才真正地、彻底地消失。而在这 60 秒里,它虽然不再传输任何数据、虽然背后早已没有了任何进程,它却依然【占着】当初那一组本地的地址和端口——因为这个端口的归属,从来就不是记在某个进程名下的,而是记在这个"四元组"连接自己名下的。这就解开了我所有的困惑:我 kill 掉老进程的那一刻,它身上挂着的几百个连接被一口气全部关闭,几百个连接齐刷刷地走进了 TIME_WAIT 这个房间,而它们离开进程时,把 8080 这个端口的"使用权"一并带了进去。我的新进程想敲门进来,内核看了一眼——这个端口上还挂着几百个尚未咽气的旧连接呢——于是它拒绝了我。我那顿饭吃得"恰到好处",不是因为我做对了什么,而仅仅是因为,我吃饭的那段时间,刚好够那 60 秒走完,旧连接们陆续散尽,端口才腾了出来。而 TIME_WAIT 这个看似"赖着不走"的设计,根本不是什么疏漏,它是 TCP 深思熟虑后的刻意为之:它要确保网络里那些可能还在飘荡的、属于旧连接的迷路报文,有足够的时间彻底过期,绝不会窜进一个复用了同一端口的新连接里,把数据搅乱。这次最大的收获,是我意识到,很多让我百思不得其解的"诡异现象",其根源往往不是现象本身有多诡异,而是我用来理解它的那个概念模型,从一开始就过于粗糙了。我把"连接"这个有始有终、有一段清晰生命周期的东西,在脑子里简化成了一个非黑即白的开关——要么连着,要么没了;我把"占用"这个本可以由多种实体造成的状态,粗暴地和"进程"划上了等号。当现实的精度高于我脑中模型的精度时,现实就会以"诡异"的面目出现在我面前——其实诡异的从来不是现实,是我那个不够精细的模型,跟不上现实的颗粒度。所以下一次,当一个现象怎么也套不进我的认知、当我开始忍不住怀疑工具是不是在骗我的时候,我会先停下来,反过来审视的不是工具,而是我自己脑子里那个用来解释世界的模型:它是不是漏掉了某种状态?它是不是把一个本有过程的事情,当成了一个瞬间?它是不是把一个本有多种可能的概念,武断地锁死成了一种?真相,常常不在那个"消失了的进程"里,而在我从来没有正眼看过的、连接走向死亡的那一段安静的过程里。
—— 别看了 · 2026