【从0开始学习计算机网络】| TIME_WAIT 为什么是 2MSL,CLOSE_WAIT / TIME_WAIT 堆积怎么排查

🌈个人主页 :一条泥憨鱼(欢迎各位大佬莅临)

🎬精选专栏传送门:

❄️《数据结构》 ❄️《AI与Agent那些事》

❄️《从0开始学计算机网络》 ❄️《后端开发》

凌晨两点,监控报警:某台机器本地端口快用完了。登上机器敲一句 `ss -s`,输出里 `TIME-WAIT` 那一栏几万条。旁边的同事第一反应:"是不是连接泄漏了?赶紧重启吧。"

再过几天,另一台机器报警:`CLOSE_WAIT` 持续增长,从几百爬到几千,一直没降。这回同事又说了:"重启一下。"

这两个状态看起来都像"连接堆着没释放",但它们的性质完全相反:TIME_WAIT 基本是正常的,会自己消失;CLOSE_WAIT 才是真问题,而且永远不会自己消失。把它们搞混,要么白折腾内核参数,要么把真正的代码 bug 藏起来。

这篇文章就想把三件事讲透:

  1. 为什么 TIME_WAIT 必须等 2MSL?

  2. TIME_WAIT 堆积什么时候要管,什么时候不用管?

  3. CLOSE_WAIT 堆积该找谁?

如果你从没系统学过网络,也没关系,我们从"打电话"开始讲。

一、先把"连接"这件事讲清楚:为什么断开要挥手四次

很多人第一次听到"TCP 连接",会想象成两台机器之间拉了一根实实在在的线。不是的。

真实情况是:两台机器各自在自己的内存里维护一份"我跟谁在通信、通信到哪一步了"的记录。这份记录就是所谓的 socket(套接字)。连接建立时双方各创建一份,连接关闭时双方各销毁一份。中间传输的数据,是从一台机器的一个 socket 里,通过网络,送到另一台机器的对应 socket 里。

理解了这个,"连接"就好比两个人打电话:

  • 建立连接 = 三次握手。A 拨号:"喂,在吗?" B:"在。" A:"好,我开始说了。" 三次确认,双方都知道"对面能听见我,我也能听见对面"。

  • 断开连接 = 四次挥手。为什么不三次?因为挂电话这件事,双方各要挂一次:A 说"我说完了,我要挂了",B 说"好,我知道了"(这只是确认收到,不代表 B 也说完了);B 说完自己剩下的内容,再说"我也说完了,我也挂了",A 再回一句"好"。各关各的方向,所以是四次。

把这两个过程用状态名标出来,就是下面这张图。第一次看可能有点晕,不要紧,只要抓住一个关键点:谁先发起关闭,谁最后会进入 TIME_WAIT;谁收到了对方的关闭请求但还没关自己,谁就停在 CLOSE_WAIT。

把这张图对应到"打电话":

  • 主动关闭方(先说要挂的人):`FIN_WAIT_1` 是"我说了要挂,等你回";`FIN_WAIT_2` 是"你回了,我等你把话说完";`TIME_WAIT` 是"咱俩都挂了,但我不马上把话筒放下,再等一会儿"。

  • 被动关闭方(后挂的人):`CLOSE_WAIT` 是"我听到你说要挂了,但我这边还有话没说完/还没放话筒";`LAST_ACK` 是"我也说完了,等你最后确认"。

记住这个对应关系,后面所有排查都不会迷路。

二、TIME_WAIT 为什么必须等 2MSL:不是设计缺陷,是保命机制

先说 MSL是什么。它的全称是 Maximum Segment Lifetime,翻译过来就是"一个数据包在网络里最多能活多久"。你可以把它想成一封信在路上最多飘多久------超过这个时间还没到,就当作它已经丢了。

Linux 里 MSL 是写死在协议实现里的,值是 60 秒。所以 2MSL ≈ 120 秒------但请注意,这是老印象现代 Linux 的 TIME_WAIT 实际时长由内核参数控制,默认大约 60 秒,不是 120 秒。这个下面细说。

为什么 TIME_WAIT 要等 2MSL?也就是等一个"来回"。两个理由,都很实在。

理由一:防止旧连接的迷路报文污染新连接。

设想 A 和 B 用同一个四元组(源 IP、源端口、目的 IP、目的端口)通信。A 发了一个数据包,这个包在路上绕了很久没到。A 觉得超时了,主动关闭连接,等 TIME_WAIT 结束后,又用同一个四元组跟 B 建立了一条新连接。结果------那个绕了很久的旧包,这时候突然到了 B。B 一看四元组对得上,就会把它当成新连接的数据,数据就串了。

TIME_WAIT 等 2MSL,就是**让"旧连接的所有迷路报文"在这段时间里全部老死、被网络丢弃。**等旧包死光,再复用端口就安全了。

理由二:保证最后一个 ACK 能送到。

再看"打电话"的过程。A 先说要挂,B 说"好的",A 再回一个 ACK 表示"收到"。如果 A 回完 ACK 就立刻把连接状态清掉,会怎样?

假设那个 ACK 在路上丢了,B 没收到。B 会以为自己那句"好的"没送到,于是重发一次 FIN。可是 A 这边已经把连接彻底删掉了,收到一个"陌生 FIN",只能回一个 RST(重置),B 就懵了。

TIME_WAIT 就是给 A 留一段缓冲时间:如果 B 重发 FIN,A 还在 TIME_WAIT 里,可以直接回一个 ACK,告诉 B "我真的收到了,你可以放心关了"。

关于那个"60 秒"的数字。Linux 里有两个容易搞混的东西:

- `net.ipv4.tcp_fin_timeout`:这个参数控制的是 FIN_WAIT_2状态的超时,默认 60 秒。它不是 TIME_WAIT 的时长,很多人搞错。

- TIME_WAIT 的实际时长,在 Linux 内核里是固定的 60 秒(对应 MSL=60s,2MSL 理论上是 120s,但内核实现里 TIME_WAIT 超时是 60s)。

所以别人说"TIME_WAIT 等 2MSL"和"TIME_WAIT 大约 60 秒",其实并不矛盾------前者是协议概念,后者是 Linux 的具体实现。

要强调的一点:TIME_WAIT 是主动关闭方必须付出的代价,是协议正常运转的一部分,不是 bug。看到它就紧张,是新手最常见的误会。

三、TIME_WAIT 堆积:什么时候该管,什么时候不用管

先给一句结论:TIME_WAIT 是端口资源,不是内存泄漏。单机几万条是常见的,尤其短连接高并发场景下。

为什么常见?因为每关闭一条连接,主动关闭方就留下一个 TIME_WAIT,占据一个本地端口,约 60 秒后才释放。如果你的程序用短连接模式,每秒关闭几千条连接,那 TIME_WAIT 数量就是"关闭速率 × 60"。每秒 1000 条,就是 6 万条 TIME_WAIT。听起来很吓人,但机器完全扛得住。

那什么时候真的会出问题?当本地端口被占满的时候。典型症状是程序报错:

复制代码
Cannot assign requested address

意思是"我要发起一个新连接,但本机没有空闲端口可用了"。常见于客户端侧(比如一个服务作为客户端去调用另一个服务),短连接高频,端口范围不够用。

排查命令,从粗到细:

bash 复制代码
# 1. 看总览,一眼看到各状态数量

ss -s



# 2. 统计每个状态各自有多少条

ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn



# 3. 只看某个端口上的 TIME_WAIT

ss -ant state time-wait '( sport = :8080 )'

```

`ss` 是 `netstat` 的现代替代品,速度快得多,`netstat` 在大连接量下会卡很久,现在基本不用了。

确认是 TIME_WAIT 堆积、并且确实影响了新连接之后,处理手段有几种,各自的代价要说清:

治本:应用层改成长连接 / 用连接池。 这是最有效的。短连接改成连接池复用,TIME_WAIT 数量直接掉一个数量级。绝大多数"TIME_WAIT 报警"最后都是靠这个解决的。

客户端侧缓解:`net.ipv4.tcp_tw_reuse = 1`。 允许在安全的前提下复用 TIME_WAIT 状态的端口去发起新连接(只对客户端主动连接生效)。相对安全,可以用。

**坚决不推荐:`net.ipv4.tcp_tw_recycle`。**这个参数在 NAT 环境下会误杀连接(同一个 NAT 后面多台机器的报文时间戳不一致,会被内核直接丢弃),坑过无数人。新内核已经把它移除了,看到教程教你开这个,直接跳过。

**扩大本地端口范围:`net.ipv4.ip_local_port_range`。**默认大概是 `32768 60999`,也就是约 28000 个端口。调大到 `1024 65535` 能多不少,但治标不治本。

调内核参数是止痛药,改连接模型才是根治。

四、CLOSE_WAIT 堆积:这才是真正的"代码 bug 信号"

和 TIME_WAIT 相反,CLOSE_WAIT 堆积基本可以确定是程序的问题。

回顾一下:CLOSE_WAIT 是被动关闭方收到对方的 FIN 之后、自己还没调用 `close()` 的状态。它堆积,意思就是------对方已经说"我要挂了",你这边"嗯"了一声,但手一直没放话筒。

用代码说,就是你的程序收到了"对端关闭"的事件,但没有正确关闭自己这端的连接。常见原因,我按踩坑频率排一下:

  1. **代码里忘了 `close()`。**最典型:正常路径记得关,异常分支里 `return` 或 `throw` 之前忘了关。

  2. **连接池配置问题。**借出去不还,或者池子满了不释放,连接就一直挂在 CLOSE_WAIT。

  3. **线程阻塞 / 死锁。**处理逻辑卡住了,`close()` 永远执行不到。

  4. **第三方库超时设置不当。**比如 HTTP 客户端没设 `read timeout`,对端断了它还一直阻塞在读上。

看一段对比代码,讲清楚为什么 `defer` / `finally` 这么重要:

Go 复制代码
// ❌ 错误示范:异常路径忘了关连接

func badHandler(conn net.Conn) error {

    data, err := readSomething(conn)

    if err != nil {

        return err          // 这里直接 return,conn 没关,留下 CLOSE_WAIT

    }

    process(data)

    conn.Close()

    return nil

}



// ✅ 正确示范:defer 保证任何路径都会关闭

func goodHandler(conn net.Conn) error {

    defer conn.Close()      // 无论函数怎么退出,都会执行 close

    data, err := readSomething(conn)

    if err != nil {

        return err

    }

    process(data)

    return nil

}

```

Python 里对应 `try/finally` 或者 `with` 语句,Java 里对应 `try-with-resources`。核心思想一样:**关闭动作要放在"一定会执行到"的位置**。

排查步骤,按这个顺序走:

bash 复制代码
# 1. 找出所有 CLOSE_WAIT 连接,看看对端 IP 分布

ss -ant state close-wait



# 2. 定位到进程(-p 会显示进程信息,需要 root 或对应权限)

ss -antp state close-wait



# 3. 拿到 PID 后,看进程打开了哪些文件/连接

lsof -p <pid>



# 4. 看线程卡在哪(按语言选工具)

#    Java:  jstack <pid>

#    Python: py-spy dump --pid <pid>

#    Go:    pprof / gdb

```

关键一点必须强调:CLOSE_WAIT 不会自己消失。 TIME_WAIT 有超时,时间到了内核自动回收;CLOSE_WAIT 没有超时,只要你的程序不调用 `close()`,它就永远挂在那里。所以改内核参数对 CLOSE_WAIT 完全没用,唯一出路是修代码。

五、一张图分清两个状态:排查决策树

到这里,把两个状态摆在一起对比一下,就非常清楚了。

遇到"连接堆积"报警,走这么一条决策树:

  1. 先看是哪个状态堆积。`ss -ant | awk '{print $1}' | sort | uniq -c` 一条命令搞定。

  2. 如果是 TIME_WAIT:

  • 是不是主动关闭方?(看是谁先发 FIN)------ 是的话,就是正常代价。

  • 本地端口有没有耗尽?有没有 `Cannot assign requested address`?

  • 没耗尽 → 忽略,或者考虑改长连接。

  • 耗尽了 → 改连接模型为主,`tcp_tw_reuse` 为辅。

  1. 如果是 CLOSE_WAIT:
  • 直接查代码,找没关连接的分支。

  • 内核参数不用动,动了也没用。

附一张对照表:

| 状态 | 出现在哪一方 | 是否正常 | 会自己消失吗 | 主要处理方式 |

| TIME_WAIT | 主动关闭方 | 正常 | 会,约 60 秒 | 改长连接 / 连接池;必要时 `tcp_tw_reuse` |

| CLOSE_WAIT | 被动关闭方 | 异常 | 不会 | 改代码,确保 `close()` 被执行 |

| FIN_WAIT_2 | 主动关闭方 | 通常短暂 | 会,超时(`tcp_fin_timeout`) | 一般无需处理 |

| LAST_ACK | 被动关闭方 | 通常短暂 | 会 | 一般无需处理 |

工具上再补一句:想彻底看懂挥手过程,抓包是最直接的。在机器上跑:

bash 复制代码
# 抓本机 8080 端口上跟某台对端机器的完整挥手过程

tcpdump -i any -nn 'tcp port 8080 and host 10.0.0.5'

```

你会亲眼看到 FIN、ACK 一个个过去,TIME_WAIT 和 CLOSE_WAIT 是怎么来的,一目了然。

最后

回到开头那个凌晨两点的场景。如果再看到 TIME_WAIT 报警,先别慌,问自己三个问题:是谁先发起的关闭?本地端口耗尽了吗?应用能不能改成长连接?

而看到 CLOSE_WAIT,别犹豫,直接去翻代码------它不会自己好,也不会被任何内核参数救活。

一个实际建议:把 `ss -s` 和状态统计做成例行监控,每天看一眼趋势。TIME_WAIT 突然翻倍、CLOSE_WAIT 开始缓慢爬升,这些苗头在报警之前就能发现。比出事再救火省事得多。

相关推荐
(Charon)1 小时前
【C++】 定时器入门:从定时任务到 epoll 驱动
开发语言·c++
FW-Linker1 小时前
多卡聚合的数据包级并行传输原理在广电直播场景中详解
网络·5g·架构·智能路由器
亮_一个嵌入式新手1 小时前
C语言Day21
c语言·开发语言
kcuwu.1 小时前
第 1 课 · Hello, World 与一个 Go 程序的诞生
开发语言·后端·golang
php@king1 小时前
golang入门到精通
开发语言·后端·golang
Leo.yuan1 小时前
Flink + Kafka + Doris 之外的另一条路:FineDataLink 一体化实时数仓方案
开发语言·javascript·ecmascript
Tiny2141 小时前
解决VMware中win10虚拟机无法上网
网络·windows·vmware
只睡四小时1 小时前
端侧推理实战:face-api.js 零后端人脸识别踩坑记
开发语言·javascript·ecmascript
某不知名網友1 小时前
C++ 深浅拷贝:从默认拷贝到 Rule of Five
java·开发语言