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:出现在主动关闭方
存在的两个原因:
-
确保最后一个ACK能送达。如果最后的ACK丢了,服务端会重发FIN,客户端需要在TIME_WAIT状态下重新回ACK。如果已经CLOSED了就没人回了。
-
防止旧连接的延迟包干扰新连接。如果没有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
一行命令,十秒钟,可能省掉你半天的排查时间。