四次挥手看腻了?TCP三次挥手是什么?!

摘要:从一次"抓包发现只有3次挥手"的意外发现出发,深度剖析TCP连接关闭的优化机制。通过三次握手、四次挥手、以及延迟确认和捎带应答的优化原理,揭秘为什么有时候四次挥手会变成三次、TIME_WAIT状态为什么是2MSL、以及如何避免大量TIME_WAIT导致端口耗尽。配合Wireshark抓包图展示真实网络包,给出高并发场景下的TCP参数优化方案。


💥 翻车现场

周三下午,哈吉米在排查一个网络问题。

运维同学:"有个服务的连接数异常,你看看。"

哈吉米用Wireshark抓包分析:

抓包结果(HTTP短连接):

# 三次握手
1. ClientServer: SYN
2. ServerClient: SYN + ACK
3. ClientServer: ACK

# HTTP请求响应
4. ClientServer: GET /api/user
5. ServerClient: HTTP 200 OK

# 四次挥手?
6. ClientServer: FIN + ACK
7. ServerClient: FIN + ACK
8. ClientServer: ACK

咦?只有3个包?

哈吉米:"卧槽,四次挥手怎么变成三次了?"

运维同学:"对啊,我也奇怪,明明书上写的是四次挥手啊!"

南北绿豆和阿西噶阿西来了。

南北绿豆:"这是TCP的优化机制,四次挥手可以合并成三次!"
哈吉米:"???"
阿西噶阿西:"来,我给你讲讲TCP挥手的完整原理。"


🤔 先复习:标准的四次挥手

四次挥手的流程

阿西噶阿西在白板上画了一个图。

客户端                          服务器
  |                               |
  |  1. FIN(我要关闭了)           |
  |------------------------------>|
  |                               |
  |  2. ACK(收到,等我发完数据)    |
  |<------------------------------|
  |                               |
  |  3. FIN(我也关闭了)           |
  |<------------------------------|
  |                               |
  |  4. ACK(收到)                |
  |------------------------------>|
  |                               |

详细解释

第1次挥手:客户端发送FIN
- 客户端:我没有数据要发送了(主动关闭)
- 状态:客户端进入FIN_WAIT_1

第2次挥手:服务器发送ACK
- 服务器:收到你的FIN,但我可能还有数据要发送
- 状态:服务器进入CLOSE_WAIT,客户端进入FIN_WAIT_2

第3次挥手:服务器发送FIN
- 服务器:我的数据也发完了,准备关闭
- 状态:服务器进入LAST_ACK

第4次挥手:客户端发送ACK
- 客户端:收到你的FIN
- 状态:客户端进入TIME_WAIT(等待2MSL),服务器进入CLOSED

状态转换图

stateDiagram-v2
    [*] --> ESTABLISHED: 连接建立
    
    ESTABLISHED --> FIN_WAIT_1: 客户端发FIN
    FIN_WAIT_1 --> FIN_WAIT_2: 收到ACK
    FIN_WAIT_2 --> TIME_WAIT: 收到FIN
    TIME_WAIT --> CLOSED: 2MSL后
    
    ESTABLISHED --> CLOSE_WAIT: 服务器收到FIN
    CLOSE_WAIT --> LAST_ACK: 服务器发FIN
    LAST_ACK --> CLOSED: 收到ACK
    
    CLOSED --> [*]
    
    Note right of TIME_WAIT: 等待2MSL(2分钟)<br/>防止最后的ACK丢失

哈吉米:"标准流程是4次,那为什么会变成3次?"


🎯 三次挥手的秘密:延迟确认 + 捎带应答

什么情况下会变成三次?

南北绿豆:"如果服务器没有数据要发送了,第2次和第3次挥手可以合并!"

标准四次挥手(服务器还有数据)

场景:HTTP长连接,服务器正在推送数据

1. 客户端:FIN(我不发数据了)
   ↓
2. 服务器:ACK(收到,但我还要发数据)
   ↓
3. 服务器继续发送数据...
   ↓
4. 服务器:FIN(我发完了)
   ↓
5. 客户端:ACK(收到)

结果:4次挥手

优化三次挥手(服务器没数据了)

场景:HTTP短连接,服务器响应完就关闭

1. 客户端:FIN(我不发数据了)
   ↓
2. 服务器:没有数据要发了,直接发FIN + ACK(合并)
   ↓
3. 客户端:ACK(收到)

结果:3次挥手

时序对比图

sequenceDiagram
    participant C1 as 客户端(四次挥手)
    participant S1 as 服务器(有数据)
    participant C2 as 客户端(三次挥手)
    participant S2 as 服务器(无数据)

    Note over C1,S1: 四次挥手(服务器还有数据)
    
    C1->>S1: 1. FIN
    S1->>C1: 2. ACK
    Note over S1: 继续发送数据
    S1->>C1: 3. 数据包
    S1->>C1: 4. FIN
    C1->>S1: 5. ACK
    
    Note over C2,S2: 三次挥手(服务器没数据了)
    
    C2->>S2: 1. FIN
    S2->>C2: 2. FIN + ACK(合并)
    C2->>S2: 3. ACK
    
    Note over C2,S2: 优化:少了一次网络往返

关键

TCP的优化机制(延迟确认 + 捎带应答):

延迟确认(Delayed ACK):
- 收到数据后,不立即发ACK
- 等待一小段时间(通常40ms)
- 看是否有数据要回复,有的话一起发

捎带应答(Piggybacking):
- 把ACK和数据包合并发送
- 减少网络包数量

应用到挥手:
- 服务器收到FIN
- 延迟40ms(看是否有数据要发)
- 没有数据 → FIN和ACK合并发送
- 四次挥手变成三次挥手

哈吉米:"所以三次挥手是TCP的优化,不是错误!"


🎯 TIME_WAIT状态:2MSL的秘密

为什么要等待2MSL?

MSL(Maximum Segment Lifetime):报文最大生存时间,通常30秒-2分钟

2MSL = 2 × 30秒 = 60秒(Linux默认)

为什么要等2MSL?

原因1:保证最后的ACK能到达

场景:最后的ACK丢失

客户端                          服务器
  |  FIN                          |
  |------------------------------>|
  |  FIN + ACK                    |
  |<------------------------------|
  |  ACK(丢失)                   |
  |----X------------------------->|
  |                               |
  |  如果客户端立即关闭:          |
  |  服务器收不到ACK,重发FIN      |
  |  但客户端已关闭,无法响应       |
  |  服务器认为连接异常 ❌         |

正确流程(TIME_WAIT):
客户端等待2MSL:
  |  ACK(丢失)                   |
  |----X------------------------->|
  |  FIN(服务器重发)              |
  |<------------------------------|
  |  ACK(重发)                   |
  |------------------------------>|
  |  等待2MSL后关闭 ✅              |

原因2:防止旧连接的包干扰新连接

场景:快速重连同一端口

旧连接:
客户端:12345 → 服务器:80
关闭后,立即创建新连接(复用端口12345)

新连接:
客户端:12345 → 服务器:80

问题:
- 旧连接的延迟包可能到达
- 新连接误认为是自己的包
- 数据混乱 ❌

TIME_WAIT的作用:
- 等待2MSL(旧包最多存活1MSL,来回2MSL)
- 确保旧包全部消失
- 新连接不会收到旧包 ✅

南北绿豆:"所以TIME_WAIT是为了:确保连接正常关闭 + 防止旧包干扰。"


TIME_WAIT过多的问题

问题场景

高并发场景(短连接):
- 每秒10000个请求
- 每个请求1个连接
- 每个连接用完就关闭

结果:
- 每秒产生10000个TIME_WAIT
- TIME_WAIT等待60秒
- 累积:10000 × 60 = 60万个TIME_WAIT

查看:
netstat -an | grep TIME_WAIT | wc -l
600000

问题:
- 端口耗尽(客户端端口范围:1024-65535,约6万个)
- 无法创建新连接 ❌

解决方案

方案1:调整内核参数

# /etc/sysctl.conf

# 1. 允许TIME_WAIT状态的socket复用
net.ipv4.tcp_tw_reuse = 1

# 2. 快速回收TIME_WAIT(不推荐,可能导致问题)
# net.ipv4.tcp_tw_recycle = 1

# 3. 缩短TIME_WAIT时间(不推荐,违反RFC)
# net.ipv4.tcp_fin_timeout = 30

# 生效
sysctl -p

方案2:使用长连接(推荐)

// ❌ 短连接(每次请求新建连接)
for (int i = 0; i < 10000; i++) {
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.connect();
    // 使用连接
    conn.disconnect();  // 关闭连接(产生TIME_WAIT)
}

// ✅ 长连接(复用连接)
HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_1_1)  // HTTP/1.1支持长连接
    .build();

for (int i = 0; i < 10000; i++) {
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("http://example.com/api"))
        .build();
    
    client.send(request, HttpResponse.BodyHandlers.ofString());
    // 连接复用,不关闭
}

方案3:连接池(推荐)

// HTTP连接池
@Configuration
public class HttpClientConfig {
    
    @Bean
    public CloseableHttpClient httpClient() {
        // 连接池配置
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        cm.setMaxTotal(200);           // 最大连接数
        cm.setDefaultMaxPerRoute(20);  // 每个路由的最大连接数
        
        return HttpClients.custom()
            .setConnectionManager(cm)
            .setKeepAliveStrategy((response, context) -> 60 * 1000)  // 保持60秒
            .build();
    }
}

// 使用
@Autowired
private CloseableHttpClient httpClient;

public String request(String url) {
    HttpGet httpGet = new HttpGet(url);
    try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
        return EntityUtils.toString(response.getEntity());
    }
}

🎯 TCP的其他优化

优化1:三次握手的Fast Open

传统三次握手

RTT(Round-Trip Time)往返时间:100ms

1. SYN → 100ms
2. SYN + ACK → 100ms
3. ACK + 数据 → 100ms

总耗时:300ms(3个RTT)

TCP Fast Open(TFO)

1. SYN + Cookie + 数据 → 100ms
2. SYN + ACK + 响应数据 → 100ms

总耗时:200ms(2个RTT)

优化:减少1个RTT

优化2:半关闭(Half-Close)

场景:客户端发完数据,但还要接收服务器的数据

Socket socket = new Socket("server", 8080);

// 发送数据
OutputStream os = socket.getOutputStream();
os.write("request data".getBytes());

// 关闭输出流(发送FIN)
socket.shutdownOutput();  // 半关闭:只关闭写,不关闭读

// 继续接收数据
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
int len = is.read(buffer);  // 仍然可以读

// 全部关闭
socket.close();

半关闭流程

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: 1. 发送请求数据
    Client->>Server: 2. shutdownOutput()发FIN
    Note over Client: 关闭写,但可以读
    
    Server->>Client: 3. ACK(收到FIN)
    Server->>Client: 4. 继续发送响应数据
    Server->>Client: 5. 发送完毕
    Server->>Client: 6. 发FIN
    
    Client->>Server: 7. ACK
    
    Note over Client,Server: 连接关闭

🎯 TIME_WAIT的实战问题

问题:大量TIME_WAIT导致端口耗尽

查看TIME_WAIT数量

# Linux
netstat -an | grep TIME_WAIT | wc -l

# 或者
ss -ant | grep TIME_WAIT | wc -l

模拟问题

// 压测工具(不断创建短连接)
public class ConnectionTest {
    
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100000; i++) {
            Socket socket = new Socket("192.168.1.100", 8080);
            
            // 发送数据
            socket.getOutputStream().write("test".getBytes());
            
            // 立即关闭(产生TIME_WAIT)
            socket.close();
            
            if (i % 1000 == 0) {
                System.out.println("已创建连接: " + i);
            }
        }
    }
}

// 结果:
// 创建3万个连接后,报错:
// java.net.BindException: Cannot assign requested address
// 原因:客户端端口耗尽(1024-65535,约6万个端口)

监控TIME_WAIT

#!/bin/bash
# monitor_time_wait.sh

while true; do
    count=$(ss -ant | grep TIME_WAIT | wc -l)
    echo "$(date '+%H:%M:%S') TIME_WAIT数量: $count"
    
    if [ $count -gt 50000 ]; then
        echo "告警:TIME_WAIT过多,可能端口耗尽"
    fi
    
    sleep 5
done

🎓 面试标准答案

题目:TCP四次挥手为什么有时候是三次?

答案

标准四次挥手

  1. 客户端:FIN
  2. 服务器:ACK
  3. 服务器:FIN
  4. 客户端:ACK

优化为三次挥手

条件:服务器收到FIN时,没有数据要发送

流程

  1. 客户端:FIN
  2. 服务器:FIN + ACK(合并第2、3次)
  3. 客户端:ACK

原因

  • TCP的延迟确认机制
  • 捎带应答(把ACK和FIN合并)
  • 减少1次网络往返

何时是四次

  • 服务器还有数据要发送
  • 需要等发完数据再发FIN

何时是三次

  • 服务器没有数据要发送
  • 直接发FIN + ACK

题目:TIME_WAIT状态为什么要等2MSL?

答案

MSL:报文最大生存时间(30秒-2分钟)

2MSL = 60秒-4分钟

两个原因

1. 保证最后的ACK能到达

  • 如果ACK丢失,服务器会重发FIN
  • 客户端等待2MSL,能收到重发的FIN并回复ACK

2. 防止旧连接的包干扰新连接

  • 旧包最多存活1MSL
  • 来回2MSL,确保旧包全部消失

TIME_WAIT过多的问题

  • 端口耗尽(客户端端口有限)
  • 连接数受限

解决方案

  • 服务器端优化:tcp_tw_reuse=1
  • 应用层优化:用长连接、连接池
  • 避免短连接

题目:如何避免TIME_WAIT导致端口耗尽?

答案

5种方案

1. 使用长连接(推荐)

  • HTTP/1.1 Keep-Alive
  • 连接复用,减少TIME_WAIT

2. 使用连接池(推荐)

  • HTTP连接池
  • 数据库连接池

3. 调整内核参数

  • tcp_tw_reuse=1(允许TIME_WAIT复用)
  • tcp_max_tw_buckets(限制TIME_WAIT数量)

4. 增加端口范围

  • net.ipv4.ip_local_port_range = 10000 65000

5. 让服务器主动关闭

  • 客户端不关闭,服务器关闭
  • TIME_WAIT在服务器端(服务器端口充足)

推荐

  • 应用层:长连接 + 连接池
  • 系统层:tcp_tw_reuse=1

🎉 结束语

晚上8点,哈吉米终于搞懂了TCP挥手的优化。

哈吉米:"原来三次挥手是TCP的优化,把ACK和FIN合并发送!"

南北绿豆:"对,延迟确认和捎带应答是TCP的核心优化机制。"

阿西噶阿西:"记住:TIME_WAIT是必要的,但可以通过长连接避免过多TIME_WAIT。"

哈吉米:"还有高并发场景,一定要用连接池,不要频繁创建关闭连接。"

南北绿豆:"对,理解了TCP的优化机制,才知道如何优化网络性能!"


记忆口诀

四次挥手可优化,FIN和ACK能合并
服务器无数据发,三次挥手就完成
TIME_WAIT等2MSL,保证ACK能到达
防止旧包干扰新,必要等待别着急
高并发用长连接,连接池避免TIME_WAIT