3. TCP三次握手与四次挥手:状态转换图、半关闭状态、TIME_WAIT详解、保活机制
TCP的连接管理,说白了就是三次握手和四次挥手。这两个过程,我当年刚入行时觉得很简单,不就是发几个包嘛。直到线上出了几次诡异的问题,我才意识到——这里面的门道,远比教科书上写的要深。
今天咱们就掰开揉碎,把状态转换图、半关闭、TIME_WAIT、保活机制这些硬骨头啃下来。嗯,我会结合我踩过的坑来讲,希望能帮你少走弯路。
3.1 三次握手:状态转换图
三次握手,本质上是双方协商初始序列号、确认对方存在的过程。我习惯把它理解成「你准备好了吗?」「我准备好了,你呢?」「我也准备好了」——三次对话,缺一不可。
核心流程:
- 客户端发送 SYN(seq=x),进入 SYN_SENT 状态
- 服务端收到 SYN,回复 SYN+ACK(seq=y, ack=x+1),进入 SYN_RCVD 状态
- 客户端收到 SYN+ACK,回复 ACK(seq=x+1, ack=y+1),进入 ESTABLISHED 状态
- 服务端收到 ACK,也进入 ESTABLISHED 状态
这里有个细节:为什么是三次,不是两次?我当年也问过这个问题。说白了,两次握手无法防止「已失效的连接请求」突然传到服务端。举个例子,客户端发了个 SYN,网络延迟了,重发一次,结果第一次的 SYN 又到了——两次握手的话,服务端会直接建立两个连接,浪费资源。三次握手通过服务端回复 SYN+ACK 并等待客户端的 ACK,就能避免这个问题。
下面我用一张 SVG 图来展示完整的状态转换过程,你一看就明白:
你看,客户端主动发起 SYN,服务端被动响应。这个过程中,双方各自维护一个状态机。我建议你把这个图记在脑子里,排查连接问题时特别有用。
我的经验:有一次线上服务大量报「Connection timed out」,我一看监控,SYN_SENT 状态的连接数暴涨。最后发现是客户端并发太高,服务端 SYN_RCVD 队列满了,直接丢包。解决办法?调大 tcp_max_syn_backlog,同时开启 syncookies。
3.2 四次挥手与半关闭状态
断开连接比建立连接复杂。为什么?因为 TCP 是「全双工」的,双方都可以独立地关闭自己的发送通道。四次挥手,就是双方各自说「我发完了」并得到确认的过程。
标准流程:
- 主动方发送 FIN(seq=u),进入 FIN_WAIT_1
- 被动方回复 ACK(ack=u+1),进入 CLOSE_WAIT
- 主动方收到 ACK,进入 FIN_WAIT_2
- 被动方发送 FIN(seq=v),进入 LAST_ACK
- 主动方收到 FIN,回复 ACK(ack=v+1),进入 TIME_WAIT
- 被动方收到 ACK,进入 CLOSED
这里有个关键概念——半关闭。什么意思?就是一方关闭了发送,但还可以接收。比如客户端调用了 shutdown(SHUT_WR),它不能再发数据,但还能收服务端的数据。这在某些协议中很有用,比如 HTTP 的 keep-alive 升级。
我遇到过一个问题:服务端在 CLOSE_WAIT 状态堆积了大量连接,导致文件描述符耗尽。排查后发现,是业务代码忘了调用 close()——被动方收到 FIN 后,如果不主动关闭自己的 socket,就会一直卡在 CLOSE_WAIT。说白了,这是典型的「只收不发」的僵尸连接。
避坑指南:我曾经在一个高并发网关项目中,发现 CLOSE_WAIT 连接数持续增长。最后定位到是某个第三方库在读取完数据后,没有调用 close()。修复后,连接数立刻降下来了。记住:被动方收到 FIN 后,一定要在合适时机调用 close(),否则就是内存泄漏。
3.3 TIME_WAIT 详解
TIME_WAIT 是四次挥手中最「磨叽」的状态。主动关闭方在发送最后一个 ACK 后,会进入 TIME_WAIT,等待 2MSL(Maximum Segment Lifetime)时间,通常是 1-4 分钟。
为什么要等这么久?两个原因:
- 防止最后一个 ACK 丢失:如果服务端没收到 ACK,会重发 FIN。TIME_WAIT 状态能确保主动方还能响应这个重发的 FIN。
- 让旧连接的所有报文都消失:2MSL 时间足够让网络中残留的旧连接报文全部过期,避免干扰新连接。
我在项目中见过一个经典问题:高并发短连接场景下,服务器上 TIME_WAIT 连接数达到几万个,占满了端口资源。新连接无法建立,报错「Address already in use」。
我的建议:如果业务允许,可以开启 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_tw_recycle(注意:tcp_tw_recycle 在 NAT 环境下有坑,慎用)。或者调整 net.ipv4.tcp_fin_timeout 缩短 TIME_WAIT 时长。但我个人更推荐用 SO_REUSEADDR 选项,让新连接复用 TIME_WAIT 状态的端口。
下面这个表格总结了 TIME_WAIT 相关的内核参数,方便你参考:
| 参数名 | 默认值 | 说明 |
|---|---|---|
| tcp_fin_timeout | 60 | FIN_WAIT_2 状态的超时时间(秒) |
| tcp_tw_reuse | 0 | 允许将 TIME_WAIT 状态的 socket 用于新连接 |
| tcp_tw_recycle | 0 | 快速回收 TIME_WAIT 连接(NAT 环境慎用) |
| tcp_max_tw_buckets | 180000 | TIME_WAIT 连接的最大数量,超过后系统会直接关闭 |
3.4 保活机制
TCP 连接建立后,如果双方长时间不通信,中间的路由器或防火墙可能会把连接「忘掉」。保活机制就是用来探测对端是否还活着。
保活机制有三个关键参数:
- tcp_keepalive_time:空闲多久开始探测,默认 7200 秒(2小时)
- tcp_keepalive_intvl:每次探测的间隔,默认 75 秒
- tcp_keepalive_probes:最多探测几次,默认 9 次
也就是说,默认情况下,一个空闲连接要等 2 小时才开始探测,然后每 75 秒发一次,连续 9 次没响应才断开。总共耗时 2小时 + 75*9 = 2小时11分钟。这个时间太长了,很多业务场景等不了。
我的做法:在应用层实现心跳机制,比如每 30 秒发一个心跳包。这样即使 TCP 保活没触发,应用层也能快速感知对端是否存活。TCP 保活机制我一般只作为兜底方案,参数调成 tcp_keepalive_time=300(5分钟),tcp_keepalive_intvl=10,tcp_keepalive_probes=3。
我曾经在一个长连接网关项目中,客户端频繁断连,但服务端要等 2 小时才发现。后来我在应用层加了心跳,30 秒一次,3 次没收到就主动断开。问题立刻解决了。你想想看,如果只依赖 TCP 保活,那用户体验得多差?
注意:保活机制不是 TCP 规范强制要求的,有些操作系统可能不实现。而且保活探测包会增加网络流量,如果连接数很大(比如 10 万+),每秒的探测包数量会非常可观。所以,我建议只在关键连接上开启保活,或者干脆用应用层心跳替代。
好了,TCP 三次握手和四次挥手的核心内容就这些。状态转换图要刻在脑子里,半关闭状态要理解它的应用场景,TIME_WAIT 要懂得怎么调优,保活机制要结合业务来设计。嗯,这些知识点,你在面试和实战中都会反复遇到。记住:理论是基础,但真正让你成长的,是线上踩过的每一个坑。