﻿# TCP TIME_WAIT 原理、影响与处理方法

TIME_WAIT 是 TCP 状态机中最容易被误解的状态——新手觉得一堆 TIME_WAIT 是"bug"，运维急着"优化掉它"，但 TIME_WAIT **不是 bug，是 TCP 协议的自我保护机制**。本篇讲清楚三个问题：**为什么设计它、它带来什么麻烦、怎么处理它**。

> 本篇是 [kernel-tuning-net.md](/concepts/network/kernel-tuning-net.md) 第四节 TIME_WAIT 的深度展开。调参入口见那一篇的第四章，这里只讲"为什么"。

---

## 一、TIME_WAIT 在 TCP 状态机中的位置

TCP 连接的关闭是四次挥手，主动关闭方在发出最后一个 ACK 后不立即消失，而是进入 TIME_WAIT 停留 **2×MSL**（Maximum Segment Lifetime，Linux 默认 MSL=30s，即 60s）：

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12
skinparam sequenceMessageAlign center

title TCP 四次挥手与 TIME_WAIT

participant "主动关闭方\n(客户端/服务端)" as A
participant "被动关闭方" as B

== 四次挥手 ==
A -> B : FIN (FIN_WAIT_1)
B -> A : ACK (CLOSE_WAIT)
note right of A : FIN_WAIT_2
B -> A : FIN (LAST_ACK)
note right of A : CLOSING → TIME_WAIT
A -> B : ACK
note right of B : CLOSED

== 2×MSL 等待 ==
note right of A #FFE0E0
  **TIME_WAIT**
  停留 60 秒 (2×MSL=30s)
  等待两个 MSL 周期：
  ① 保证最后的 ACK 到达对端
  ② 让旧连接的"迷途数据包"
     在网络上消亡
end note

A -> A : 等待 60s...
A -> A : CLOSED

@enduml
```

| TCP 状态 | 谁进入 | 含义 |
|----------|--------|------|
| FIN_WAIT_1 | 主动关闭方 | 发出 FIN，等待 ACK |
| FIN_WAIT_2 | 主动关闭方 | 收到对方 ACK，等对方 FIN |
| CLOSE_WAIT | 被动关闭方 | 收到对方 FIN，应用还没调 `close()` |
| LAST_ACK | 被动关闭方 | 应用调了 `close()`，发出 FIN，等最后 ACK |
| **TIME_WAIT** | **主动关闭方** | 发出最后 ACK 后等待 2×MSL |
| CLOSED | 双方 | 连接彻底消失 |

> **关键认知**：TIME_WAIT 出现在**主动调用 `close()` 的一方**——客户端还是服务端不重要，谁先关，谁进 TIME_WAIT。

---

## 二、为什么协议要设计 TIME_WAIT

TCP 是可靠传输协议——"可靠"不仅指数据不丢不重，更指**连接结束后网络上的残留数据不能干扰新连接**。TIME_WAIT 解决两个场景：

### 2.1 场景一：最后一个 ACK 丢了怎么办

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title 最后一个 ACK 丢失 → TIME_WAIT 兜底

participant "主动关闭方\n(TIME_WAIT)" as A
participant "被动关闭方\n(LAST_ACK)" as B

A -> B : ACK (最后的 ACK)
note right of B #FFCCBC
  ACK 在网络中丢失！
  被动方重传 FIN
end note

B -> A : FIN (重传)
note left of A #E0FFE0
  如果主动方已经 CLOSED：
  → 内核回 RST (复位)
  → 被动方收到 RST 认为异常
  → 可能丢失数据 / 误报错误

  但因为有 TIME_WAIT：
  → 主动方还在等
  → 收到重传 FIN → 重发 ACK
  → 重新启动 2×MSL 计时
  → 被动方正常关闭 ✅
end note

A -> B : ACK (重发)

@enduml
```

> **一句话**：TIME_WAIT 像一个"人走茶不凉"的状态——主动关闭方拿着最后一个 ACK 不撒手，确保对方收到了才真正消失。

### 2.2 场景二：旧连接的"迷途数据包"污染新连接

这是 TIME_WAIT **更核心**的使命，也是 2×MSL 的灵魂所在：

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title TIME_WAIT 防止旧连接数据污染新连接

participant "客户端\n(端口 54321)" as C
participant "服务端\n(端口 8080)" as S

== 连接 A: C:54321 → S:8080 ==
C -> S : 数据包 seq=100
note right of C #FFE0E0
  连接 A 关闭，C 进入 TIME_WAIT
  但这个 seq=100 的数据包
  **在网络中迷路了**
  (路由循环 / 网络延迟)
end note

== 假如没有 TIME_WAIT ==
C -> C : 立即复用端口 54321
C -> S : SYN (新连接: C:54321 → S:8080)
S -> C : SYN-ACK
C -> S : ACK (新连接建立！)
S -> C : 迷途包 seq=100 **恰好到达**！
note right of S #FFCCBC
  服务端分不清：
  这包是旧连接的残余？
  还是新连接的数据？
  
  → 数据错乱！
  → 协议可靠性被破坏！
end note

== 因为有 TIME_WAIT ==
note right of C #E0FFE0
  端口 54321 被 TIME_WAIT "锁定" 60 秒
  任何新连接无法使用同一个四元组
  60 秒后迷途包要么到达并被丢弃
  要么已在网络上被 TTL 淘汰
  → 新连接安全 ✅
end note

@enduml
```

四元组（源 IP + 源端口 + 目的 IP + 目的端口）在此起关键作用——TIME_WAIT 利用 TCP 状态机中的四元组唯一性，用 60 秒的时间窗口隔离旧连接的残存包和新的同名连接。

> **2×MSL 的含义**：MSL 是 IP 包在网络中的最大存活时间（Linux 默认 30s），2×MSL = 一个包从发送到确认来回的最长时间。等够 2×MSL，既保证了最后的 ACK 能送到，也保证**当前连接的"正向 + 反向"迷途包都已消亡**。

### 2.3 完整的安全保障逻辑

| 保护目标 | 机制 | 窗口 |
|---------|------|------|
| 最后一个 ACK 可靠交付 | TIME_WAIT 期间收到重传 FIN → 重发 ACK + 重启计时 | 1×MSL（单向延迟上限）|
| 旧连接数据不污染新连接 | 四元组锁定 60s，确保旧包消亡 | 2×MSL（来回延迟上限）|

---

## 三、TIME_WAIT 的缺点

设计上完全正确的东西，到了高并发短连接场景就变成了灾难。

### 3.1 端口耗尽——最头疼的问题

```bash
一台机器 → 目标 IP:Port 固定（例如往 upstream 发请求）
临时端口范围 32768-60999 ≈ 28000 个
每个短连接主动关闭后进 TIME_WAIT → 占掉一个端口 60 秒

极限 QPS = 28000 / 60 = ~467 QPS
超过这个速率 → connect() 返回 EADDRNOTAVAIL (Cannot assign requested address)
```

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title 端口耗尽过程

rectangle "临时端口池\n(tcp_local_port_range)\n默认 32768-60999" as POOL #BBDEFB

rectangle "TIME_WAIT 连接 1" as TW1 #FFCCBC
rectangle "TIME_WAIT 连接 2" as TW2 #FFCCBC
rectangle "..." as TW3 #FFCCBC
rectangle "TIME_WAIT 连接 28000" as TWN #FFCCBC

POOL -down-> TW1
TW1 -down-> TW2
TW2 -down-> TW3
TW3 -down-> TWN

note bottom of TWN
  端口池耗尽！
  新连接 connect() 失败
  → EADDRNOTAVAIL
end note

@enduml
```

### 3.2 内存占用

每个 TIME_WAIT 连接在内核中轻量（tcp_timewait_sock，约 200 字节），但**几万条积累**起来：
- 2 万条 = 约 4MB（可忽略）
- 10 万条 = 约 20MB（可接受，但要留意）
- 50 万条 = 约 100MB（加上 hash 表开销，内存开始吃紧）

### 3.3 在 echo 实验中的表现

echo-bench（短连接客户端）跑完 10000 req 后，`ss -tan state time-wait | wc -l` 能看到 **数千个** TIME_WAIT 条目——每次请求都是新 `connect() → echo → close()`，关闭方（客户端）全部进入 TIME_WAIT。

| 场景 | TIME_WAIT 数量 | 是否出问题 |
|------|--------------|-----------|
| echo-bench 10000 req（localhost）| ~3K-6K | 端口不耗尽（localhost 临时端口池够大）|
| echo-bench 持续压测 60s+ → 停止 | 瞬时堆积大量 | 不释放，但不再新建连接，无影响 |
| 高频短连接生产者（>467 QPS 同 upstream）| 持续堆积至 28000 | **端口耗尽，新连接失败** |

---

## 四、如何处理 TIME_WAIT

### 4.1 手段一：tcp_tw_reuse——安全复用（推荐）

```bash
sysctl net.ipv4.tcp_tw_reuse=1
```

**工作原理**：内核允许将 TIME_WAIT 状态的连接复用于**新的出站连接**，但必须满足一个安全条件——**TCP 时间戳保护**。

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title tcp_tw_reuse 的安全机制（时间戳保护）

participant "客户端" as C
participant "服务端" as S

== 旧连接 (C:54321 → S:8080) 关闭 ==
C -> C : 旧连接进入 TIME_WAIT\n记录最后时间戳 TS_old

== 新连接尝试复用端口 54321 ==
C -> C : tcp_tw_reuse=1\n检查: 新连接的 TS_new > TS_old？
note right of C #E0FFE0
  ✅ TS_new > TS_old
  → 时间戳单调递增
  → 旧包的 TS 一定比 TS_new 小
  → 旧包到达后会被时间戳校验丢弃
  → 安全！允许复用
end note

C -> S : SYN (端口 54321 复用)

== 如果 TS_new < TS_old ==
note right of C #FFCCBC
  ❌ 新连接时间戳小于旧连接
  → 旧包可能还在路上且 TS 更大
  → 有污染风险，拒绝复用！
end note

@enduml
```

> **关键限制**：`tcp_tw_reuse` 只对**主动出站连接（客户端角色）**生效，且要求 `net.ipv4.tcp_timestamps=1`（默认开启）。服务端 listen 端口的连接不受此参数影响。

### 4.2 手段二：调宽临时端口范围

```bash
sysctl net.ipv4.ip_local_port_range="1024 65535"
```

| 端口范围 | 可用端口数 | 极限 QPS (短连 60s TIME_WAIT) |
|---------|-----------|----------------------------|
| 32768-60999（默认）| ~28000 | ~467 |
| 1024-65535 | ~64000 | ~1067 |
| 1024-65535 + tcp_tw_reuse | ~64000 | **无上限**（复用不占新端口）|

> 扩大范围只缓解、不根治——QPS 继续增长仍然会耗尽。真正的上限由 `tcp_tw_reuse` 打破。

### 4.3 手段三：长连接 / 连接池（根本解决）

TIME_WAIT 只出现在**短连接**的主动关闭方。如果连接一直不关：

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title 短连接 vs 长连接：TIME_WAIT 是"频繁关门"的代价

rectangle "短连接模型\n────────\nconnect() → 请求 → close()\nconnect() → 请求 → close()\nconnect() → 请求 → close()\n...\n\n每 1 次请求 = 1 次 close\n= 1 个 TIME_WAIT\n= 占 1 个端口 60 秒\n\n→ 高 QPS 必端口耗尽" as SHORT #FFCCBC

rectangle "长连接模型\n────────\nconnect() → 请求 1 → 请求 2 → ...\n连接保持打开，复用\n...\n→ 请求 N\n\n10000 次请求 = 1 次 close\n= 1 个 TIME_WAIT\n= 占 1 个端口 60 秒\n\n→ 端口无忧 ✅" as LONG #E0FFE0

@enduml
```

| 方案 | 复杂度 | 效果 |
|------|--------|------|
| HTTP Keep-Alive | 低（header `Connection: keep-alive`）| 同一 host 的连接复用 |
| 连接池（client pool）| 中（需管理池大小、超时、探活）| 多目标连接复用，控制总连接数 |
| gRPC / HTTP/2 多路复用 | 中（协议层支持）| 一个 TCP 连接承载多个并发请求 |

> **一句话**：`tcp_tw_reuse` 治标，长连接治本。高并发服务**优先做连接池**，`tcp_tw_reuse` 作为保险开关。

### 4.4 手段四：tcp_tw_recycle——已废弃，永远别用

```bash
# ⚠️ 不要加这行！
# sysctl net.ipv4.tcp_tw_recycle=1  # 4.12 内核已移除
```

**为什么废弃**：

`tcp_tw_recycle` 曾试图在服务端也快速回收 TIME_WAIT，但它依赖一个**全局**的时间戳比较——要求**同一对端 IP** 发来的请求时间戳必须单调递增。

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title tcp_tw_recycle 的致命缺陷

rectangle "NAT 网关" as NAT #FFF9C4

rectangle "客户端 A\n(TS=100)" as CA
rectangle "客户端 B\n(TS=200)" as CB
rectangle "服务端 (tcp_tw_recycle=1)" as SRV #FFCCBC

CA -> NAT : 请求 (TS=100)
CB -> NAT : 请求 (TS=200)
NAT -> SRV : 请求 (源 IP=NAT_IP, TS=100)
NAT -> SRV : 请求 (源 IP=NAT_IP, TS=200)

note bottom of SRV
  TS 100 → 200 ✅ 递增
  TS 200 → 100 (B 发完 A 接上)
  → 拒绝！对端看来 TS 逆序
  → 服务端丢弃 SYN！
  → NAT 后的合法连接失败
  → 不可排查的"偶发抖动"
end note

@enduml
```

**关键问题**：4.12 内核已从代码中移除该参数。如果看到旧文章建议开 `tcp_tw_recycle`，那篇文章已经过时了。

### 4.5 手段五：SO_LINGER——危险的"快速关闭"

```c
struct linger ling = {1, 0};     // l_onoff=1, l_linger=0
setsockopt(fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
close(fd);  // 立即发送 RST，不经过四次挥手，不进入 TIME_WAIT
```

| 方式 | 状态机 | TIME_WAIT | 代价 |
|------|--------|-----------|------|
| 正常 close() | FIN → FIN-ACK → FIN → ACK | ✅ 有（60s）| 占用端口 |
| `SO_LINGER(l_onoff=1, l_linger=0)` | RST | ❌ 无 | **内核发送缓冲区中的未确认数据全部丢弃！** |

**为什么危险**：

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title SO_LINGER 用 RST 替代 FIN → 数据丢失

participant "发送方\nSO_LINGER=1s" as A
participant "接收方" as B

A -> B : write(data) (刚放进缓冲区)
A -> A : close() + SO_LINGER
A -> B : RST (不等 FIN 挥手)
note right of B #FFCCBC
  接收方刚读完前面数据
  正在等后面的数据
  收到 RST → 连接被硬中断
  → 发送缓冲区中未确认的数据
  → 永远丢失！
end note

@enduml
```

**唯一安全的使用场景**：确定发送缓冲区已空（`SO_LINGER` 配合非零 `l_linger` 等待排空），且能接受对端收到 RST 而非正常 FIN。**web 应用几乎永远不要用 `SO_LINGER, l_linger=0`**。

### 4.6 手段六：调整 tcp_max_tw_buckets（治标）

```bash
sysctl net.ipv4.tcp_max_tw_buckets=262144  # 默认值，TIME_WAIT 条目上限
```

超过上限时内核**直接销毁超出的 TIME_WAIT 条目并打印 kernel log**。这会让个别连接的 TIME_WAIT 保护失效，但能防止全系统被 TIME_WAIT 撑爆。

> 把这个调大只是"允许更多 TIME_WAIT"，不等于"解决 TIME_WAIT"。真正解决靠 4.3 的长连接。

### 4.7 手段七：SO_REUSEADDR——注意区分

很多新手把 `SO_REUSEADDR` 和 TIME_WAIT 混为一谈：

| socket 选项 | 作用 | 与 TIME_WAIT 的关系 |
|------------|------|-------------------|
| `SO_REUSEADDR` | bind() 时允许复用处于 TIME_WAIT 的**本地地址** | 服务端重启时有用，不影响 TIME_WAIT 的清理 |
| `SO_REUSEPORT` | 多进程/线程各自 bind 同一端口（内核负载均衡）| 无关，不解决 TIME_WAIT 问题 |
| `tcp_tw_reuse` | 出站连接复用 TIME_WAIT 端口 | **直接解决 TIME_WAIT 端口耗尽** |

---

## 五、观测手段

### 5.1 看 TIME_WAIT 数量

```bash
# 统计 TIME_WAIT 总数
ss -tan state time-wait | wc -l

# 按目标端口分组（看哪些 upstream 吃 TIME_WAIT 最多）
ss -tan state time-wait | awk '{print $4}' | awk -F: '{print $NF}' | sort | uniq -c | sort -rn | head -10

# ss -s 看总体连接摘要
ss -s
# 输出示例：
# Total: 3124
# TCP:   12043 (estab 2152, closed 0, orphaned 0, timewait 9847, synrecv 0)
```

### 5.2 看端口分配

```bash
# 查看当前临时端口范围
sysctl net.ipv4.ip_local_port_range

# /proc/net/sockstat 看全局 socket 统计
cat /proc/net/sockstat
```

### 5.3 验证 tcp_tw_reuse 是否生效

```bash
# 确认参数已开
sysctl net.ipv4.tcp_tw_reuse

# 确认时间戳已开（tcp_tw_reuse 的前置条件）
sysctl net.ipv4.tcp_timestamps
```

---

## 六、处理策略总结

```plantuml
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

title TIME_WAIT 处理决策树

start

if (能否改成长连接？) then (✅ 能)
  :用连接池 / HTTP Keep-Alive / gRPC;
  :TIME_WAIT 不再是问题;
  stop
else (❌ 必须短连接)
  if (是出站连接吗？) then (✅ 客户端/反代)
    :tcp_tw_reuse=1;
    if (仍不够？) then (是)
      :调宽 ip_local_port_range;
    else (否)
      :✅ 解决;
      stop
    endif
  else (❌ 服务端)
    :服务端处于被动关闭方\n通常不进 TIME_WAIT;
    :如果是主动关闭方\n考虑调整架构让客户端关;
    stop
  endif
endif

stop

@enduml
```

| 场景 | 方案 | 优先级 |
|------|------|--------|
| 微服务间调用（短连接多）| 连接池 + `tcp_tw_reuse=1` | P0 |
| 反代到 upstream（固定目标）| 连接池 + keepalive | P0 |
| 压测工具 / 批量客户端 | `tcp_tw_reuse=1` + 调宽端口范围 | P1 |
| 服务端被动关闭 | 通常不需要处理（被动关不产生 TIME_WAIT）| — |
| 服务端主动关（特殊场景）| 重构让客户端主动关 | P2 |
| 临时救火：TIME_WAIT 太多已影响稳定性 | 调大 `tcp_max_tw_buckets` | 应急 |
| ~~`tcp_tw_recycle`~~ | ~~废弃~~ | **永远不用** |
| ~~`SO_LINGER(l_linger=0)`~~ | ~~危险~~ | **web 应用不用** |

---

## 七、与同目录文档的关系

- [kernel-tuning-net.md](/concepts/network/kernel-tuning-net.md) —— 第四节：TIME_WAIT 调参入口（`tcp_tw_reuse` / 端口范围 / 连接池）
- [tcp-flow-congestion-control.md](/concepts/network/tcp-flow-congestion-control.md) —— TCP 状态机全貌、流量控制与拥塞控制
- [tcp-nodelay.md](/concepts/network/tcp-nodelay.md) —— TCP_NODELAY/TCP_CORK 对写行为的影响
- [demos/echo/day-02/README.md](/demos/echo/day-02/README.md) —— 短连接 echo 实验中 TIME_WAIT 的观测与对压测的影响

---

> **一句话总结**：TIME_WAIT 不是 bug，是 TCP 协议的两个硬需求——保证最后 ACK 可靠交付 + 隔离旧连接迷途包。主动关闭方 60s 内锁定四元组，代价是高并发短连接端口耗尽。`tcp_tw_reuse` + 时间戳保护可以在出站侧安全复用端口（治标），连接池/长连接从根上消除频繁关闭（治本）。`tcp_tw_recycle` 已废弃，永远别开；`SO_LINGER(l_linger=0)` 用 RST 跳掉 TIME_WAIT 会丢数据，web 应用不要用。

