ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCP 三次握手为什么是三次:丢包、重传与初始序列号

TCP 三次握手为什么是三次:丢包、重传与初始序列号

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • TCP 三次握手为什么是三次:丢包、重传与初始序列号
    • 一、"为什么不是两次"才是真问题
    • 二、两次握手会造成什么后果
    • 三、每一次握手丢失会发生什么
      • 第 1 次 SYN 丢失
      • 第 2 次 SYN+ACK 丢失
      • 第 3 次 ACK 丢失
    • 四、初始序列号不是从 0 开始
    • 五、握手阶段的两个队列
    • 六、和握手相关的几个参数
    • 七、带走物

TCP 三次握手为什么是三次:丢包、重传与初始序列号

一、"为什么不是两次"才是真问题

大部分人对三次握手的解释是"确认双方的发送和接收能力都正常",这个说法不算错,但没有回答真正的问题:为什么两次不够?四次又太多?

真正的答案藏在**初始序列号(ISN)**上。

TCP 是可靠传输,每个字节都有序号。连接的初始序列号是双方各自随机生成的,而且必须让对方知道。所以:

  • 客户端要告诉服务端"我的 ISN 是 x" → 第 1 次(SYN)
  • 服务端要告诉客户端"我的 ISN 是 y,并且我收到你的 x 了" → 第 2 次(SYN+ACK)
  • 客户端要告诉服务端"我收到你的 y 了" → 第 3 次(ACK)

第 3 次不是可选的,它是服务端确认"自己的 ISN 被对方收到了"的唯一途径。少了这一步,服务端就不知道客户端到底收没收到自己的 SYN+ACK——如果那个包丢了,服务端会一直重传。

二、两次握手会造成什么后果

假设只有两次握手,服务端发完 SYN+ACK 就认为连接建立了、开始分配资源、准备发数据。

现在考虑这个场景:

1. 客户端发 SYN(seq=x),但这个包在网络里绕了很久 2. 客户端超时重传了一个 SYN,这次很快到了,连接建立、传完数据、关闭 3. 此时那个绕路的旧 SYN 终于到了服务端 4. 服务端以为是新连接,回 SYN+ACK,并(在两次握手的假设下)直接建立连接 5. 客户端收到后发现这是自己早就放弃的连接,直接忽略 6. 服务端这边却留下了一条永远不会被使用的连接

这条"半开连接"会占着服务端的内存和 fd,直到超时。如果有人伪造大量源 IP 发 SYN,服务端的半连接队列会被打满——这就是经典的SYN Flood攻击的原理。

三次握手把这个风险降低了:服务端在收到第 3 次 ACK 之前不分配完整资源(只放在半连接队列里),收不到 ACK 就会重传 SYN+ACK,重试几次后放弃。

三、每一次握手丢失会发生什么

这是面试和实践里都最有用的部分。

第 1 次 SYN 丢失

客户端发完 SYN,进入SYN_SENT,等不到 SYN+ACK 就超时重传。

# Linux 重试次数cat/proc/sys/net/ipv4/tcp_syn_retries# 默认 6

重试间隔是指数退避:1s、2s、4s、8s、16s、32s,总共约127 秒才放弃。这就是为什么connect()到一个不存在的 IP 有时会卡两分钟才报Connection timed out。

第 2 次 SYN+ACK 丢失

双方都会重传,但表现不同:

  • 服务端:没收到第 3 次 ACK,重传 SYN+ACK(次数由tcp_synack_retries控制,默认 5);
  • 客户端:没收到 SYN+ACK,重传 SYN(按tcp_syn_retries)。

服务端重传几次后会把这条半连接从队列里删掉。如果此时客户端的 SYN 才到,服务端会重新走一遍握手流程。

第 3 次 ACK 丢失

这是最有意思的一种。此时:

  • 客户端已经认为连接建立了(它发完 ACK 就进ESTABLISHED);
  • 服务端还没收到 ACK,仍在SYN_RCVD。

如果这时候客户端开始发数据会怎样?

数据包的 ACK 字段会带上服务端的 ISN+1,服务端收到后就确认了连接,直接进入ESTABLISHED。所以第 3 次握手丢失时,只要客户端随后发了数据,连接依然能建立起来。

如果客户端不发数据(比如一个只连不发的探测),服务端会一直重传 SYN+ACK 直到超时。

这就是为什么有些健康检查看起来"连得上"但服务端日志里没有连接记录——握手没走完,服务端没认为连接建立。

四、初始序列号不是从 0 开始

早期实现里 ISN 是随系统启动时间递增的,这带来安全问题:攻击者可以猜出 ISN,伪造 TCP 包做会话劫持。

现代 Linux 用的是基于时钟 + 连接四元组哈希的生成方式:

// 简化逻辑:ISN = 定时器计数 + 四元组哈希的偏移seq=secure_tcp_seq(saddr,daddr,sport,dport)

每 64ns 递增一次,对不同四元组有不同的随机偏移。这样 ISN 既不会在短时间内重复(避免旧连接的报文被新连接误收),又不容易被预测。

ISN 之所以要随机,还有一个原因:避免上一条连接的延迟报文被下一条相同四元组的新连接当作有效数据——这一点和TIME_WAIT存在的理由是一致的。

五、握手阶段的两个队列

服务端在三次握手期间维护两个队列,很多连接问题都出在这里:

收到 SYN → 半连接队列(SYN queue) ↓ 收到第 3 次 ACK 全连接队列(accept queue) → 应用 accept() 取走
# 全连接队列最大值(受 backlog 和这个参数共同限制)cat/proc/sys/net/core/somaxconn# 默认 4096(旧版 128)# 查看溢出情况netstat-s|grep-i"listen"# 12345 times the listen queue of a socket overflowed# 6789 SYNs to LISTEN sockets dropped

这两个计数持续增长就说明队列在溢出。

  • 半连接队列溢出 → 新 SYN 被丢弃,客户端表现为连接超时(这是 SYN Flood 的症状);
  • 全连接队列溢出 → 握手完成了但连接被丢,客户端可能收到 RST,也可能超时。

常见诱因:应用accept()太慢(比如 Go 服务在Accept之后做了耗时操作)、backlog设太小、突发流量。

排查:

# 看监听 socket 的当前队列长度ss-lnt# Recv-Q 是当前全连接队列已用,Send-Q 是最大值

六、和握手相关的几个参数

参数默认值作用
tcp_syn_retries6客户端 SYN 重试次数(约 127s)
tcp_synack_retries5服务端 SYN+ACK 重试次数
tcp_syncookies1SYN 队列溢出时用 syncookie 防御 SYN Flood
net.core.somaxconn4096全连接队列上限
tcp_abort_on_overflow0队列满时回 RST(1)还是丢包(0)

tcp_syncookies=1建议保持开启:它在半连接队列满时用一种无状态的方式完成握手,能有效抵御 SYN Flood,正常情况下不会生效,没有副作用。

而tcp_abort_on_overflow一般不要开:开了之后队列满时客户端会立刻收到 RST(快速失败),看起来"更干脆",但会让客户端把"服务端繁忙"误判成"服务不可用",反而影响重试策略。

七、带走物

回答"为什么是三次"的完整答案,三层递进:

  1. 表层:确认双方的收发能力;
  2. 真正的原因:双向初始序列号都要被确认——第 3 次 ACK 是服务端知道"自己的 ISN 被收到了"的唯一途径;
  3. 工程后果:两次握手的场景下,历史重复 SYN 会在服务端留下半开连接,是 SYN Flood 的根源。

再补两条实践要点:

  • 握手丢包时谁重传:第 1 次丢 → 客户端重传 SYN;第 2 次丢 → 双方都重传;第 3 次丢 → 服务端重传 SYN+ACK,但如果客户端紧接着发数据,数据里的 ACK 会自动补上握手;
  • 连接超时排查:connect()卡约 127 秒是tcp_syn_retries=6的表现;看netstat -s | grep listen判断是不是队列溢出。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进