一、三次握手
1.1 报文序列
客户端 服务器
│ │ (LISTEN)
│ ① SYN=1, Seq=x │
├───────────────────────────────────────────▶│
│ (SYN_SENT) │ (SYN_RCVD)
│ │
│ ② SYN=1, ACK=1, Seq=y, Ack=x+1 │
│◀───────────────────────────────────────────┤
│ (ESTABLISHED) │
│ │
│ ③ ACK=1, Seq=x+1, Ack=y+1 (可携带数据) │
├───────────────────────────────────────────▶│
│ │ (ESTABLISHED)
每一步在做什么:
| 步骤 | 客户端 → 服务器 | 服务器 → 客户端 |
|---|---|---|
| ① | 「我要连接,我的起始序号是 x」 | |
| ② | 「同意。我确认你的 x,我的起始序号是 y」 | |
| ③ | 「我确认你的 y」 |
⭐ **本质:双方各自宣告初始序号,并各自得到对方的确认。**一共需要 4 件事(发 x、确认 x、发 y、确认 y),但中间两件可以合并到一个报文段里——所以是 3 次而不是 4 次。
⚠️ SYN 和 FIN 各消耗一个序号(虽然它们不携带数据)。这就是确认号是 x+1 的原因。
1.2 为什么不能是两次握手 ⭐
这是面试和考试的经典题。
假设只握手两次(客户端发 SYN,服务器回 SYN-ACK 就算建立):
① 客户端发出 SYN(Seq=x)
⚠️ 这个 SYN 在某个路由器队列里滞留了很久
② 客户端超时,重发 SYN(Seq=x'),正常完成连接,传完数据,关闭
③ ⭐ 那个滞留的旧 SYN 终于到达服务器
④ 服务器以为是新连接,回 SYN-ACK,并【立即分配资源、进入 ESTABLISHED】
⑤ 客户端收到莫名其妙的 SYN-ACK,回 RST
结果:服务器为一个根本不存在的连接分配了资源(哪怕只有短暂时间)
更糟的是,若旧连接的数据分组也滞留后到达,
服务器会把它们当作这条"新连接"的数据接受 ❌
第三次握手的作用:让服务器确认客户端确实还在、并且认可这条连接。只有收到第三个包,服务器才最终确立连接。
📌 一般化的结论:在一个分组可能延迟、重复、乱序的网络上,要让双方就「连接已建立」达成一致,两次消息是不够的。(顺带一提,理论上任何有限次握手都无法在不可靠信道上达成完美共识——这就是著名的「两军问题」。三次握手是工程上足够好的近似。)
1.3 SYN 洪泛与 SYN Cookie
攻击:攻击者发送大量 SYN 但从不回第三个 ACK。
每个 SYN → 服务器分配一个 TCB(传输控制块)并进入 SYN_RCVD
→ 半连接队列被塞满
→ ⭐ 合法用户的 SYN 被丢弃
配合 IP 欺骗(第 4 讲),攻击者甚至不需要暴露真实地址。
防御:SYN Cookie(RFC 4987)
⭐ 核心思想:收到 SYN 时不分配任何资源。
服务器收到 SYN:
⭐ 不创建 TCB
把连接信息(四元组 + 时间戳 + MSS)用密钥做哈希
→ 得到一个特殊的初始序号 y = cookie
→ 回 SYN-ACK(Seq=cookie),然后【彻底忘掉这件事】
收到第三次握手的 ACK(Ack=cookie+1):
⭐ 验证 cookie 是否是自己算出来的
若是 → 此刻才创建 TCB,连接建立
若否 → 丢弃
为什么有效:服务器把状态「编码进了序号里」,交给客户端保管。无状态 = 无法被耗尽。
📌 这是一个极其漂亮的设计思想:把服务端状态转移到客户端携带的令牌中。同样的思路出现在 JWT、无状态 Session、以及 QUIC 的地址验证令牌中。
代价:无法使用某些需要在 SYN 阶段协商并保存的选项(部分实现下窗口缩放、SACK 会受影响),因此通常只在检测到攻击时才启用。
二、连接关闭
2.1 四次挥手
TCP 是全双工的,每个方向要各自关闭。
客户端 服务器
│ ① FIN=1, Seq=u │
├──────────────────────────────────────────▶│
│ (FIN_WAIT_1) │ (CLOSE_WAIT)
│ ② ACK=1, Ack=u+1 │
│◀──────────────────────────────────────────┤
│ (FIN_WAIT_2) │
│ │
│ ⭐ 服务器可能还有数据要发(半关闭) │
│ ←—————— 数据 —————————————————————————— │
│ │
│ ③ FIN=1, Seq=w, Ack=u+1 │
│◀──────────────────────────────────────────┤
│ (TIME_WAIT) │ (LAST_ACK)
│ ④ ACK=1, Ack=w+1 │
├──────────────────────────────────────────▶│
│ │ (CLOSED)
│ ⏳ 等待 2×MSL │
│ (CLOSED) │
⭐ 为什么关闭是 4 次而握手是 3 次?
因为握手时,服务器的 ACK 和 SYN 可以合并(它同时同意连接并宣告序号)。而关闭时,服务器收到 FIN 后可能还有数据没发完,所以必须先只回 ACK(「我知道你不发了」),等自己的数据发完再发 FIN(「我也不发了」)。
📌 这中间的状态叫「半关闭」(half-close):一个方向已关,另一个方向还能传数据。
2.2 TIME_WAIT 与 2MSL ⭐⭐
主动关闭的一方进入 TIME_WAIT,等待 2×MSL(MSL = Maximum Segment Lifetime,Linux 默认 30 秒,所以 TIME_WAIT 通常 60 秒)。
为什么必须等?两个理由,都要能说出来:
理由 1:保证最后那个 ACK 能到达对方
若第 ④ 个 ACK 丢失:
服务器收不到 → 超时重传 FIN
⭐ 客户端还在 TIME_WAIT,能收到并重发 ACK
若客户端已经 CLOSED → 它会回一个 RST
→ 服务器认为连接异常终止,可能丢失最后的数据 ❌
理由 2:让旧连接的残留报文在网络中消亡
若立刻用同样的四元组建立新连接:
⭐ 旧连接滞留在网络中的报文段可能突然到达
→ 序号恰好落在新连接的窗口内 → 被当作新连接的数据接受 ❌
(这称为"老化的重复报文段"问题)
等待 2MSL 保证:任何旧报文段都已经超过生存期而被丢弃
为什么是 2 倍 MSL 而不是 1 倍?
最坏情况:
最后的 ACK 走完全程需要 1 个 MSL
对方没收到,重传的 FIN 走回来又需要 1 个 MSL
→ 共 2 个 MSL
2.3 TIME_WAIT 的工程麻烦
⚠️ 一台高并发的服务器(尤其是主动关闭连接的一方,如反向代理连接后端)会积累几万条 TIME_WAIT,耗尽本地端口。
| 手段 | 评价 |
|---|---|
SO_REUSEADDR |
✅ 允许 bind 复用地址(第 9 讲的坑 4) |
tcp_tw_reuse |
✅ 在有 Timestamps 时安全复用 TIME_WAIT 连接 |
| 用长连接、减少连接建立 | ✅✅ 根本解法 |
tcp_tw_recycle |
❌ 已从 Linux 移除,在 NAT 后会造成连接失败 |
缩小 tcp_fin_timeout |
⚠️ 治标,且有风险 |
📌 **看到大量 TIME_WAIT 首先要问的是:为什么这里在频繁建立和关闭短连接?**这通常是架构问题,不是内核参数问题。
2.4 RST:异常终止
RST 的常见触发:
• 连接到一个没有进程监听的端口 → 立即回 RST(这就是"connection refused")
• 收到与任何连接都不匹配的报文段
• 应用调用 close 时接收缓冲区还有未读数据
• SO_LINGER 设为 0 后 close
⚠️ RST 与 FIN 的区别:FIN 是有序的、优雅的关闭,缓冲区中的数据会被发完;RST 是立即的、粗暴的中止,未发送的数据被丢弃,对端收到会报 Connection reset by peer。
三、流量控制
3.1 要解决什么问题
⭐ 流量控制解决的是「发送方发得太快,接收方来不及处理」的问题。
⚠️ 不要与拥塞控制混淆(这是最常见的概念错误):
| 流量控制 | 拥塞控制 | |
|---|---|---|
| 保护谁 | ⭐ 接收方(缓冲区不溢出) | ⭐ 网络(路由器队列不溢出) |
| 信息来源 | 接收方明确告知(rwnd 字段) | 发送方推断(丢包、时延) |
| 参与方 | 两个端点之间 | 所有共享路径的连接 |
| 讲次 | 本讲 | 第 15–16 讲 |
3.2 接收窗口 rwnd
接收方在每个报文段的头部里告知自己的剩余缓冲区:
接收缓冲区 RcvBuffer
┌────────────────────────────────────────────┐
│ 已交付给应用 │ 已收到未读取 │ 空闲空间 │
└────────────────────────────────────────────┘
◀──── rwnd ────▶
rwnd = RcvBuffer − (LastByteRcvd − LastByteRead)
发送方遵守:
LastByteSent − LastByteAcked ≤ rwnd
(在途未确认数据量 ≤ 接收方剩余空间)
3.3 零窗口死锁 ⭐
问题:
接收方缓冲区满 → 通告 rwnd = 0
发送方停止发送
⭐ 应用读走了数据,缓冲区空了 → 接收方想通告 rwnd = 5000
但接收方【没有数据要发】,因此不会发出任何报文段
→ 发送方永远收不到窗口更新
→ 双方永久卡死 ❌
解法:坚持定时器 + 窗口探测(Window Probe)
发送方收到 rwnd=0 后,启动坚持定时器
定时器到期 → 发送一个只含 1 字节数据的探测报文段
接收方必须回一个 ACK,其中携带当前的 rwnd
→ 若仍为 0,加倍定时器再试;若已非 0,恢复发送
📌 设计思想:**不要依赖对方主动通知你状态变化,要能主动去问。**这条原则在分布式系统里普遍适用(心跳、轮询、租约)。
3.4 窗口缩放(Window Scale)
rwnd 字段只有 16 位 → 最大 65535 字节 ≈ 64 KB。
回顾第 3 讲的 BDP:一条 1 Gbps、RTT 100 ms 的链路,BDP = 12.5 MB。64 KB 只能跑出:
65535 × 8 / 0.1 s ≈ 5.2 Mbps ← 1 Gbps 链路的 0.5%
窗口缩放选项(RFC 7323):在三次握手时协商一个移位因子 S(0–14),之后真实窗口 = rwnd << S,最大可达 1 GB。
⚠️ 两个要点:
- 只能在握手时协商,连接建立后无法启用
- 必须双方都支持,否则不生效
📌 这是"长肥管道"(Long Fat Network)性能问题的头号原因。做跨洲高速传输时,第一件要检查的事就是窗口缩放是否启用。
四、Nagle 算法与延迟确认:一对坏搭档
4.1 Nagle 算法
问题:Telnet/SSH 每敲一个键就发一个 41 字节的包(1 字节数据 + 40 字节头部),开销 97.5%。
Nagle 算法(RFC 896):
若有未确认的数据在途中:
⭐ 把新的小数据攒起来,不立即发送
等收到 ACK 后,把攒的数据一次发出
否则:立即发送
效果:网络越慢(RTT 越大),攒得越多——这是一个自适应的机制。
4.2 延迟确认(Delayed ACK)
问题:纯 ACK 包(40 字节,无数据)浪费带宽。
做法:收到数据后不立即确认,等待最多 200 ms,希望:
- 有反向数据可以捎带确认,或
- 又来一个报文段,可以用一个 ACK 一起确认
4.3 二者相遇:40–500 ms 的神秘延迟 ⭐
这是一个真实的、经典的、至今仍在坑人的性能问题。
发送方(开了 Nagle) 接收方(开了延迟确认)
│
├─ 发送小包 #1 ─────────────────▶ 收到
│ ⭐ 延迟确认:等 200ms 看看有没有别的
│ ⭐ Nagle:有未确认数据,
│ 小包 #2 先攒着不发
│
│ ⏳ 双方互相等待 ⏳
│
│◀──────── 200ms 后的 ACK ───────┤
├─ 发送小包 #2 ─────────────────▶
结果:一个本该几毫秒完成的请求-响应,变成了 200 ms 甚至 500 ms。
症状识别:延迟精确地是 40 ms 或 200 ms 的整数倍,且只在小包、请求-响应模式下出现。
解法:
| 方案 | 说明 |
|---|---|
⭐ TCP_NODELAY |
关闭 Nagle。绝大多数 RPC 框架、数据库客户端、游戏服务器默认这么做 |
| 应用层合并写 | 把 header 和 body 一次 writev/sendall 发出,避免产生连续小包 |
TCP_QUICKACK |
Linux 特有,临时关闭延迟确认(不持久,需反复设置) |
📌 教训:**两个各自合理的优化,组合起来可能产生严重的负面交互。**这在系统设计中极其常见,也是为什么「局部最优 ≠ 全局最优」在网络协议里是一条真理。
五、例题(Worked Example)
题目:主机 A 与主机 B 建立 TCP 连接。A 的 ISN = 10000,B 的 ISN = 50000。B 通告接收缓冲区大小 RcvBuffer = 8000 字节,MSS = 1000。
(a) 写出三次握手三个报文段的 Seq 和 Ack。 (b) 握手完成后,A 立即发送 6 个满载报文段。B 的应用一直没有读取数据。B 在收到第几个报文段后会通告 rwnd = 0? (c) 此后 A 应该怎么办? (d) 若 B 的应用随后读走了 3000 字节,但 B 恰好没有数据要发给 A,会发生什么?
解答:
(a)
① A→B: SYN, Seq=10000, (无 Ack)
② B→A: SYN+ACK, Seq=50000, Ack=10001
③ A→B: ACK, Seq=10001, Ack=50001
(SYN 消耗一个序号,所以确认号是 ISN+1。)
(b) B 的缓冲区 8000 字节、应用不读取,因此每收到一个满载报文段,rwnd 就减少 1000:
收到 seg1 (1000B) → rwnd = 7000
收到 seg2 → rwnd = 6000
收到 seg3 → rwnd = 5000
...
收到 seg6 → rwnd = 2000 ← 题目中 A 发的 6 个报文段发完
收到 seg7 → rwnd = 1000
收到 seg8 → rwnd = 0 ⭐
握手时 B 通告 rwnd = 8000,因此 A 在收到任何窗口更新之前,最多只能发出 8000 字节即 8 个报文段。发完题目所说的 6 个后 rwnd 仍有 2000,A 还可以再发 2 个;第 8 个报文段到达后,rwnd 降为 0。
(c) A 必须停止发送数据,并启动坚持定时器,定期发送 1 字节的窗口探测报文段。
(d) B 的 rwnd 变成 3000,但因为没有数据要发,B 不会主动发出任何报文段——A 无从得知窗口已经打开。这正是零窗口死锁。
打破死锁的是 A 的窗口探测:探测报文段到达后,B 必须回一个 ACK,该 ACK 携带 rwnd = 3000(或 2999,取决于探测字节是否被接收),A 随即恢复发送。
📌 注意责任分配:解决死锁的责任在发送方,而不是接收方。协议设计上,把责任放在「有动力去解决问题的那一方」是一条通用原则——发送方想把数据发出去,所以让它去探测。
六、随堂自测
- 为什么握手是三次而不是两次?给出一个两次握手会出错的具体场景。
- 为什么关闭需要四次而握手只要三次?
- TIME_WAIT 存在的两个理由是什么?为什么是 2×MSL?
- 服务器上出现大量 TIME_WAIT,说明什么?根本解法是什么?
- FIN 和 RST 的区别是什么?
- 流量控制和拥塞控制的区别是什么?各保护谁?
- 零窗口死锁怎么产生,怎么打破?为什么责任在发送方?
- 一条 10 Gbps、RTT 80 ms 的链路,不启用窗口缩放的最大吞吐量是多少?
- Nagle 算法 + 延迟确认导致的症状是什么?怎么解决?
七、本讲要点回顾
- 三次握手:双方各自宣告 ISN 并各自得到确认;第三次是为了让服务器确认客户端仍在。
- SYN 和 FIN 各消耗一个序号。
- SYN Cookie:把连接状态编码进初始序号,无状态因而无法被耗尽。
- 四次挥手:因为可能有半关闭,服务器的 ACK 和 FIN 不能合并。
- TIME_WAIT = 2MSL:① 保证最后的 ACK 送达;② 让旧连接的残留报文消亡。
- 流量控制保护接收方,拥塞控制保护网络。
- rwnd 16 位 = 64 KB 上限,长肥管道必须启用窗口缩放。
- 零窗口死锁靠发送方的坚持定时器 + 窗口探测打破。
- Nagle + 延迟确认 = 200 ms 神秘延迟,解法是
TCP_NODELAY。
八、自测答案
1. 因为在允许分组延迟、重复、乱序的网络上,两次消息不足以让双方都确认连接的建立。具体场景:客户端的一个旧 SYN 在网络中长期滞留,客户端早已超时重连、完成传输并关闭;滞留的 SYN 随后到达服务器,两次握手下服务器会立即认为连接建立并分配资源、进入 ESTABLISHED,甚至可能接受随后到达的旧数据分组。第三次握手让服务器确认「客户端确实现在还想连」,从而排除这种情况。
2. 握手时服务器的「确认对方 SYN」与「发出自己的 SYN」可以合并成一个 SYN-ACK。关闭时服务器收到 FIN 后可能还有数据没发完,必须先只回 ACK(表示「知道你不发了」),等自己的数据发完后再发 FIN。这中间的状态就是半关闭。
3. ① 保证自己发出的最后一个 ACK 能可靠送达——若它丢失,对方会重传 FIN,处于 TIME_WAIT 的一方能收到并重发 ACK;若已 CLOSED 则会回 RST,导致对方异常终止。② 让本连接残留在网络中的旧报文段全部消亡,避免它们被序号相邻的新连接误接受。2×MSL 的由来:最后的 ACK 最多走 1 个 MSL,对方重传的 FIN 再走 1 个 MSL 回来。
4. 说明该服务器是大量连接的主动关闭方,且在频繁地建立/关闭短连接(典型场景:反向代理到后端用短连接、或客户端每次请求都新建连接)。根本解法是改用长连接/连接池减少连接建立的次数;SO_REUSEADDR、tcp_tw_reuse 是缓解手段,而 tcp_tw_recycle 已被移除且在 NAT 环境下有害。
5. FIN 是优雅关闭:它在字节流中有序,之前排队的数据会被正常发送和确认,双方各自关闭自己的发送方向。RST 是异常中止:立即终止连接,未发送和未读取的数据被丢弃,对端的读写立即报 Connection reset by peer,且不需要对方确认。
6. 流量控制保护接收方,防止其接收缓冲区溢出,信息由接收方通过 rwnd 字段明确告知。拥塞控制保护网络,防止路由器队列溢出,发送方只能通过丢包、时延等信号间接推断网络状态。两者同时生效,实际发送窗口取二者的较小值。
7. 接收方缓冲区满,通告 rwnd=0;发送方停发。随后接收方的应用读走数据、窗口打开,但接收方没有数据要发,因而不会发出任何报文段告知这一变化,双方永久等待。打破方式:发送方的坚持定时器到期后发送 1 字节的窗口探测报文段,强制接收方回一个携带最新 rwnd 的 ACK。责任在发送方,是因为发送方才是有动力解决这个问题的一方(它想把数据发出去),把责任交给它更可靠。
8.
最大在途数据 = 65535 字节
最大吞吐量 = 65535 × 8 / 0.08 s ≈ 6.55 Mbps
利用率 = 6.55 Mbps / 10 Gbps ≈ 0.066%
必须启用窗口缩放(所需窗口 ≈ 10 Gbps × 0.08 s / 8 = 100 MB)。
9. 症状是请求-响应模式下出现精确的 40 ms / 200 ms 量级延迟,且只在发送小包时出现:发送方因 Nagle 攒着第二个小包不发,接收方因延迟确认压着 ACK 不回,双方互等到延迟确认超时。解法首选 TCP_NODELAY 关闭 Nagle;其次是在应用层把多次小写合并为一次写(writev/一次性拼装缓冲区),从源头上不产生连续小包。