当闲置并不等于闲置:解决 QUIC 拥塞控制死亡螺旋

拥塞控制算法 (CCAs) 是互联网中隐形的交通警察。它们管理发送方如何探测可用带宽、在丢包时退避以及如何恢复以最大化吞吐量。CUBIC 是 Linux 内核中默认的拥塞控制器,广泛应用于 TCP 和 QUIC 连接,以确保数据流高效传输而不导致网络崩溃。

然而,Cloudflare 开源的 QUIC 实现 quiche 最近揭示了一个危险的边缘情况:在这种情况下,拥塞窗口 (cwnd) 会永久锁定在其最小值,导致连接在经历严重丢包后无法恢复。这是一个关于原本出于好意的内核优化如何演变成性能死亡螺旋的故事。

症状:61% 的失败率

该问题最初出现在 Cloudflare 的入口代理集成测试中。团队观察到在连接初期发生严重丢包的场景下,会出现不稳定的失败。

在一个模拟测试中——在下载 10 MB 文件时,前两秒进行 30% 的随机丢包——CUBIC 理应在丢包阶段进行限速,并在网络稳定后重新增加速率。然而,大约 60% 的测试未能通过宽裕的 10 秒超时限制完成下载。

通过 qlog 进行的监测显示了一个惊人的异常:在两秒钟丢包完全停止后,cwnd 仍锁定在其 2,700 字节的最小底限(大约两个全尺寸数据包)。连接进入了快速振荡,每 ~14ms 就在“拥塞避免”和“恢复”状态之间切换——这几乎正好等于连接的往返时延 (RTT)。

根本原因:"闲置" 优化

要理解为什么窗口停止增长,我们必须研究 CUBIC 如何处理闲置期。

Linux 内核上下文

在 2017 年,Linux 内核的 CUBIC 实现中发现了一个 bug。当应用程序进入闲置状态并随后恢复发送时,距离上次增长周期 (epoch) 经过的时间可能非常长。这会导致一个巨大的 delta_t,导致 CUBIC 在恢复时计算出一个巨大的目标窗口并激进地膨胀 cwnd,从而可能导致立即发生拥塞。

为了修复这个问题,内核开发者实施了一种转变:不再重置 epoch,而是将 epoch 向前推进闲置期的持续时间。这在保留增长曲线形状的同时,考虑了传输中的间隙。

移植到 QUIC

当 Cloudflare 在 quiche 中实现 CUBIC 时,他们移植了这种闲置期调整机制。然而,由于 QUIC 在用户空间运行,它缺乏内核特有的 CA_EVENT_TX_START 回调。相反,quicheon_packet_sent() 函数内部检查闲置状态:

if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;
    self.congestion_recovery_start_time += delta;
}

死亡螺旋:当拥塞看起来像闲置

当三个条件同时满足时,该 bug 会被触发:已经发生丢包事件(设置了恢复边界)、连接处于拥塞避免模式,且 cwnd 已塌陷至其最小的两个数据包底限。

在这种最小窗口状态下,会发生“死亡螺旋”:

  1. 窗口排空: 发送方发送两个数据包。由于窗口非常小,管道被完全排空。经过一个 RTT 后,两个数据包都被 ACK 确认,bytes_in_flight 降至零。
  2. 错误闲置检测: 当发送下一个突发数据包时,on_packet_sent() 看到 bytes_in_flight == 0,并假设连接处于闲置状态。
  3. 膨胀的 Delta: 代码计算闲置时长为 now - last_sent_time。由于上次发送发生在约一个 RTT 之前,delta 约为 14ms。算法错误地将 RTT 视为“闲置时间”。
  4. 恢复陷阱: 这个 RTT 大小的 delta 被添加到恢复开始时间,将其推向未来。由于恢复开始时间现在领先于当前时间,系统认为自己仍处于恢复期。
  5. 停滞: CUBIC 在恢复期间会跳过 cwnd 增长。窗口保持在两个数据包,确保在下一个 ACK 到达时管道再次被排空,从而使该循环重复数千次。

修复方案:精确的闲置测量

解决方案是停止仅依赖于上次发送的数据包,转而从管道实际变为空的状态开始测量闲置——即处理最后一个 ACK 的时刻。

通过引入 last_ack_time 时间戳,团队可以利用最后一个 ACK 时间和上次发送时间中的最大值来计算闲置 delta。这确保了如果 bytes_in_flight 降至零仅仅是因为窗口很小,那么 delta 将保持接近于零,且恢复边界不会被推向未来。

let idle_start = cmp::max(cubic.last_ack_time, cubic.last_sent_time);
if let Some(idle_start) = idle_start {
    if idle_start < now {
        let delta = now - idle_start;
        r.congestion_recovery_start_time = Some(recovery_start_time + delta);
    }
}

经验教训与技术启示

这次事件为系统工程师提供了几个关键教训:

  • 移植的危险性: 正如社区观察者所指出的,从内核中复制逻辑而不完全跟踪后续的修复补丁,可能会将遗留 bug 引入新实现中。
  • "闲置" 的细微差别: 在高性能网络中,“闲置”是一个必须被精确定义的术语。由于拥塞窗口过小而导致的瞬时飞行数据量降至零,与应用层面的闲置期并不相同。
  • 边缘情况的可视化: 这个 bug 在标准吞吐量仪表板中是无法察觉的的,只有通过刻意地将 CCA 驱动至其最小窗口状态——这种状态在典型测试中很少被触及,但对于可靠性至关重要——才能使其显察现。

通过修复此逻辑,Cloudflare 恢复了其测试套件的 100% 通过率,确保 QUIC 连接可以在严重的拥塞崩溃后优雅地恢复,而不会陷入自我维持的陷阱。

Sources