TCP协议详解:三次握手、四次挥手、状态机与流量控制
TCP 这东西,说白了就是给不可靠的 IP 层套了个「可靠」的壳。我早年刚接触网络编程时,总觉得它太复杂,不就是发个数据吗?直到自己写了个简单的 socket 程序,发现数据丢了、乱序了、连接卡死了……嗯,从那以后我才老老实实把 TCP 的状态机背了下来。
这一节我们就把 TCP 的核心机制拆开揉碎。你跟着我走一遍,保证以后抓包排查问题时心里有底。
1. 三次握手:建立连接为什么非要三次?
先看一个最经典的场景:客户端想跟服务器说话。TCP 用三次握手来同步双方的序列号,并确认双方都有收发能力。
核心流程:
- SYN(客户端→服务器):客户端发送 SYN 包,随机生成一个初始序列号
seq=x,并进入SYN_SENT状态。 - SYN+ACK(服务器→客户端):服务器收到后,分配资源,回复 SYN+ACK,其中
ack=x+1,自己的序列号seq=y,进入SYN_RCVD状态。 - ACK(客户端→服务器):客户端收到后,再发一个 ACK,
ack=y+1。此时双方都进入ESTABLISHED状态,可以开始传数据了。
为什么不是两次?我举个例子你就明白了。假设只有两次握手:客户端发 SYN,服务器回 SYN+ACK 就认为连接建立。但如果这个 SYN 在网络中延迟了,客户端超时重传了一个新 SYN,服务器又回了新 SYN+ACK,此时旧 SYN 突然到达服务器……服务器会以为这是两个连接,白白浪费资源。三次握手通过客户端的最后一次 ACK 来「确认确认」,彻底避免了这种历史重复连接的问题。
避坑指南: 我曾经在调试一个高并发网关时,发现大量 SYN_RCVD 状态的连接堆积。排查下来是客户端发完 SYN 后不回复 ACK,这就是典型的 SYN Flood 攻击。解决方案是开启 Linux 的 tcp_syncookies,内核会在半连接队列满时用 cookie 机制绕过队列。
2. 四次挥手:断开连接为什么比建立多一次?
断开连接时,TCP 是全双工的,每一方向都要单独关闭。所以需要四次交互。
标准流程:
- FIN(主动关闭方):主动方发送 FIN,
seq=u,进入FIN_WAIT_1状态。表示「我没数据要发了」。 - ACK(被动关闭方):被动方回复 ACK,
ack=u+1,进入CLOSE_WAIT状态。主动方收到后进入FIN_WAIT_2。 - FIN(被动关闭方):被动方处理完剩余数据后,发送 FIN,
seq=v,进入LAST_ACK状态。 - ACK(主动关闭方):主动方回复 ACK,
ack=v+1,进入TIME_WAIT状态。等待 2MSL 后彻底关闭。
你想想看,为什么不能把第二步和第三步合并?因为被动方收到 FIN 时,可能还有数据没发完。它必须先回复 ACK 表示「我知道你要关了」,等自己数据发完再发 FIN。这就是四次挥手比三次握手多一次的根本原因。
注意: TIME_WAIT 状态持续 2MSL(约 1-4 分钟),目的是让旧连接的所有报文在网络中消失,防止新连接收到旧数据。我在线上遇到过因为 TIME_WAIT 过多导致端口耗尽的问题,后来用 tcp_tw_reuse 和 tcp_tw_recycle 优化了回收策略(注意:内核 4.12 之后已移除 tcp_tw_recycle)。
3. TCP 状态转换图:一张图看懂所有状态
下面这张 SVG 图是我自己梳理的 TCP 状态机核心路径。你盯着看两分钟,比背十遍文字都管用。
这张图我建议你保存下来。我个人习惯在排查连接异常时,先看 ss -tna 输出的状态,然后对照这张图反推当前卡在哪一步。
4. 滑动窗口与流量控制:TCP 怎么做到不丢包?
TCP 不能一股脑把数据全扔出去,否则接收方来不及处理就会丢包。滑动窗口就是用来做流量控制的。
简单说,接收方会告诉发送方:「我当前能收多少字节」,这个值就是窗口大小(Window Size)。发送方只能在这个窗口内发送数据,不能超发。
滑动窗口的核心概念:
- 已发送并确认:最左边,已经收到 ACK 的数据。
- 已发送未确认:在窗口内,但还没收到 ACK。
- 允许发送但未发送:窗口剩余空间,发送方可以继续发。
- 不可发送:超出窗口范围,必须等窗口滑动后才能发。
你想想看,如果接收方处理速度慢,它会逐渐缩小窗口。当窗口变为 0 时,发送方就停止发送,并启动「零窗口探测」定时器,定期发一个字节去试探窗口是否恢复。这就是流量控制的本质——接收方主导,发送方配合。
实战经验: 我曾经在调优一个文件传输服务时,发现吞吐量上不去。抓包一看,接收方每次通告的窗口都很小,但接收方的 CPU 和内存都空闲。后来发现是接收方的 socket 缓冲区设置太小,默认只有 64KB。调大 SO_RCVBUF 后,窗口立刻变大,吞吐量翻了 3 倍。
5. 窗口缩放因子:突破 65535 字节的限制
TCP 头部的窗口字段只有 16 位,最大只能表示 65535 字节。这在百兆、千兆网络下显然不够用。所以 RFC 1323 引入了窗口缩放因子(Window Scaling)。
三次握手时,双方会在 SYN 包中协商一个缩放因子。比如因子是 7,实际窗口大小就是 通告窗口 × 2^7,最大可达 1GB。这个机制默认开启,你不需要手动配置,但排查问题时要知道它的存在。
| 概念 | 说明 | 常见问题 |
|---|---|---|
| 三次握手 | SYN → SYN+ACK → ACK,同步序列号,防止历史连接 | SYN Flood 攻击、半连接队列溢出 |
| 四次挥手 | FIN → ACK → FIN → ACK,全双工独立关闭 | TIME_WAIT 过多、CLOSE_WAIT 泄漏 |
| 滑动窗口 | 接收方通告可用缓冲区大小,控制发送速率 | 窗口过小导致吞吐量低、零窗口死锁 |
| 窗口缩放 | 通过因子放大窗口,支持高速网络 | 中间设备可能改写缩放因子导致性能下降 |
注意: 有些老旧的路由器或防火墙会改写 TCP 选项,把窗口缩放因子置为 0。如果你发现两台服务器之间明明带宽很大,但吞吐量卡在 64KB/s 左右,大概率是中间设备把缩放因子干掉了。用 tcpdump 抓包看 SYN 中的 wscale 选项就能确认。
6. 总结一下
TCP 的可靠性不是凭空来的。三次握手建立连接,四次挥手优雅关闭,状态机管理整个生命周期,滑动窗口做流量控制。每一个机制背后都有实际要解决的问题。
我个人建议你找个空闲时间,用 tcpdump 抓一次本机访问某个网站的包,对照着本节内容看一遍三次握手和四次挥手的序列号变化。嗯,看完你就彻底懂了。