第一堂课:三次握手、四次挥手与状态机

讲 TCP 优化,头一条你得把它的生命周期吃透。说白了,TCP 就三个阶段:建连、传数、断连。每个阶段都靠状态机驱动,状态机一旦跑偏,性能就崩。

三次握手:不是多此一举

客户端要跟服务端说话,得先“握手”。三次,少一次都不行。你想想看,如果只握两次,服务端怎么知道客户端收到了自己的 SYN+ACK?万一丢了,服务端会一直等,白白浪费资源。

具体流程:

  1. 客户端发 SYN=1, seq=x。状态变成 SYN_SENT。
  2. 服务端回 SYN=1, ACK=1, seq=y, ack=x+1。状态变成 SYN_RCVD。
  3. 客户端发 ACK=1, seq=x+1, ack=y+1。双方进入 ESTABLISHED。
一个小坑:我曾经遇到过线上服务大量超时,抓包发现服务端发了 SYN+ACK 却收不到客户端的第三次 ACK。查下去,是客户端半连接队列满了——服务端来不及 accept,客户端重传了 SYN,服务端又回 SYN+ACK,结果自己把自己堆死了。

这里要注意半连接队列和全连接队列。半连接队列(tcp_max_syn_backlog)存的是刚收到 SYN、还没完成三次握手的连接。一旦它满了,新 SYN 直接丢,客户端会反复重试,表现为连接建立超慢。我建议调大 net.core.somaxconn 和 net.ipv4.tcp_max_syn_backlog,尤其高并发场景。

四次挥手:好聚好散

断连比建连复杂。因为 TCP 是双工的,两端都要关闭自己的发送通道,所以需要四次交互。

  1. 主动方发 FIN=1, seq=u → FIN_WAIT1
  2. 被动方回 ACK=1, seq=v, ack=u+1 → CLOSE_WAIT
  3. 被动方发 FIN=1, seq=w, ack=u+1 → LAST_ACK
  4. 主动方回 ACK=1, seq=u+1, ack=w+1 → TIME_WAIT,2MSL 后关闭

怎么记这个顺序?我自己的口诀:

  • 先断写(主动FIN)
  • 对方确认(被动ACK)
  • 对方也断(被动FIN)
  • 我方确认(主动ACK)
TIME_WAIT 是双刃剑:它防止迟到的数据包干扰新连接。但端口被占满会引发“address already in use”。我曾经调 net.ipv4.tcp_tw_reuse 和 tcp_tw_recycle(不过注意,内核 4.12 后移除了 recycle),配合 tcp_fin_timeout 缩短 TIME_WAIT 时长,在高并发短连接场景效果明显。

状态机:一张图看懂全部

TCP 状态机把上面所有流程串起来。不像 HTTP 那么无脑,TCP 状态切换有严格的前后依赖。下面这张图我亲手画出来,方便你对照抓包时看到的状态。

TCP 核心状态转移图 CLOSED SYN_SENT SYN_RCVD ESTABLISHED FIN_WAIT1 CLOSE_WAIT FIN_WAIT2 LAST_ACK TIME_WAIT(2MSL) 主动打开

(注:上面 SVG 是示意图,展示主要状态间的跳转。实际 TCP 状态机文档中有 11 种状态,这里我保留了最关键的 9 个,方便你快速对照 ss 或 netstat 的输出。)

状态机里的几个“死结”

状态 常见原因 影响
SYN_RCVD 堆积 半连接队列满,或服务端 accept 太慢 新连接超时重传,连接建立延迟
CLOSE_WAIT 过多 被动方应用没调用 close() 连接泄漏,文件描述符耗尽
TIME_WAIT 过多 短连接频繁,且主动关闭方在客户端 端口耗尽,无法发起新连接
FIN_WAIT2 长时间卡住 被动方不发 FIN(应用没关闭 socket) 连接半开,资源占着不释放
实战 Tip:我发现很多人只会用 netstat -anp | grep CLOSE_WAIT 数数,但其实 ss -s 能一眼看出整体状态分布。我自己的服务器上习惯每分钟记录一次状态快照,丢到时序库里,出问题时能快速回放到底是哪个状态堆积了。

怎么用这些知识做优化?

你知道握手阶段最怕什么?**SYN Flood**。攻击者只发 SYN 不回 ACK,服务端挂一堆半连接,CPU 忙着重试,正常用户连不上。我建议:

  • 开启 net.ipv4.tcp_syncookies,内核在队列满时改用 cookie 验证,省掉半连接占用。
  • 调小 net.ipv4.tcp_syn_retries,默认 6 次(127 秒),改成 3 次(13 秒)足够。
  • 设置 net.core.somaxconn 和 net.ipv4.tcp_abort_on_overflow:后者改成 1 时,队列满直接发 RST 切断,别让客户端傻等。

断连阶段,TIME_WAIT 是老大难。我经历过一个支付通知服务,单机并发 2 万 QPS,TIME_WAIT 冲上 6 万,端口耗光,服务直接拒绝新连接。当时的解法:

  1. 调整 net.ipv4.tcp_fin_timeout 从 60 降到 15 秒。
  2. 开启 net.ipv4.tcp_tw_reuse(需要 NAT 对称路由没问题时才安全)。
  3. 改成非主动关闭方——让服务端先 close,把 TIME_WAIT 留给客户端(虽然不完美,但客户端端口多,不容易耗光)。
关于 tcp_tw_recycle:我提醒一句,这个参数在 NAT 环境下会误杀报文,新版内核已经彻底去掉。别再用它来对付 TIME_WAIT 了。

状态机这个东西,你只要记住了那九个状态之间的箭头,再看 ss -tan 的输出就一目了然。嗯,建连和断连的流程先讲到这里。下一小节我们聊 TSO、GSO 和接收卸载 —— 不过那些是内核数据路径的事,咱们先把这棵根基扎牢。