TCP 要解决的核心问题
IP 协议只负责把数据包扔出去,不保证到达、不保证顺序、不保证不重复。TCP 在它之上补上这些保证,方式是:确认、重传、排序、去重。
要确认,双方就必须先知道对方的接收能力,并同步各自的起始序号。这就是握手的意义。
三次握手的过程
客户端 服务端
|------- SYN, seq=x ----------->| 我要连接,我的起始序号是 x
|<-- SYN+ACK, seq=y, ack=x+1 ---| 收到,我的序号是 y,确认收到 x
|------- ACK, ack=y+1 --------->| 确认收到 y,连接建立
三次交互,双方各自确认了「我的发送能力」和「对方的接收能力」都正常。
为什么不能是两次
两次握手时,客户端发 SYN,服务端回 SYN+ACK 就直接认为连接建立了。问题出在网络延迟导致的旧连接请求:
- 客户端发了一个 SYN,网络拥塞导致它滞留
- 客户端超时重发,第二个 SYN 顺利到达,连接建立、传输、关闭
- 此时那个滞留的旧 SYN 才到达服务端
- 服务端以为这是新请求,直接建立连接并等待数据
- 客户端认为没有这个连接,不理会——服务端就白白占着一个连接资源
第三次握手的作用就是让客户端有机会拒绝这种陈旧的连接请求。
四次挥手
关闭连接要四次,因为 TCP 是全双工的:两个方向要分别关闭。
客户端 服务端
|------- FIN ---------------->| 我没有数据要发了
|<------ ACK -----------------| 知道了
|<------ FIN -----------------| 我也没有数据要发了
|------- ACK ---------------->| 知道了,连接关闭
中间的 ACK 和 FIN 不能合并,因为服务端收到 FIN 时可能还有数据要发完,必须先确认、后关闭。
TIME_WAIT 为什么存在
主动关闭的一方在发完最后一个 ACK 后,会进入 TIME_WAIT 状态,等待 2 倍报文最大生存时间(通常 1 到 4 分钟)才真正释放。
两个原因:
- 确保最后的 ACK 能到达。如果丢了,对方会重发 FIN,此时本端还能响应
- 让旧连接的残余数据包自然消亡,避免它们被新连接误收
所以 TIME_WAIT 不是 bug,而是可靠性设计的一部分。服务器上大量 TIME_WAIT 通常说明短连接太多,正确的解法是用连接池或长连接,而不是简单调小超时时间。
常见状态速查
| 状态 | 含义 | 出现位置 |
|---|---|---|
SYN_SENT | 已发 SYN,等对方确认 | 主动连接方 |
SYN_RECV | 收到 SYN,已回 SYN+ACK | 被动连接方 |
ESTABLISHED | 连接已建立,可传数据 | 双方 |
CLOSE_WAIT | 对方要关,本端还没关 | 被动关闭方 |
TIME_WAIT | 已关闭,等待残余报文消亡 | 主动关闭方 |
其中 CLOSE_WAIT 堆积是最危险的状态:它表示对方已经关闭连接,而本端代码没有调用关闭。这几乎总是代码 bug(比如忘了关闭连接),堆积下去会耗尽文件描述符。
排查命令:
netstat -an | awk "{print $6}" | sort | uniq -c | sort -rn
对开发者的实际影响
第一,短连接是有成本的。 每次请求都新建连接,就要付一次握手的 RTT。所以数据库访问、下游服务调用都要用连接池。
第二,超时时间要设置。 默认超时可能长达几分钟,一个慢下游会把线程池占满。合理做法是显式设置连接超时和读取超时,并且两者分开设置。
第三,理解为什么 HTTP/2 快。 它在一个 TCP 连接上多路复用所有请求,省掉了反复握手的开销,也避开了 HTTP/1.1 的队头阻塞(应用层层面的)。
小结
握手和挥手的每一步都有明确目的:握手是为了同步序号并确认双向可达,挥手是因为全双工要分别关闭。
工程上要记住的只有两条:用连接池避免反复握手,监控 CLOSE_WAIT 及早发现漏关连接的代码。