TCP三次握手和四次挥手:面试能答不代表真懂

17 阅读11分钟

TCP三次握手和四次挥手:面试能答不代表真懂


摘要:三次握手和四次挥手,面试能答出来只是及格。TIME_WAIT出现在哪一端?CLOSE_WAIT堆积说明什么?tcp_tw_recycle为什么被移除了?这些问题答得上来,才算真正理解了TCP连接的生命周期。本文用抓包输出讲清楚每个状态的含义,附带生产环境的排查方法和Java代码示例。

关键词:TCP、三次握手、四次挥手、TIME_WAIT、CLOSE_WAIT、连接排查

分类:网络 / Java / 后端开发


面试答案只是起点

三次握手,面试标准答案:

"客户端发SYN,服务端回SYN+ACK,客户端再发ACK,连接建立。"

四次挥手:

"主动方发FIN,被动方回ACK,被动方发FIN,主动方回ACK,连接关闭。"

及格了。但追几个问题:

  • 为什么握手是三次不是两次?
  • 为什么挥手是四次不是三次?有没有可能变成三次?
  • TIME_WAIT在哪一端?为什么要等2MSL?
  • CLOSE_WAIT堆积说明什么?跟TIME_WAIT有什么区别?
  • tcp_tw_recycle为什么被Linux内核移除了?

能全答清楚的,不多。


先自己抓一次包

理论讲一百遍不如自己看一遍。准备两台机器:

# 服务端
nc -l 8888

# 客户端
nc 服务端IP 8888

客户端抓包:

tcpdump -i eth0 host 服务端IP and port 8888 -nn -S

客户端输入任意内容回车,然后Ctrl+C断开。你会看到完整的握手→数据传输→挥手过程。


三次握手:逐包拆解

1  客户端.49152 > 服务端.8888:  Flags [S],   seq 1000000
2  服务端.8888 > 客户端.49152:  Flags [S.],  seq 2000000, ack 1000001
3  客户端.49152 > 服务端.8888:  Flags [.],   ack 2000001

第1包:客户端发SYN

客户端进入SYN_SENT状态。这个包说的是:"我想建连,我的初始序列号是1000000。"

注意seq=1000000,不是0也不是1,是随机值。随机化是为了安全——防止TCP序列号预测攻击。如果你发现某个系统的TCP初始序列号是固定值或者规律递增的,那它的连接可以被伪造。

第2包:服务端回SYN+ACK

服务端从LISTEN进入SYN_RCVD状态。一个包做了两件事:

SYN:我也要建连,我的初始序列号是2000000。 ACK:你的序列号1000000我收到了,我期望你下一个包从1000001开始。

ack=1000001 = 对端seq + 1。这是TCP确认号的规则:确认号 = 已收到的最后一个字节的序列号 + 1。

第3包:客户端发ACK

客户端进入ESTABLISHED。服务端收到后也进入ESTABLISHED。连接建好。

为什么是三次不是两次

这是面试必问题。标准答案是"防止历史重复连接",但很多人不理解具体场景。

看这个情况:

客户端发了SYN(seq=1000),网络延迟,这个包绕了很久。
客户端等不到回复,重发SYN(seq=1500),这次服务端收到并完成握手,正常通信后关闭。
这时第一个延迟的SYN(seq=1000)终于到了服务端。

如果是两次握手:

服务端收到迟到的SYN → 发SYN+ACK → 认为连接建立了 → 开始等数据
客户端根本不知道有这个连接 → 不会发数据
服务端的资源白白浪费

如果是三次握手:

服务端收到迟到的SYN → 发SYN+ACK
客户端收到SYN+ACK → 发现ack=1001不是我期望的 → 发RST拒绝
服务端收到RST → 不建连,资源不浪费

第三次握手给了客户端一个否决权,可以拒绝无效的历史连接。


数据传输

连接建好后双方互发数据。确认机制:

客户端 > 服务端:  seq 1000001, len 6     (发了6字节)
服务端 > 客户端:  seq 2000001, ack 1000007  (确认收到,下一个要1000007)

ack=1000007 = 1000001+6,表示"1000007之前的所有字节我都收到了"。TCP的确认是累积确认——不需要逐包确认,效率更高。


四次挥手:逐包拆解

4  客户端 > 服务端:  Flags [F.],  seq 1000007, ack 2000001
5  服务端 > 客户端:  Flags [.],   ack 1000008
6  服务端 > 客户端:  Flags [F.],  seq 2000001, ack 1000008
7  客户端 > 服务端:  Flags [.],   ack 2000002

第4包:客户端发FIN

客户端说"我没有数据要发了"。进入FIN_WAIT_1。

注意:FIN只表示"我不再发数据",不是说我不收了。 这是半关闭(half-close)的概念。TCP是全双工的,两个方向的数据流独立控制。

第5包:服务端回ACK

服务端确认"知道你要关了"。进入CLOSE_WAIT。客户端收到ACK后进入FIN_WAIT_2。

第6包:服务端发FIN

服务端也说"我也没有数据要发了"。进入LAST_ACK。

第7包:客户端回ACK

客户端确认。进入TIME_WAIT,等2MSL后彻底关闭。服务端收到ACK后直接CLOSED。

为什么是四次不是三次

因为TCP是全双工,两个方向的数据流独立。

握手时,服务端收到SYN的同时就决定同意连接,SYN和ACK可以合并成一个包。

挥手时,服务端收到FIN只能先ACK确认"我知道你要关了",但服务端可能还有数据没发完,不能立刻发FIN。所以ACK和FIN通常分成两个包。

收到FIN → 立刻ACK(确认对方要关)→ 继续发剩余数据 → 发完后发FIN(我也关了)

不过,如果服务端收到FIN时恰好没有剩余数据,ACK和FIN可以合并成一个包,这时候四次挥手就变成了三次。 抓包中确实偶尔能看到这种情况。


状态机全景

客户端                                    服务端
  |                                         |
  |  ---- SYN(seq=x) ---->                 |
  |  SYN_SENT               LISTEN          |
  |                                         |
  |  <--- SYN+ACK(seq=y,ack=x+1) ----      |
  |                       SYN_RCVD          |
  |  ---- ACK(ack=y+1) --->                |
  |  ESTABLISHED            ESTABLISHED     |
  |                                         |
  |  <========== 数据传输 ==========>       |
  |                                         |
  |  ---- FIN ---->                         |
  |  FIN_WAIT_1             CLOSE_WAIT      |
  |  <--- ACK ----                          |
  |  FIN_WAIT_2                             |
  |                                         |
  |  <--- FIN ----                          |
  |                       LAST_ACK          |
  |  ---- ACK ---->                         |
  |  TIME_WAIT              (CLOSED)        |
  |  (等60秒)                               |
  |  (CLOSED)                               |

TIME_WAIT和CLOSE_WAIT:生产环境真正在意的两个状态

基础知识讲完了,下面是生产环境真正会遇到的问题。

TIME_WAIT:出现在主动关闭方

存在的两个原因:

  1. 确保最后一个ACK能送达。如果最后的ACK丢了,服务端会重发FIN,客户端需要在TIME_WAIT状态下重新回ACK。如果已经CLOSED了就没人回了。

  2. 防止旧连接的延迟包干扰新连接。如果没有TIME_WAIT,新连接复用了相同的四元组(源IP、源端口、目的IP、目的端口),旧连接的迟到的包会被误认为新连接的包。

持续时间: Linux上TIME_WAIT的超时是60秒(硬编码在内核里,MSL=30秒,2MSL=60秒)。

注意:net.ipv4.tcp_fin_timeout这个参数控制的是FIN_WAIT_2的超时时间(默认60秒),不是TIME_WAIT的超时。TIME_WAIT的60秒是写死在内核代码里的,不能通过sysctl调整。

# 查看TIME_WAIT数量
ss -ant | grep TIME_WAIT | wc -l

大量TIME_WAIT的处理:

# sysctl.conf

# 允许复用TIME_WAIT状态的socket(仅对出站连接生效)
net.ipv4.tw_reuse = 1

# 扩大可用端口范围
net.ipv4.ip_local_port_range = 1024 65535

tcp_tw_reuse是安全的,它只对出站连接生效,而且会检查时间戳确保不会复用到还活着的旧连接。

tcp_tw_recycle不要用。 这个参数在NAT环境下会导致严重问题——同一个NAT出口后面的多台机器时间戳不一致,会导致合法的SYN包被丢弃。Linux 4.12已经把这个参数移除了。如果你的内核文档里还看得到它,别碰。

更根本的解决办法:用长连接替代短连接。 TIME_WAIT堆积的元凶是大量短连接。如果是自己写的服务,用连接池复用连接;如果是调用外部服务,HTTP用Connection: keep-alive。

CLOSE_WAIT:出现在被动关闭方

CLOSE_WAIT是一个短暂的过渡状态——收到FIN后进入CLOSE_WAIT,发完剩余数据发自己的FIN,就到LAST_ACK了。

但如果你的服务出现大量CLOSE_WAIT堆积,说明你的代码有问题。

CLOSE_WAIT意味着:你的程序收到了对端的FIN(对端要关连接),但你的程序没有调用close()。

CLOSE_WAIT不会自动消失。它不像TIME_WAIT有60秒超时,CLOSE_WAIT会一直持续到你的程序主动close。一旦堆积只会越来越多,直到文件描述符耗尽,进程崩掉。

# 查看CLOSE_WAIT数量
ss -ant | grep CLOSE_WAIT | wc -l

# 找到对应的进程
ss -antp | grep CLOSE_WAIT | head -5
CLOSE-WAIT  0  0  10.0.1.50:45000  10.0.1.60:6379  users:(("java",pid=12345,fd=89))

同一个进程大量CLOSE_WAIT,大概率是连接泄漏。

常见原因:

// 连接泄漏的典型代码
Connection conn = pool.getConnection();
Result result = conn.query(sql);  // 如果这里抛异常
pool.release(conn);               // 这行就不会执行,连接就泄漏了

// 正确写法:try-finally确保归还
Connection conn = pool.getConnection();
try {
    Result result = conn.query(sql);
    return result;
} finally {
    pool.release(conn);  // 无论异常与否都归还
}

这种泄漏不直接报错,表现是运行一段时间后连接池耗尽、新请求超时。重启就好了,过几天又出现。

如果你的Java服务有这种症状,第一件事查CLOSE_WAIT。


各状态速查表

状态在谁身上意味着大量堆积说明
SYN_SENT主动连接方发了SYN等回复对方没响应,查防火墙或端口
SYN_RCVD被动连接方收到SYN等ACK可能是SYN Flood攻击
ESTABLISHED双方连接已建立正常
FIN_WAIT_1主动关闭方已发FIN等ACK对方没回ACK,网络问题
FIN_WAIT_2主动关闭方已收到ACK等对方FIN对方程序没close()
CLOSE_WAIT被动关闭方收到对方FIN自己还没关自己的代码没close
LAST_ACK被动关闭方已发FIN等最后ACK正常过渡,极少堆积
TIME_WAIT主动关闭方等60秒后彻底关闭短连接太多,用长连接解决

一句话:CLOSE_WAIT是代码bug的信号,TIME_WAIT是架构设计的信号。 前者查代码,后者改架构。


排查实战

连接状态分布

ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
  5000 ESTABLISHED
  2000 TIME_WAIT
   500 CLOSE_WAIT
    50 SYN_SENT

TIME_WAIT 2000不一定有问题(短暂存在是正常的)。CLOSE_WAIT 500就有问题了。

CLOSE_WAIT对应哪个进程

ss -antp | grep CLOSE_WAIT | awk '{print $6}' | sort | uniq -c | sort -rn
  480 users:(("java",pid=12345,fd=89))
   20 users:(("python",pid=6789,fd=12))

java进程pid=12345占了绝大多数。然后用jstack看这个进程在干什么。

SYN_SENT堆积

ss -ant | grep SYN_SENT | wc -l

如果SYN_SENT大量堆积,说明你的服务器在主动建立连接但对方没回应。可能是下游服务挂了、防火墙拦了、或者端口没开。

# 看SYN_SENT都连着谁
ss -ant | grep SYN_SENT | awk '{print $5}' | sort | uniq -c | sort -rn
  45 10.0.1.60:3306
   5 10.0.1.70:6379

10.0.1.60:3306(MySQL)有45个连接卡在SYN_SENT。大概率MySQL挂了或者被防火墙拦了。


给Java开发的几个建议

连接池配置

// Apache HttpClient 4.x
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);           // 总连接数
cm.setDefaultMaxPerRoute(50);  // 每个目标的最大连接数

// 超时配置三个都要设
RequestConfig config = RequestConfig.custom()
    .setConnectTimeout(3000)              // TCP建连超时
    .setSocketTimeout(5000)               // 等待响应超时
    .setConnectionRequestTimeout(2000)    // 从连接池获取连接超时
    .build();

ConnectionRequestTimeout很多人漏了。不设的话连接池满了请求会无限卡住,排查起来很迷惑——日志里看起来像是"请求没发出去",实际是卡在等连接。

长连接保活

# Spring Boot配置
server:
  tomcat:
    connection-timeout: 20s     # 空闲连接超时
    keep-alive-timeout: 60s     # Keep-Alive超时
    max-keep-alive-requests: 100  # 单连接最大请求数

定期检查连接状态

建议在监控系统里加上TCP状态监控:

#!/bin/bash
# tcp_state_monitor.sh - 每分钟执行一次

CLOSE_WAIT=$(ss -ant | grep -c CLOSE_WAIT)
TIME_WAIT=$(ss -ant | grep -c TIME_WAIT)

if [ "$CLOSE_WAIT" -gt 100 ]; then
    echo "[ALERT] CLOSE_WAIT=$CLOSE_WAIT 可能有连接泄漏"
fi

echo "$(date) CLOSE_WAIT=$CLOSE_WAIT TIME_WAIT=$TIME_WAIT" >> /var/log/tcp_state.log

CLOSE_WAIT超过阈值就告警,早发现早修复。


最后

面试能答出三次握手四次挥手只是及格。生产环境需要理解的是每个状态的含义,知道什么情况下会堆积,堆积了怎么排查和解决。

CLOSE_WAIT是代码bug的信号——查连接泄漏。TIME_WAIT是架构设计的信号——用长连接替代短连接。SYN_SENT堆积是下游出了问题——查目标服务和防火墙。

这些不是运维的专属知识。做后端开发的,线上出问题排查方向搞反了——明明是网络连接的问题,却一直在查代码逻辑,白白浪费时间。

# 排查问题之前先看一眼连接状态
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn

一行命令,十秒钟,可能省掉你半天的排查时间。