2. TCP连接管理:三次握手建立连接、四次挥手断开连接、半关闭状态与TCP状态转换图

TCP 的连接管理,说白了就是两个端点之间怎么「对上暗号」开始聊天,以及怎么「体面告别」结束对话。我做了这么多年网络排查,发现很多线上问题归根结底都是连接管理没搞明白。今天咱们就把这块彻底吃透。

2.1 三次握手:建立连接

为什么叫「三次握手」?因为需要交换三个报文段才能把连接建起来。你想想看,如果只有两次,服务端怎么知道客户端已经准备好接收数据了?

核心流程:

  1. 第一次握手(SYN): 客户端发送 SYN 报文,初始序列号设为 x。客户端进入 SYN_SENT 状态。
  2. 第二次握手(SYN+ACK): 服务端收到后,回复 SYN+ACK,确认号 ack=x+1,自己的初始序列号为 y。服务端进入 SYN_RCVD 状态。
  3. 第三次握手(ACK): 客户端收到后,发送 ACK,确认号 ack=y+1。双方进入 ESTABLISHED 状态。

我个人习惯把三次握手比作打电话:

  • 客户端:「喂,能听到吗?」(SYN)
  • 服务端:「听到了,你能听到我吗?」(SYN+ACK)
  • 客户端:「听到了,开始说吧。」(ACK)

嗯,这里要注意:第三次握手是可以携带数据的。但前两次不行,因为连接还没建立,数据发过去也没人认。

避坑指南: 我曾经遇到过一个问题——客户端大量发送 SYN 但不完成第三次握手,这叫 SYN 洪水攻击。服务端会维护一个半连接队列,如果队列满了,正常用户的连接就进不来了。解决方案是开启 SYN Cookie,把连接信息编码到 SYN+ACK 的序列号里,等收到第三次握手再重建。

2.2 四次挥手:断开连接

断开连接比建立连接复杂,因为 TCP 是全双工的——两个方向可以独立关闭。所以需要四次挥手。

核心流程:

  1. 第一次挥手(FIN): 主动关闭方发送 FIN,序列号为 u。进入 FIN_WAIT_1 状态。
  2. 第二次挥手(ACK): 被动关闭方回复 ACK,确认号 ack=u+1。进入 CLOSE_WAIT 状态。主动方收到后进入 FIN_WAIT_2。
  3. 第三次挥手(FIN): 被动关闭方发送 FIN,序列号为 v。进入 LAST_ACK 状态。
  4. 第四次挥手(ACK): 主动关闭方回复 ACK,确认号 ack=v+1。进入 TIME_WAIT 状态,等待 2MSL 后关闭。

为什么是四次而不是三次?因为服务端收到 FIN 后,可能还有数据没发完,不能马上关闭。所以先回 ACK 告诉客户端「我知道了」,等数据发完再发 FIN。说白了,ACK 和 FIN 是分开的。

常见问题: 我见过很多开发者在写网络程序时,服务端收到 FIN 后直接调用 close(),导致客户端还在读数据就被强制关闭了。正确的做法是:先调用 shutdown(SHUT_RD) 关闭读方向,等数据发完再 close()。

2.3 半关闭状态

半关闭是 TCP 的一个特性——允许一端关闭自己的发送通道,但保留接收通道。这在某些场景下非常有用。

举个例子:客户端发完请求后,调用 shutdown(SHUT_WR) 告诉服务端「我说完了,你慢慢处理」。服务端知道客户端不会再发数据了,但还能继续发响应。等响应发完,服务端再关闭自己的发送通道。

实战经验: 我曾经在做一个 HTTP 代理服务器时,利用半关闭状态解决了「请求-响应边界」的问题。客户端发完请求后立即半关闭,代理就知道请求结束了,可以开始转发。如果不这样做,代理就得自己解析 HTTP 头部来找到请求结束位置,麻烦得多。

半关闭的 API 调用:

// 关闭发送方向,保留接收方向
shutdown(sockfd, SHUT_WR);

// 关闭接收方向,保留发送方向
shutdown(sockfd, SHUT_RD);

// 完全关闭
shutdown(sockfd, SHUT_RDWR);

注意:close() 会立即关闭两个方向,而 shutdown() 可以精细控制。我建议在需要优雅关闭的场景下优先使用 shutdown()。

2.4 TCP 状态转换图

TCP 的状态机是整个协议最核心的部分。我当年考 CCIE 时,这张图背了不下 50 遍。但后来发现,光背没用,得理解每个状态的含义和转换条件。

下面这张 SVG 图展示了完整的 TCP 状态转换过程:

TCP 状态转换图 TCP 状态转换图 CLOSED SYN_SENT ESTABLISHED FIN_WAIT_1 FIN_WAIT_2 TIME_WAIT CLOSED LISTEN SYN_RCVD ESTABLISHED CLOSE_WAIT LAST_ACK 主动打开 收到 SYN+ACK 主动关闭 收到 ACK 收到 FIN 2MSL 超时 被动打开 收到 SYN 收到 ACK 收到 FIN 应用关闭 收到 ACK 超时 说明 蓝色:客户端状态 | 绿色:连接建立 | 红色:连接关闭 橙色:服务端监听状态 | 紫色:TIME_WAIT 等待

2.5 关键状态详解

状态 所属端 含义 常见问题
LISTEN 服务端 等待客户端连接 端口被占用时无法进入此状态
SYN_SENT 客户端 已发送 SYN,等待 SYN+ACK 对端无响应时卡在此状态
SYN_RCVD 服务端 已收到 SYN,等待 ACK 半连接队列满时丢包
ESTABLISHED 双方 连接已建立,可传输数据 正常状态,无需担心
FIN_WAIT_1 主动关闭方 已发送 FIN,等待 ACK 对端不回复时卡住
FIN_WAIT_2 主动关闭方 已收到 ACK,等待 FIN 对端不关闭时卡住
TIME_WAIT 主动关闭方 等待 2MSL 后关闭 大量 TIME_WAIT 耗尽端口
CLOSE_WAIT 被动关闭方 已收到 FIN,等待应用关闭 应用未调用 close() 时泄漏
LAST_ACK 被动关闭方 已发送 FIN,等待 ACK 对端不回复时卡住

重点提醒: TIME_WAIT 状态经常被误解。它存在的两个原因:

  • 保证最后一个 ACK 能到达对端(如果丢了,对端会重发 FIN)
  • 让网络中残留的报文段过期,避免影响新连接

2MSL 是报文最大生存时间的两倍,通常为 1-4 分钟。我曾经在压测时遇到大量 TIME_WAIT 导致端口耗尽,后来通过开启 tcp_tw_reuse 和 tcp_tw_recycle 解决。但注意,tcp_tw_recycle 在 NAT 环境下会出问题,慎用。

2.6 实战排查技巧

最后分享几个我常用的排查命令:

# 查看当前所有 TCP 连接状态统计
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

# 查看特定端口的连接状态
ss -tna | grep :8080

# 查看 TIME_WAIT 状态的连接详情
ss -tna state time-wait

# 抓取三次握手过程
tcpdump -i eth0 'tcp port 80 and (tcp-syn or tcp-ack)'

嗯,这里要注意:ss 命令比 netstat 快得多,我建议优先用 ss。尤其是在生产环境,netstat 读取 /proc 文件系统,连接数多时会很慢。

个人经验: 有一次线上服务突然大量报错「Connection reset by peer」,我一看 ss -tna,发现 CLOSE_WAIT 状态堆积了上千个。排查后发现是业务代码在收到 FIN 后没有调用 close(),导致服务端一直卡在 CLOSE_WAIT。修复后问题立刻消失。所以,如果你看到 CLOSE_WAIT 堆积,十有八九是应用层忘了关闭 socket。