Skip to content

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",这样发送方知道只丢了一段,可以只重传那一段,不用把后面的全部重发。

三、滑动窗口:流量控制 ​

已发已确认 | 已发未确认 | 可发未发 | 不可发
           |<------ 窗口大小(接收方通告)------>|

三个要点:

  1. 窗口大小由接收方决定,反映"我的接收缓冲区还剩多少"。这叫流量控制——防止快的发送方淹没慢的接收方。
  2. 窗口滑动:收到 ACK 后,窗口右移,新数据可以发。
  3. 零窗口:接收方缓冲区满了,通告 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 > 只重传丢失段)
          > 发多快?(窗口)
            > 窗口受谁限制?(接收方窗口 + 拥塞窗口,取小)
              > 拥塞窗口怎么变?(慢启动 / 拥塞避免 / 快恢复)

和其他知识的连接 ​

自测 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 分钟,中途不卡壳