·约 4 分钟阅读·DeepSeek

一个塞满的缓冲区,看起来和一条慢链路一模一样——这就是全部问题

所有人都默认网络缓冲区越大越安全。跑一下这个仿真:把缓冲区调大,它会把一条快链路变成一条「感觉上很慢」的链路,而吞吐数字一点都没变。

networkinglinux

下面实验室打开时,链路是打满的。队列是一条永不排空的高台,吞吐读数正好卡在瓶颈容量上。

现在把缓冲区滑块拖到 0。队列被排平,而吞吐读数一动不动。

这就是全部问题。一个塞满的缓冲区,和一条慢链路,给人的体验完全一样——视频通话卡住、SSH 一顿一顿、页面转圈——但只有其中一种会在吞吐曲线里显形。另一种,在你平常看的每一个指标里都是隐形的。

TCP 拥塞控制实验室

cwnd 随时间(上),瓶颈队列随时间(下)

cwnd(拥塞窗口)瓶颈队列
吞吐
—
队列
—

默认状态下队列就是满的。把缓冲区调小,它会排空;把 RTT 调大,锯齿会被拉长。

吞吐和延迟,是由两件不同的事决定的

看上面那个面板。锯齿是 cwnd,发送方的拥塞窗口:它一直涨,直到丢了包,然后减半,再涨。这就是加性增、乘性减(AIMD)——也正是为什么一次 TCP 传输的速率从来不是一个固定值,它是一个不断自我修正的猜测。

虚线是瓶颈的容量。注意锯齿大部分时间都在那条线之上。cwnd 高的时候,链路并没有在搬更多数据——它搬的还是那么多,把剩下的放进了队列。cwnd 超过容量不是额外吞吐,是库存。

所以这两个数字来源不同:

  • 吞吐由瓶颈封顶。发送方做什么都无法超过它,窗口再大也买不到更多。
  • 延迟由排队多少决定。窗口越大,排队的越多,而队列就是每个包要等的时间。

缓冲区这个旋钮,用延迟换来了什么也没有

把缓冲区调到 0,队列面板变平。丢包来自瓶颈本身,锯齿很规整,时延接近基础 RTT。

现在把缓冲区推上去。队列升起来,并且一直高着。链路依然以容量速率在服务——吞吐读数不会变——但每个包现在都要排在一打包后面。把它加到 RTT 上,就是用户真正感受到的那个数。这就是 bufferbloat:一个为了吸收突发而做大的缓冲区,被一个「因为没丢包所以继续加窗口」的发送方一直填满。

它之所以是个陷阱,是因为修复看起来像病因。缓冲区是为了防丢包而加的,而丢包正是拥塞的信号;把信号去掉,也就去掉了唯一在叫发送方后退的东西。所以现代的做法不是单纯把缓冲区改小,而是让它主动:在队列满之前就丢包或标记,让信号在还有余地反应的时候就到。

RTT 是整个环路跑的时钟

拖动 RTT 滑块,看锯齿被横向拉长。算法本身什么都没变,只是完成一个循环需要更久。这是长距离链路最让人意外的地方:同一份拥塞控制代码、同样的丢包率,传输行为却完全不同,因为反馈要 200 毫秒而不是 20 毫秒。

一条流能在途中的数据量被 cwnd 限制,而 cwnd 每个往返才涨一次。所以在高时延路径上,单条流无法快速填满一条快链路——不管窗口最终能涨多大,爬升要花很多个往返,而任何一次丢包都把它打回起点。这是带宽时延积(BDP)的问题,不是拥塞控制的 bug;也正是为什么一条长距离 TCP 连接,常常跑不过几条并行的连接。

这个模型做不到什么

我想把局限说清楚,因为一个看起来很有说服力的玩具,比没有玩具更糟。

它是每个往返走一步,不是每个包走一步。没有逐包计时、没有延迟 ACK、没有快速重传、没有 SACK、没有 pacing、没有接收窗口——所以它复现不出真实吞吐数字,我也不会拿它去算。丢包模型是一个基础概率加缓冲区溢出,足以塑造锯齿,但不足以建模真实的 AQM。而「类 CUBIC」确实只是类:真正的 CUBIC 有不同的窗口增长函数和自己的快速恢复行为。

它诚实复现的是几何关系:窗口会涨,容量封顶吞吐,队列变成延迟。这三条关系在真实网络里成立,也正是当一条链路「很慢」、但没有任何东西被打满时,你脑子里该有的东西。

如果你想要真正的机制——cwnd 怎么和接收窗口互动、内核的 socket 缓冲区到底住在哪——那是另一篇文章,而我写过:TCP 窗口与 socket 缓冲区 和 TCP 拥塞控制。

该先做的那一次测量

在怪链路之前,先测负载下的延迟,而不是负载下的吞吐。一条吞吐跑满、队列却始终空的链路,是快的。一条吞吐跑满、队列却始终满的链路,速度一样,而产品体验差得多——而且它永远不会出现在测速里,因为测速测的恰恰是那个没变的东西。

队列是没人画进图里的那个数,而它正是用户正在体验的东西。