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,说明:
- 网络路径仍然畅通
- 后续的包已经到达接收方
- 只是某一个包丢了
这完全不像网络崩溃,更像是一次"意外的单包丢失"。
快速重传(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。
| |
为什么这些概念值得理解?
作为开发者,理解 TCP 拥塞控制能帮你解释很多"玄学"现象:
- 为什么大文件传输开始很慢,后来越来越快? 慢启动阶段。
- 为什么同一个文件,分 10 个连接下载比 1 个连接快? 每个连接独立地慢启动和拥塞控制,多连接能更快占满带宽。(这也是为什么 HTTP/2 推行多路复用的原因之一——减少连接数,用更智能的方式分配带宽。)
- 为什么丢包率从 0% 到 1% 对吞吐量的影响远大于从 1% 到 2%? 因为 TCP 把每次丢包都当作拥塞信号,触发 AIMD 的乘法减少。
- 为什么卫星网络(高延迟低丢包)的 TCP 性能很差? 高 RTT 意味着慢启动需要更多轮次才能填满管道,而且 ACK 反馈太慢,cwnd 增长极其缓慢。BBR 专门解决这个问题。
一句话总结
TCP 拥塞控制的本质是:在一个没有全局视图的分布式网络中,每个端点通过观察 ACK 的反馈,用"慢慢试探、遇墙退让"的策略,在自私与合作之间找到平衡,让整个网络既不崩溃,又尽量高效。
慢启动解决"从哪里开始",拥塞避免解决"接近极限时怎么走",快速重传/恢复解决"遇到小问题时怎么快速修复",AIMD 保证了所有人都这么做时系统的公平性与稳定性。
每一个概念,都是在回答一个具体的工程问题。
延伸阅读
- Van Jacobson & Michael J. Karels, Congestion Avoidance and Control (SIGCOMM 1988) — 原始论文,可读性很高
- RFC 5681 — TCP Congestion Control 标准规范
- Neal Cardwell et al., BBR: Congestion-Based Congestion Control (ACM Queue 2016)
- TCP/IP Illustrated, Vol. 1 — W. Richard Stevens,网络协议圣经