TCP 三次握手
TCP 三次握手
一个问题:怎么可靠地建立连接
你想给朋友寄一封信。先打个电话确认"我现在寄可以吗",朋友说"可以",你说"好"。三句话完成确认,对方知道你真的要寄,你知道对方准备好了,可以开始寄了。
这通电话的逻辑,就是 TCP 三次握手的本质。问题是:为什么是三次不是两次或四次?
我们一步步推演。
两次握手为什么不够
假设两次握手就够:
- 客户端发 SYN("我要连你")
- 服务器回 SYN+ACK("好的,同意")
听起来够了对吧。但有个问题:陈旧的 SYN 包会捣乱。
设想一个场景:客户端发了一个 SYN 包,但因为网络拥塞,这个包在网络里走了 1 分钟。客户端等不到 ACK,超时重发了 SYN。第二次 SYN 成功,连接建立,数据传输完,连接关闭。
但 1 分钟后,第一个陈旧的 SYN 包到了服务器。服务器以为是新的连接请求,回 SYN+ACK。客户端当前没有在连,收到这个包会发 RST 复位("我没连你"),连接关闭。这看起来没事。
但如果客户端已经发完第一个 SYN 又发第二个呢?服务器收到第一个 SYN 以为是新连接,回 SYN+ACK。客户端看到 SYN+ACK 里的"初始序列号",但自己正在和服务器建立另一个连接——它可能把"序列号对应的 ACK"发到另一个连接里,造成数据混乱。
两次握手的根本缺陷:服务器无法判断客户端是否真的收到了自己的 SYN+ACK。它只能假设收到。
三次握手为什么够了
三次握手流程:
- 客户端发 SYN(seq=x,x 是客户端初始序列号)
- 服务器回 SYN+ACK(seq=y, ack=x+1,y 是服务器初始序列号,x+1 是确认客户端的 SYN)
- 客户端发 ACK(seq=x+1, ack=y+1)
每一步解决一个问题:
第一次握手:客户端告诉服务器"我要连你,我的初始序列号是 x"。客户端确认了"我能发"。服务器不知道客户端能不能发。
第二次握手:服务器回 SYN+ACK,告诉客户端"我的初始序列号是 y,我收到了你的 SYN(通过 ack=x+1 表明)"。服务器确认了"我能收也能发"。客户端此时收到了 SYN+ACK,确认了"我能收"——但服务器还不知道客户端能不能收。
第三次握手:客户端发 ACK,确认收到服务器的 SYN。服务器收到了这个 ACK,确认了"客户端能收"。
三次之后,双方都确认:对方能收能发,初始序列号都收到了。连接建立完成。
为什么不能是两次:服务器发完 SYN+ACK 后必须等客户端 ACK 才能确认连接建立。如果不等,服务器单方面认为连接建立,分配资源,但客户端可能根本收不到 SYN+ACK(网络故障),那这些资源就浪费了。三次握手的本质是"双方都确认一次收发"——双向确认要至少 3 个包。
为什么不是四次:四次可以工作但没必要。第三次 ACK 完全可以和客户端要发的第一个数据一起发(捎带确认),节省一个包。
真实的三次握手抓包
Wireshark 过滤器 tcp.flags.syn == 1 && tcp.flags.ack == 0 看所有 SYN 包。tcp.stream eq 5 看第 5 个 TCP 连接的所有包。
Time Source Dest Length Info
0.000 192.168.1.100 93.184.216.34 74 54321 → 443 [SYN] Seq=0 Win=65535
0.045 93.184.216.34 192.168.1.100 74 443 → 54321 [SYN, ACK] Seq=0 Ack=1
0.045 192.168.1.100 93.184.216.34 66 54321 → 443 [ACK] Seq=1 Ack=1
0.046 192.168.1.100 93.184.216.34 ... (HTTPS ClientHello)注意:
- 0.000 秒客户端发 SYN
- 0.045 秒服务器回 SYN+ACK(RTT 45ms,说明跨城市或跨运营商)
- 0.045 秒客户端立刻发 ACK(同毫秒,因为客户端算好 1 RTT 后再发)
- 0.046 秒客户端开始发数据,连接进入 ESTABLISHED 状态
初始序列号 ISN:为什么每次都不一样
TCP 头部有个"序列号"字段,每个包都带。初始序列号 ISN 是连接建立时双方各选一个随机数。为什么不能是固定值(如每次都从 0 开始)?
原因:防止旧连接的包混入新连接。
设想 ISN 固定为 0:
- 第一次连接,客户端发包 1-100,连接关闭
- 几秒后建立新连接,ISN 还是 0
- 旧连接被路由器延迟的包 1-100 到了服务器,服务器以为是新连接的数据
- 数据错乱
ISN 随机就解决了:旧包的序列号落在新连接的合法范围之外,服务器收到后丢弃。
Linux 现在的 ISN 生成器:
- 4 微秒一个 tick
- ISN = (tick + hash(五元组)) 模 2^32
- 即使连接被重置,ISN 也和上次不一样
SYN Flood 攻击与 SYN Cookie
三次握手有个资源不对称问题:服务器收到 SYN 后要分配半连接队列(Syn Queue)存放这个状态,然后回 SYN+ACK。客户端不需要分配资源。
攻击者发海量 SYN 包但不回 ACK,服务器的半连接队列被塞满,正常用户的 SYN 被丢弃。这是 SYN Flood 攻击,DDoS 的经典手法。
SYN Cookie 是 Linux 1996 年引入的防御机制:
- 服务器不分配半连接队列记录
- 把客户端的 SYN 信息(IP、端口)通过哈希算法编码进 SYN+ACK 的"初始序列号"字段
- 客户端回 ACK 时,服务器验证 ACK 里的确认号能反算出 SYN 信息
- 验证通过才为连接分配资源
SYN Cookie 代价是服务器不能存客户端选项(窗口缩放、时间戳等),但能扛住 SYN Flood。Linux 默认开启。
真实场景:握手失败怎么办
实际部署中三次握手可能失败,原因各种各样:
服务器端口未监听:服务器没有 listen 这个端口,回 RST 而不是 SYN+ACK。客户端报错 "Connection refused"。
防火墙拦截 SYN:网络安全设备(iptables、AWS Security Group)丢弃 SYN 包,客户端等不到 SYN+ACK,超时报错。3 次 SYN 重试(Linux 默认)后报 "Connection timed out"。
SYN+ACK 被丢弃:服务器回了 SYN+ACK 但中间路由器丢弃,客户端重发 SYN,服务器以为是新连接又回 SYN+ACK。客户端反复重发,最终超时。
半连接队列满:服务器压力大或受 SYN Flood,半连接队列满,丢弃新 SYN。客户端超时后重试,可能成功也可能持续失败。
Wireshark 抓 "Connection refused":用 tcp.flags.reset == 1 看所有 RST 包,找到对应流。
一次完整握手的 TCP 状态机
TCP 连接两端各自维护状态机。客户端侧:
CLOSED→ 发 SYN →SYN_SENT→ 收 SYN+ACK → 发 ACK →ESTABLISHED
服务器侧:
CLOSED→ listen() →LISTEN→ 收 SYN → 发 SYN+ACK →SYN_RCVD→ 收 ACK →ESTABLISHED
Linux 用 netstat -tlnp 看 LISTEN 状态的服务端口。netstat -tnp 看所有 ESTABLISHED 连接。
$ netstat -tnp
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program
tcp 0 0 192.168.1.100:443 93.184.216.34:54321 ESTABLISHED 1234/nginx思考题
- TCP 为什么用三次握手而不是两次?设计一个两次握手的 TCP,然后说明它在哪种网络环境下会出错。
- 服务器 SYN_RCVD 状态会保留半连接资源。Linux 默认
tcp_max_syn_backlog是多少?这个值怎么调?(提示:sysctl、负载高的服务器怎么改) - SYN Cookie 让服务器不存半连接状态。但 SYN Cookie 的副作用是什么?现代服务器都用吗?
- 你打开网页
https://example.com,按 F12 看 Network 面板,Timing 里Initial Connection (TCP)大约几十毫秒。这个时间包含哪几段?
延伸阅读
- 《深入浅出计算机网络》(高军)第 5 章
- RFC 793: Transmission Control Protocol
- RFC 4987: TCP SYN Flooding Attacks and Common Mitigations
- 动手实验:
curl -v -4 --tcp-nodelay https://example.com看完整握手时间 - 动手实验:在 Linux
sysctl net.ipv4.tcp_max_syn_backlog看半连接队列上限