深色模式
03 · TCP 可靠传输与拥塞控制
握手只是"搭好了线"。这一篇讲线上怎么保证不丢、不乱、不把路堵死。 三个机制:确认重传(不丢)、滑动窗口(不乱 + 不浪费)、拥塞控制(不堵)。
一句话本质
TCP 把"不可靠的 IP 网络"包装成"可靠的字节流":每个字节编号、收到要确认、没确认就重传、发多少看窗口。
生活类比:寄一箱书
| TCP 机制 | 寄书场景 |
|---|---|
| 序号 | 每本书编号 1~100 |
| 确认号 | 收件人回"我收到第 1~37 本了" |
| 超时重传 | 等太久没回音,重寄 |
| 快速重传 | 对方连说三次"我只有前 37 本",说明 38 本丢了,立刻重寄 |
| 滑动窗口 | 收件人说"我书架只放得下 20 本,你先寄 20 本" |
| 拥塞控制 | 快递公司说"最近路上堵,你每天只能发 10 件" |
关键区别:滑动窗口是"收件人"限制的(怕你寄太快它收不下);拥塞控制是"路上"限制的(怕把网络堵死)。这两个经常被混,面试常考。
核心机制
一、可靠传输的三根支柱
① 序号:给每个字节编号,接收方靠它发现"缺了哪一段"
② 确认(ACK):接收方告诉发送方"我连续收到了前 N 个字节"
③ 重传:发送方发现"没被确认",就重发注意 ACK 是累积确认:回 ack=1001 表示"1~1000 我全收到了"。这就意味着只要中间缺一段,后面的全都要等(队头阻塞)。
二、重传的两种触发
① 超时重传(RTO)
发出去 > 启动定时器 > 超时还没收到 ACK > 重传
问题:RTO 怎么定?
太长:丢包后干等,慢
太短:没丢也重传,浪费带宽
做法:动态估算 RTT(加权平均 + 偏差),RTO = SRTT + 4 × RTTVAR(Jacobson 算法)
② 快速重传(Fast Retransmit)
收到 3 个重复 ACK(都 ack=1001)> 说明 1001 那段丢了
> 不等超时,立刻重传
为什么是 3 个:1~2 个可能只是乱序,3 个基本可以确定是丢了SACK(选择性确认):ACK 里带上"我还收到了 2000~3000",这样发送方知道只丢了一段,可以只重传那一段,不用把后面的全部重发。
三、滑动窗口:流量控制
已发已确认 | 已发未确认 | 可发未发 | 不可发
|<------ 窗口大小(接收方通告)------>|三个要点:
- 窗口大小由接收方决定,反映"我的接收缓冲区还剩多少"。这叫流量控制——防止快的发送方淹没慢的接收方。
- 窗口滑动:收到 ACK 后,窗口右移,新数据可以发。
- 零窗口:接收方缓冲区满了,通告
window=0,发送方停止发送,并周期性发窗口探测(避免死等,因为窗口更新的 ACK 可能丢)。
糊涂窗口综合征(SWS):如果接收方每次只腾出 1 字节就通告,发送方就每次只发 1 字节,效率极低。
- 接收方解法:窗口小于某阈值(min(MSS, 缓冲区一半))时,不通告,等攒够再说。
- 发送方解法:Nagle 算法——小数据攒够一个 MSS 或收到 ACK 再发。
四、Nagle 算法与延迟确认(两个"优化"打架)
Nagle:小包先攒着,等凑够 MSS 或收到前一个的 ACK 再发 > 减少小包数量
延迟确认:收到数据不立刻 ACK,等 200ms 看看有没有数据要捎带 > 减少 ACK 数量
冲突:Nagle 等 ACK 才发,延迟确认等 200ms 才回 ACK
> 每个小包都要多等 200ms > 典型的"40ms/200ms 延迟"问题解法:TCP_NODELAY 关掉 Nagle。对延迟敏感的协议(Redis、游戏、RPC)都应该关。
五、拥塞控制:四个阶段
拥塞控制维护一个 cwnd(拥塞窗口),实际发送窗口 = min(cwnd, 接收方通告窗口)。
cwnd
|
| ╱╲ ╱╲
| ╱ ╲ ╱ ╲
| 慢启动 ╱ ╲ 拥塞避免 ╱ ╲
| (指数)╱ ╲(线性+1) ╱ ╲
| ╱ ╲ ╱ ╲
| ╱ ╲____________╱ ╲___
| ╱ ^丢包>减半 ^超时>降到1
+------+----------------------------------------> 时间
^ ssthresh| 阶段 | 行为 | 触发 |
|---|---|---|
| 慢启动 | cwnd 从 1 开始,每收到一个 ACK 就 +1(指数增长,每个 RTT 翻倍) | 连接开始 / 超时后 |
| 拥塞避免 | cwnd 超过 ssthresh 后,每个 RTT +1(线性增长) | cwnd ≥ ssthresh |
| 快速重传 | 收到 3 个重复 ACK,立刻重传丢失段 | 3 dup ACK |
| 快速恢复 | ssthresh = cwnd/2,cwnd = ssthresh(不回到 1) | 快速重传后 |
关键区分:
超时(RTO) > 认为网络严重拥塞 > cwnd 直接降到 1,重新慢启动
3 个重复 ACK > 认为只是轻微丢包 > cwnd 减半,快速恢复(不回到 1)为什么区别对待:超时说明"连 ACK 都回不来",网络可能真的堵死了;重复 ACK 说明"后面的包能到,只是中间丢了一个",网络还好。
六、几代拥塞控制算法
| 算法 | 思路 | 问题 |
|---|---|---|
| Tahoe | 丢包就回 1 | 太保守 |
| Reno | 加入快速恢复 | 高延迟高带宽下增长太慢 |
| CUBIC(Linux 默认) | 用三次函数增长,与 RTT 无关 | 仍以"丢包"为拥塞信号 |
| BBR(Google) | 不看丢包,看带宽和 RTT,主动探测 | 需要更多工程调优,公平性有争议 |
为什么 BBR 是革命性的:传统算法把"丢包"当拥塞信号,但在高带宽、有一定丢包率的链路上(比如跨国、卫星),丢包不代表拥塞,传统算法会误判并降速。BBR 直接测量瓶颈带宽和最小 RTT,不依赖丢包。
为什么这么设计
反事实 1:如果没有滑动窗口
每发一个段就停下等 ACK(停等协议)。在 RTT=100ms 的链路上,每秒最多发 10 个段 × 1460 字节 = 14.6 KB/s。这就是"带宽再大也没用"的典型——吞吐量被 RTT 卡死。
滑动窗口让"在途数据"可以有很多,吞吐量 ≈ 窗口大小 / RTT。
反事实 2:如果没有拥塞控制
所有人全速发送 > 中间路由器队列溢出 > 大量丢包 > 所有人重传 > 更堵 > 拥塞崩溃(1986 年互联网真的发生过,吞吐量从 32Kbps 掉到 40bps)。
拥塞控制是"所有 TCP 连接的集体自律"——没有人强制,但大家都降速,网络才能用。
反事实 3:如果窗口只看接收方,不看网络
发送方按接收方的 1MB 窗口全速发,但中间链路只有 100Kbps > 路由器缓冲区被塞满 > 大量丢包 > 重传 > 更糟。所以必须同时受 cwnd 限制。
面试怎么问
Q:TCP 怎么保证可靠传输?
三条:序号(每个字节编号,能发现缺失)、确认(累积 ACK)、重传(超时 + 3 次重复 ACK 触发快速重传)。 加分:提到 SACK 减少不必要的重传,以及"可靠不等于不丢,是丢了能补"。
Q:流量控制和拥塞控制的区别?
流量控制是端到端的:接收方告诉发送方"我缓冲区还剩多少",防止发送方发太快导致接收方处理不过来。用滑动窗口实现。 拥塞控制是全局的:发送方推测"网络堵不堵",主动限速,防止把整个网络压垮。用cwnd实现。 一句话:一个怕对方收不下,一个怕路上堵死。实际发送窗口 = min(两者)。
Q:慢启动为什么叫"慢"?
因为它从 cwnd=1 开始(很小的窗口),但增长是指数的(每个 RTT 翻倍)。"慢"指的是起点低,不是速度慢。 这是经典陷阱题,答"因为它增长慢"就错了。
Q:超时重传和快速重传有什么区别?
超时是"连 ACK 都没回来",判断为严重拥塞,cwnd 降到 1。 快速重传是"收到 3 个重复 ACK",说明只是个别丢包,cwnd 减半即可。 区别对待的本质:一个是"路断了",一个是"掉了个包裹"。
追问链:
TCP 怎么保证不丢?
> 靠什么发现丢了?(序号 + 累积 ACK)
> 怎么触发重传?(超时 / 3 个重复 ACK)
> 3 个为什么不是 1 个?(乱序也会产生重复 ACK,3 个才足够确定)
> 重传全部还是只重传丢的?(SACK > 只重传丢失段)
> 发多快?(窗口)
> 窗口受谁限制?(接收方窗口 + 拥塞窗口,取小)
> 拥塞窗口怎么变?(慢启动 / 拥塞避免 / 快恢复)和其他知识的连接
- < 来自 TCP 连接管理:三次握手、四次挥手、TIME_WAIT:握手时协商 MSS 和初始窗口,是这里的基础
- > 影响 HTTP / HTTPS 与缓存:HTTP/2 的多路复用解决了"应用层队头阻塞",但 TCP 层的队头阻塞还在——这是 HTTP/3 改用 QUIC(基于 UDP)的根本原因
- > 影响 IO 模型与零拷贝:等设备的时候,CPU 在干什么:socket 缓冲区大小直接影响窗口通告值
- > 影响 工程实践:CDN 就近接入是为了减小 RTT(窗口增长更快);长肥管道(高带宽高延迟)场景下窗口大小是瓶颈
自测 3 问
Q1:为什么慢启动不叫"快启动"?
点开看答案
因为它从一个 MSS 的小窗口开始,起点很低。虽然增长速度是指数的(每个 RTT 翻倍),但名字强调的是"从很小的值慢慢起步"这个动作。
如果叫"快启动"会让人以为一上来就全速发——那就直接拥塞崩溃了。
顺便记:慢启动的"慢"是针对"初始窗口"而言,和"增长速度"无关。这是面试最爱的文字陷阱。
Q2:TCP 的队头阻塞是什么?为什么 HTTP/2 解决不了?
点开看答案
TCP 层:TCP 保证"按序交付"。如果第 1001~1460 字节的段丢了,即使后面 1461~100000 的段都到了,也必须等那一段重传成功才能交给应用层。这就是队头阻塞(HOL blocking)。
HTTP/2 的多路复用:在一个 TCP 连接上跑多个流,解决了 HTTP/1.1 的"应用层队头阻塞"(一个请求卡住不影响其他请求的发送)。
但:所有流共享同一个 TCP 连接,一旦底层丢了一个段,所有流都要等——TCP 层的队头阻塞没解决,反而因为多路复用而被放大了(一个丢包卡住所有请求)。
HTTP/3 的解法:改用 QUIC(基于 UDP),在用户态实现可靠传输,每个流独立排序,一个流丢包不影响其他流。
Q3:发送窗口到底由什么决定?
点开看答案
实际发送窗口 = min(接收方通告窗口 rwnd, 拥塞窗口 cwnd)rwnd由接收方根据自己缓冲区剩余空间决定,防止接收方被淹没。cwnd由发送方根据网络拥塞情况估算,防止网络被压垮。
两个都要满足:哪怕接收方说"我能收 1MB",如果网络只允许 10KB,也只能发 10KB;反过来,网络很空但接收方缓冲区满了,也得停。
这就是"端到端 + 全局"两层限制的交汇点。
类比失效处 / 易错点
- "寄书"类比会让人以为可以乱序收。TCP 对应用层是严格有序的字节流,乱序到达的段会在接收缓冲区里排队等待,不会交给应用。
- "可靠"不等于"一定送达"。TCP 保证"要么完整送达,要么连接断开让你知道",不保证"一定能送"。断网了它也没办法。
- 累积 ACK 的副作用:只回一个
ack=1001,发送方无法知道 1001 之后有没有收到——这就是 SACK 存在的理由。 - Nagle 和延迟确认是"两个优化互相打架"的经典案例。默认开启 Nagle 在某些场景会造成 40ms 延迟,所以对延迟敏感的服务要设
TCP_NODELAY。 - 拥塞控制是"礼貌",不是"强制"。不遵守的流(比如 UDP 洪水)会挤占遵守者的带宽——这是网络公平性的根本难题。
✅ 验收清单
- 能说出可靠传输的三根支柱(序号 / 确认 / 重传),并解释累积 ACK 的含义
- 能区分超时重传和快速重传的触发条件与不同处理(cwnd 降到 1 vs 减半)
- 能画出滑动窗口的示意,并说清"窗口由谁决定"
- 能准确说出流量控制和拥塞控制的区别(一个怕对方收不下,一个怕路上堵)
- 能解释慢启动为什么叫"慢"(起点低,不是增长慢)
- 能说出 TCP 队头阻塞的成因,以及 HTTP/2 为什么解决不了、HTTP/3 怎么解决
- 能解释 Nagle 与延迟确认的冲突,以及什么时候该关 Nagle
- 合上书,用自己的话讲 3 分钟,中途不卡壳