蓝图 · 290 字 · 1 分钟阅读

TCP 拥塞控制:从 Nagle 到 BBR

四十年一直在回答同一个问题——当你看不见自己要经过的那张网络时,你该以多快的速度把包发出去?

#TL;DR

TCP 并不知道自己正发往什么样的网络。发送端看不见中间路由器的队列、看不见瓶颈链路的带宽、看不见同一条路径上还有多少其它流在抢带宽。它只能从两种信号去推断:被确认的包没被确认的包。这个推断问题自 1984 年 Nagle 写下第一篇拥塞控制论文以来,就一直是持续研究的对象,到 2026 年仍是公开问题。每一个 Linux 内核版本里,“你的连接如何在一张自己看不见的网上共享带宽”这件事都在被改。

#原始问题:拥塞崩溃

1986 年 10 月,互联网从 LBL 到加州大学伯克利分校的吞吐——也就几百码的线——从 32 kbit/s 掉到了 40 bit 每秒。三个数量级的崩塌。网络并没断,它还在转发包,只是没在转发任何有用的东西。

原因是反馈。早期 TCP 实现在检测到丢包时,会重传丢失的那个包——激进地、不退让地。当某台路由器因为队列满而开始丢包时,凡是包被丢掉的发送端都以更多的重传回应;队列溢出得更厉害,更多的包被丢,每一次重传都让问题更坏。

这就是拥塞崩溃。Van Jacobson 在 1988 年 SIGCOMM 的一篇论文里对它下了诊断,那篇论文大概是传输协议史上最重要的论文。Jacobson 的修法——也是此后所有 TCP 沿用的修法——是把发送端的发送速率耦合到从网络回来的信号上,并且在检测到丢包时减速,而不是加速。

#Nagle(1984):第一篇拥塞论文

四年之前,福特航天的 John Nagle 写了 RFC 896。它不用 “congestion control” 这个词,却引入了此后大部分的想法。Nagle 当时琢磨的是一个具体又尴尬的情形:一次 Telnet 会话跑在长距离链路上,每次发一个字节。

每个一字节的负载都变成了一整个 TCP/IP 包——40 字节包头、1 字节数据,回程还要再来一个 ACK 包。网络 98% 的工作都花在包头上了。在任何负载下,光是这部分开销就足以把链路搞拥塞。

Nagle 的算法说:只要还有未被确认的数据在飞,就不要发小包。 把小的写入先压一会,合并起来,等两种条件任一满足再发:(a) 凑够了值得一发的字节,或 (b) 在等的 ACK 回来了。这里头引入的观念是:发送端”要不要发”的决定,应该取决于网络的状态,而不只是取决于手上有没有字节。

def nagle_should_send(bytes_queued, max_segment_size, has_outstanding_unacked):
    if bytes_queued >= max_segment_size:
        return True
    if not has_outstanding_unacked:
        return True
    return False  # 按住,等 ACK 或更多数据

今天对延迟敏感的应用(交互式 SSH、小的 HTTP 请求)有时还会用 TCP_NODELAY 关掉 Nagle。权衡仍然是 Nagle 1984 年表述的那一个。

#Jacobson(1988):Slow Start、AIMD 与拥塞窗口

Jacobson 的 SIGCOMM 论文引入了四个想法,至今仍是 TCP 拥塞控制的核心。

拥塞窗口(cwnd)。 除接收端公告的窗口(接收端能缓存多少)之外,发送端再维护一个自己对”网络能吃多少”的估计。真正的发送窗口取两者的最小。cwnd 起始很小,根据反馈增长。

慢启动(Slow Start)。 cwnd 从一个包开始。每收到一个 ACK 就翻倍。这听起来慢——“慢启动”这个名字其实是开玩笑:它的增长是指数的——但让发送端可以在不一上来就承诺高速率的前提下,迅速探出网络的容量。

加性增加、乘性减少(AIMD)。 cwnd 越过某个阈值后,增长从指数切到线性:每 RTT 多一个包。一旦检测到丢包,cwnd 立即砍半(或更狠)。思路是:慢慢往上探是安全的,一旦冲过头代价很大,所以对”冲过头”的反应必须锐利。

cwnd

  │                                        ╱╲
  │                                       ╱  ╲
  │              ╱╲                     ╱      ╲
  │             ╱  ╲                  ╱          ╲
  │            ╱    ╲     ╲╱╲      ╱             
  │           ╱      ╲           ╱
  │          ╱        ╲       ╱
  │         ╱          ╲    ╱
  │        ╱            ╲ ╱
  │       ╱              ╳  ← 检测到丢包:cwnd 砍半
  │      ╱ (慢启动——指数)
  │     ╱
  │    ╱
  │   ╱
  └────────────────────────────────────────► 时间

快速重传 / 快速恢复。 与其等超时再判丢包(那要烧掉整整一个 RTT 的空转),发送端在看到三个重复 ACK——接收端连着三次为同一个缺失字节发的抱怨——时就推断出丢了。马上重传;不要把 cwnd 一路砍回 1。

这四个想法合在一起,把 TCP 从”拥塞把网搞崩”变成”几百条互相竞争的流能稳定到接近公平份额”。数学不显然,但扛得住:AIMD 是少数可证明能收敛到共享瓶颈上各流间公平的机制之一。

1988 年论文还引入了 RTT 估算——测量一条连接往返时间的算法,这样你才知道一个包要晚到什么程度才能假定它丢了。这件事本身就相当微妙;Jacobson 当年基于 EWMA 的估算器,现代 TCP 今天仍大致照搬。

#Reno、NewReno、SACK:1990 年代的迭代

1990 年代,Jacobson 原方案的各种变体被按研究所或实现冠名:

  • Tahoe — Jacobson 原始 TCP。任何丢包都把 cwnd 摔到 1 再重启慢启动。保守。
  • Reno — 加入快速恢复。单次丢包后让 cwnd 保持较高,不假设网络已经塌了。
  • NewReno — 同一窗口内多个丢包时行为更好。
  • SACK(Selective Acknowledgment,RFC 2018)— 让接收端精确告诉发送端哪些包缺了,而不是只说”我还在等字节 X”。今天每个现代 TCP 都在用它;没有 SACK,发送端只能瞎猜。

到 1990 年代末,TCP 差不多已经解决了 Jacobson 当年想解决的问题:避免拥塞崩溃、让类似网络上的流之间公平共享带宽。它还没有解决的是:当网络彼此相似时会怎样?

#CUBIC(2008):高带宽、高延迟链路

随着骨干链路从兆比特涨到吉比特、跨洲延迟却没变,AIMD 开始显得过于保守得让人尴尬。一条 10 Gbps、100 ms 的跨太平洋链路,要被吃满,需要一个巨大的 cwnd。Reno 每 RTT 线性 +1,要长到那个尺寸几乎要到天荒地老。

具体例子:10 Gbps、100 ms RTT 的 Reno 流,单次丢包后要 80 分钟左右才能把窗口填回来。这不是”网络在用它的容量”,这是”文件传输刚开始就掉一次包,然后这台机器利用午休时间把传完”。

CUBIC(Ha、Rhee、Xu,2008)把线性增长函数换成了三次函数。一次丢包之后,cwnd 起初长得慢(留在已知安全的附近),然后加速,再在上次丢包位置附近趋平。它能很快找到那个高带宽工作点,并稳定在那里,直到下一次丢包信号。

CUBIC 从内核 2.6.19(2006 年底)起就是 Linux 的默认。世界上大部分数据传输跑在它上面。

#BBR(2016):建模网络,而不是被动反应

上面的每一个算法——Reno、NewReno、CUBIC——都是基于丢包的。它们假设:只要你没丢包,就能发得更快。一旦路径里有大缓冲,这个假设就会崩。

**缓冲膨胀(bufferbloat)**是这种现象:便宜的家用路由器会有几百毫秒的缓冲。基于丢包的 TCP 会把这个缓冲先塞满才看到丢包,而此时 RTT 已经从 20 ms 胀到了 500 ms,cwnd 却还在爬。连接并不是”快丢包”意义上的拥塞——它是”以荒唐延迟在投递包”意义上的拥塞——而基于丢包的算法区分不出这两种情形。

BBR(Bottleneck Bandwidth and Round-trip propagation time),由 Google 的 Cardwell、Cheng、Gunn、Yeganeh 和 Jacobson(没错,还是那个 Jacobson)在 2016 年发表,走了一条完全不同的路:给网络建一个模型,按这个模型匀速发。

BBR 测两件事:

  • 瓶颈带宽(BtlBw) — 最近几秒观察到的最大吞吐。
  • 最小往返传播时间(RTprop) — 观察到的最小 RTT(物理路径,不被排队撑大)。

然后它按 cwnd = BtlBw × RTprop 的速率发。这就是带宽时延积——恰好能把瓶颈装满、又不堆队的在途数据量。BBR 会周期性向上探测,重新测 BtlBw,向下探测,重新测 RTprop,但大部分时间都在正确速率上巡航。

BBR 在缓冲膨胀的路径上大幅碾压 CUBIC。它在早期版本里也有公平性问题(BBRv1 对共享链路的 CUBIC 流偏激进),后来的 BBRv2、BBRv3 在一步步修。截至 2026 年,BBR 已在 Google 基础设施上大量部署,也作为非默认选项进了 Linux,仍是活跃研究领域。

#为什么到现在还在调

你可能会合理地问:既然 1988 年 Jacobson 就把拥塞崩溃解决了,为什么大家还在写相关论文?

  • 新链路类型一直在打破老假设。 卫星(Starlink)、移动 5G、数据中心以太网、有损 Wi-Fi——每一种都有不同的延迟、丢包和队列特征,暴露出老算法没考虑到的边角情形。
  • 数据中心网络在乎尾延迟,不在乎吞吐。 DCTCP、DCQCN 之类算法假设你可以用 ECN 标记(路由器发来的一比特通知)去信号拥塞,而不是等丢包。这是完全另一个优化目标。
  • 互联网上的 TCP 必须讲公平。 一个对你自家流量很香、但会饿死共享链路上其他流的算法,是不会被接纳的。现代拥塞控制大部分难点不在争吞吐,在证明公平性属性。
  • QUIC 改变了格局。 因为 QUIC 跑在用户态,拥塞控制算法可以靠升级一个二进制就上线,不必发个内核。迭代速度因此大幅加快。

TCP/IP 那篇文章用一段话把拥塞控制带过,说它”今天仍在被调”。确实如此——而且原因是根本性的。互联网是一个移动靶:新应用、新链路类型、新规模、新公平性要求。“我该以多快的速度发?”这个问题没有最终答案,因为问题本身在不断被重新定义。

真正扛得住的是 Jacobson 1988 年立下的那个骨架:用网络的反馈决定速率,上调要谨慎,出岔子就狠狠回落,并承认你对网络所能知道的只有它的 ACK 和丢包告诉你的。四十年后,每一个新算法还是在回答同一个问题——下一个包该怎么办?——只是带着稍微好一点的遥测数据,和稍微聪明一点的数学。