TCP 拥塞控制:一场关于信任与试探的网络博弈

1988 年的那场崩溃

1986 年 10 月,互联网发生了一件让工程师们措手不及的事。

LBL(劳伦斯伯克利国家实验室)到 UC Berkeley 的网络吞吐量,从 32 Kbps 骤降到 40 bps——降幅超过 99.9%。没有任何硬件故障,没有任何人为破坏,就这么垮了。

这就是历史上第一次有记录的互联网拥塞崩溃(Congestion Collapse)

原因很简单:当时的 TCP 实现非常"自私"。每个发送方都尽可能快地把数据塞进网络,完全不管别人。当所有人同时这么干,路由器队列溢出,包被丢弃,发送方超时重传,重传的包再次把队列塞满——恶性循环,网络彻底瘫痪。

Van Jacobson,一位在 LBL 工作的工程师,用了不到一年时间写出了解决方案,发表于 1988 年 SIGCOMM。他的算法至今仍是所有现代 TCP 拥塞控制的基础。

要理解他的解法,我们得先回答一个更基本的问题。


发送方面临的根本困境

想象你在一条你完全不熟悉的路上开车,不知道路有多宽,不知道前方是否堵车,不知道自己能开多快。你只能通过观察来推断——前方没有红灯,那就继续;看到堵车了,踩刹车。

TCP 发送方面临的就是这个困境。

它不知道:

  • 网络路径的带宽是多少(10 Mbps?1 Gbps?)
  • 中间有多少个路由器
  • 当前网络有多拥挤
  • 接收方能接收多快(这个倒是知道,叫 rwnd

它唯一能感知网络状态的方式,是观察 ACK 的返回情况

收到 ACK = 数据送达了,网络还好。
ACK 迟迟不来 = 可能丢包了,网络可能出问题了。

整个 TCP 拥塞控制,本质上是用这一个信号构建出对网络状态的推断,然后据此调整发送速率。


核心变量:cwnd 是什么

TCP 用 cwnd(Congestion Window,拥塞窗口) 来控制"在途"(in-flight)数据量的上限,即已发送但尚未被确认的数据。

实际可发送量 = min(cwnd, rwnd)

rwnd 是接收方通告的缓冲区大小,cwnd 是发送方对网络容量的猜测

这个窗口机制的精妙在于:它把流量控制(对接收方负责)和拥塞控制(对网络负责)统一在一个框架里——两者都是在限制"管道里有多少数据"。

另一个关键变量是 ssthresh(Slow Start Threshold),它记录了"我上次在哪里撞墙",是慢启动和拥塞避免的分界线。初始值通常设为一个较大值(如 65535 字节),意思是"我还不知道在哪里会出问题"。


第一个问题:从多大开始发?

如果你是第一次走一条路,你会怎么开车?

你不会一脚油门踩到底,你会先慢慢走,感受一下路况。

这就是**慢启动(Slow Start)**的直觉来源。

慢启动:指数探测

连接刚建立时,cwnd = 1 MSS(最大报文段,通常 1460 字节)。

规则很简单:每收到一个 ACK,cwnd 加 1

这意味着:

  • 第 1 个 RTT:发 1 个包,收到 1 个 ACK → cwnd = 2
  • 第 2 个 RTT:发 2 个包,收到 2 个 ACK → cwnd = 4
  • 第 3 个 RTT:发 4 个包,收到 4 个 ACK → cwnd = 8

每过一个 RTT,窗口翻倍。 这是指数增长,不是慢增长。

“慢"的含义是相对于旧版 TCP 而言——旧版直接用接收方的窗口满速发,新版从 1 个包开始逐步探测。在 10 Gbps 的链路上,慢启动可能只需要几毫秒就能填满管道,一点都不慢。

cwnd
 32 |                          *
 16 |                   *
  8 |            *
  4 |      *
  2 |  *
  1 |*
    +--+--+--+--+--+--→ RTT

为什么需要慢启动?

因为你根本不知道网络能承受多少。如果一上来就用满 rwnd 发送,万一路径带宽只有 1 Mbps,你会瞬间打爆所有中间路由器的队列。指数增长是一种"快速但温和"的探测方式——它增长得足够快,不会浪费太多时间;但从小开始,不会一开始就造成冲击。


第二个问题:探到顶了怎么办?

指数增长不能永远持续,总会遇到瓶颈。

当 cwnd 增长到 ssthresh 时,TCP 认为"我已经接近上次的极限了,不能再激进了”,切换到更保守的策略。

拥塞避免:线性巡航

规则:每过一个 RTT,cwnd 加 1 MSS。

实现上通常是:每收到一个 ACK,cwnd += MSS × MSS / cwnd,累积起来等效于每 RTT +1。

cwnd
 20 |                    *
 18 |               *
 16 |← ssthresh  *
 14 |       *
 12 |  *
    +--+--+--+--+--+--→ RTT

这就是著名的**锯齿波(sawtooth)**的上升边。

为什么从指数切换到线性?

你开进了一条可能快要堵的路。在不确定的地方,你会从踩油门切换到慢慢挪——既不想停下来,又不想突然冲进堵车里。线性增长就是这种"谨慎探测剩余容量"的策略,每 RTT 只多试一点点,直到遇到拥塞信号。


第三个问题:遇到拥塞怎么反应?

现在,两种情况都可能发生:

情况 A:RTO 超时
发出去的包,等了很久都没有 ACK 回来,重传定时器超时。

情况 B:收到 3 个重复 ACK
连续收到 3 个对同一个序号的 ACK,说明接收方已经收到了后续的包,但有一个包没到。

这两种情况,TCP 对它们的解读是截然不同的。


情况 A:RTO 超时——“网络很可能已经崩了”

超时意味着:数据在网络里消失了,连重复 ACK 都没有。可能路由器队列已经满了,开始大量丢包。这是最严重的拥塞信号。

响应:

ssthresh = cwnd / 2   // 记住教训
cwnd = 1              // 回到起点
进入慢启动

这是 AIMD(Additive Increase, Multiplicative Decrease)中的 Multiplicative Decrease——把窗口砍半(记录到 ssthresh),然后从 1 重新探测。

为什么是"砍半"而不是"清零"?因为网络容量不是零,之前的 cwnd 大约是真实容量的两倍左右(刚好在崩溃边缘),所以 cwnd/2 是对真实容量的一个合理估计。


情况 B:3 个重复 ACK——“网络还活着,只是丢了一个包”

重复 ACK 是个微妙的信号。

为什么是 3 个?因为网络中的乱序很正常,1-2 个重复 ACK 可能只是数据包顺序调换了,不代表丢包。3 个重复 ACK 才是比较可靠的"确认丢包"信号。

而且,既然接收方还在发送 ACK,说明:

  1. 网络路径仍然畅通
  2. 后续的包已经到达接收方
  3. 只是某一个包丢了

这完全不像网络崩溃,更像是一次"意外的单包丢失"。

快速重传(Fast Retransmit):不等 RTO 超时,立即重传丢失的包。

快速恢复(Fast Recovery)

ssthresh = cwnd / 2
cwnd = ssthresh + 3   // +3 补偿那 3 个已到达但被挡在外面的包
// 继续等待更多重复 ACK,每个 +1
// 直到收到新的 ACK(确认重传成功)
cwnd = ssthresh
进入拥塞避免(不是慢启动!)

为什么快速恢复不需要回到慢启动?

这是 TCP Reno 相对于 TCP Tahoe 最重要的改进(Tahoe 在任何丢包后都回到 cwnd=1)。

道理很简单:既然管道还在工作,重复 ACK 还在来,说明网络的实际承载能力大约是 cwnd/2(ssthresh 的位置)。从这里直接线性恢复,比从 1 重新慢启动要聪明得多——避免了不必要的吞吐量断崖。

cwnd
  ↑
  |         /\
  |        /  \          /\
  |       /    \        /  \
  |      / 拥塞  \      /    
  |     / 避免   \----/  快速
  |----/  慢启动   ssth  恢复
  +----+---+---+---+---→ 时间

  Tahoe:遇丢包总回 cwnd=1
  Reno: 3个重复ACK只降到ssthresh

AIMD:整个机制的数学本质

慢启动、拥塞避免、快速恢复,这些是策略。背后有一个数学框架支撑它们:

AIMD:Additive Increase, Multiplicative Decrease

  • 没有拥塞时:cwnd 加法增加(+1/RTT)
  • 检测到拥塞时:cwnd 乘法减少(×0.5)

Van Jacobson 和 Chiu, Jain 在 1988 年证明:AIMD 是所有发送方都采用时,唯一能收敛到公平、高效平衡点的控制策略。

直觉上也很好理解:

  • 加法增加:慢慢试探,减少对其他用户的冲击
  • 乘法减少:遇到拥塞果断退让,让网络快速恢复

如果大家都这么做,每个人都在"探测 + 退让"的循环中找到公平的份额,网络也不会崩溃。这是一个分布式的、无需中心协调的拥塞控制机制——每个端点只看自己的 ACK,却涌现出全局的公平与稳定。


把整个流程串起来

假设你发起一个 HTTP 请求,下载一个文件:

阶段 1:连接建立,cwnd=1,ssthresh=65535
        → 慢启动开始:1, 2, 4, 8, 16...

阶段 2:cwnd 到达 ssthresh(或遇到丢包把 ssthresh 调小)
        → 拥塞避免:每 RTT +1

阶段 3a:RTO 超时(严重拥塞)
        → ssthresh = cwnd/2, cwnd = 1
        → 重回慢启动

阶段 3b:3 个重复 ACK(轻微丢包)
        → 快速重传丢失的包
        → ssthresh = cwnd/2, cwnd = ssthresh
        → 快速恢复 → 拥塞避免

用状态机来看:

            ┌──────────────────────────────┐
            │                              │
         连接建立                        RTO超时
         RTO超时                           │
            │                              │
            ▼                              │
    ┌──────────────┐    cwnd>=ssthresh     │
    │   慢 启 动   │──────────────────►───┐│
    └──────────────┘                      ││
                                          ▼│
                                  ┌──────────────┐
              3个重复ACK ◄─────── │  拥塞避免    │
              │                   └──────────────┘
              ▼
    ┌──────────────────┐    新ACK
    │  快速重传+恢复   │──────────────► 拥塞避免
    └──────────────────┘

现代 TCP 的演进

1988 年的 TCP Reno 解决了拥塞崩溃,但不完美。之后三十多年,工程师们针对各种场景持续改进:

TCP NewReno(1999):修复了 Reno 在一个窗口内多个包丢失时的性能问题,让快速恢复更健壮。

TCP CUBIC(Linux 默认,2006):用三次函数替代线性增长,对高带宽延迟积(BDP,如跨洲际链路)更友好,是目前 Linux 服务器的默认算法。

BBR(Google,2016):彻底改变了思路。不再用"丢包"作为拥塞信号,而是直接测量带宽RTT,估算瓶颈带宽,主动维持在最优发送速率而不是被动等待丢包。在高延迟或有一定随机丢包的网络(如无线网络、卫星链路)上,BBR 的表现远优于 CUBIC。

1
2
3
4
// Go 中,你可以通过 syscall 查看当前连接使用的拥塞控制算法
// 在 Linux 上:
// $ sysctl net.ipv4.tcp_congestion_control
// net.ipv4.tcp_congestion_control = bbr

为什么这些概念值得理解?

作为开发者,理解 TCP 拥塞控制能帮你解释很多"玄学"现象:

  • 为什么大文件传输开始很慢,后来越来越快? 慢启动阶段。
  • 为什么同一个文件,分 10 个连接下载比 1 个连接快? 每个连接独立地慢启动和拥塞控制,多连接能更快占满带宽。(这也是为什么 HTTP/2 推行多路复用的原因之一——减少连接数,用更智能的方式分配带宽。)
  • 为什么丢包率从 0% 到 1% 对吞吐量的影响远大于从 1% 到 2%? 因为 TCP 把每次丢包都当作拥塞信号,触发 AIMD 的乘法减少。
  • 为什么卫星网络(高延迟低丢包)的 TCP 性能很差? 高 RTT 意味着慢启动需要更多轮次才能填满管道,而且 ACK 反馈太慢,cwnd 增长极其缓慢。BBR 专门解决这个问题。

一句话总结

TCP 拥塞控制的本质是:在一个没有全局视图的分布式网络中,每个端点通过观察 ACK 的反馈,用"慢慢试探、遇墙退让"的策略,在自私与合作之间找到平衡,让整个网络既不崩溃,又尽量高效。

慢启动解决"从哪里开始",拥塞避免解决"接近极限时怎么走",快速重传/恢复解决"遇到小问题时怎么快速修复",AIMD 保证了所有人都这么做时系统的公平性与稳定性。

每一个概念,都是在回答一个具体的工程问题。


延伸阅读