当"空闲"并不空闲:一个 Linux 内核优化如何变成 QUIC 性能陷阱

2026-05-12 16 预计阅读时间:1 分钟
来源:blog.cloudflare.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

你用 QUIC 搭了一个高吞吐服务,压测时吞吐量看起来正常,但一旦进入稳态长连接——比如持续推送视频帧或批量数据——吞吐量突然跌到地板。抓包看没有丢包,RTT 也稳定,但 CUBIC 的拥塞窗口(cwnd)死死卡在最小值,怎么也涨不起来。

这不是网络问题,也不是你的应用逻辑有 bug。问题出在内核对"空闲"的判定逻辑上:它把 RTT 等待时间误判成了应用空闲,触发了 CUBIC 的窗口重置机制。

CUBIC 的空闲检测:初衷是好的

CUBIC 拥塞控制算法有一条规则:如果连接长时间没有发送数据,说明应用不再需要这么大的窗口。重新开始发送时,不应该立刻用旧的大窗口去冲击网络——毕竟网络状况可能已经变了。

RFC 8312 的建议是:空闲超过一个 RTT 后,cwnd 应该收缩到一个保守值(通常是 ssthresh),然后通过正常增长机制重新爬升。短空闲则不触发收缩。

这个逻辑在 TCP 场景下运行多年,基本没问题。因为 TCP 的"空闲"判定相对简单——应用不调用 send(),内核就不会往网络发包,时间戳差异清晰可辨。

QUIC 场景下的误判

QUIC 在用户态实现拥塞控制,但底层仍然依赖内核的 UDP 发送路径。问题出在一个微妙的时间差上:

发送一批数据后,应用在等待 ACK 返回的 RTT 期间不会主动发新数据。 这段等待是协议正常的节奏,不是应用空闲。但内核的空闲检测机制只看"有没有数据经过发送队列",不看"为什么没有数据"。

于是发生了这样的链条:

  1. 应用发送一批 QUIC 包,cwnd 正常增长。
  2. 等待一个 RTT,应用层没有新数据可发——内核标记为"空闲开始"。
  3. RTT 到了,ACK 回来了,但空闲检测已经判定"空闲超过阈值"。
  4. CUBIC 触发窗口收缩,cwnd 被钉到最小地板值(通常是 10 个 MSS 或更低)。
  5. 下一次发送只能用极小窗口,吞吐量暴跌。
  6. 小窗口导致更多 RTT 等待,又触发空闲判定——恶性循环,cwnd 永远涨不起来。

这就是标题说的:当"空闲"并不空闲。RTT 等待是协议正常行为,却被当成应用空闲处理,CUBIC 的保护机制变成了性能杀手。

用 ss 和 bpftrace 观察窗口钉死现象

如果你怀疑自己的 QUIC 实现遇到了这个问题,可以用以下方法观察 cwnd 的变化轨迹:

# 1. 用 ss 观察 UDP socket 的发送队列状态(需要内核 5.8+)
# 注意:ss 对 UDP 的信息有限,主要看 tx_queue 是否长时间为 0
ss -u -a -n | grep <your_quic_port>

# 2. 用 bpftrace 追踪 QUIC 实现中 cwnd 变量(假设用 Go + quic-go)
# 先找到 cwnd 更新函数的符号
bpftrace -e '
uprobe:/path/to/your_binary:"github.com/quic-go/quic-go.(*cubicSender).onPacketSent"
{
    printf("pid=%d time=%d ns\n", pid, nsecs);
}
'

# 3. 更实用的方式:在 QUIC 实现中加日志,每隔 100ms 打印 cwnd 值
# 如果看到 cwnd 反复从正常值跳到 10 左右,就是钉死现象

关键观察点:cwnd 是否在每次 RTT 周期后被重置到最小值,而不是按 CUBIC 曲线增长。

正确区分 RTT 等待与应用空闲

修复的核心思路是:空闲判定必须基于"应用是否有数据可发",而不是"网络是否有包在飞"。

具体做法是在拥塞控制状态中引入一个 app_limited 标记,精确记录应用受限的时刻:

# 伪代码:正确的空闲判定逻辑(基于 quic-go / chromium quic 的修复思路)

class CubicSender:
    def __init__(self):
        self.cwnd = INITIAL_CWND          # 初始拥塞窗口
        self.ssthresh = INITIAL_SSTHRESH   # 慢启动阈值
        self.last_app_limited_time = None  # 上次应用受限的时间戳
        self.rtt = ESTIMATED_RTT           # RTT 估计值

    def on_packet_sent(self, sent_time, bytes_sent):
        """发送包时更新状态"""
        # 如果发送后 cwnd 内的所有包都在飞,说明应用受限
        if self.bytes_in_flight + bytes_sent >= self.cwnd:
            self.last_app_limited_time = sent_time

    def on_ack_received(self, ack_time, acked_bytes):
        """收到 ACK 时判定是否应该收缩窗口"""
        # 关键修复:只有当应用真正空闲超过一个 RTT 才收缩
        # "应用空闲" = 上次 app_limited 之后,没有新数据被发送
        if self.last_app_limited_time is not None:
            idle_duration = ack_time - self.last_app_limited_time
            # 只有空闲超过一个 RTT 才认为是真正的应用空闲
            if idle_duration > self.rtt:
                # 保守收缩:降到 ssthresh 而不是最小地板值
                self.cwnd = max(self.ssthresh, MIN_CWND)
            else:
                # RTT 等待期间不算空闲,不收缩窗口
                pass  # cwnd 保持不变,继续正常增长
        self.last_app_limited_time = None  # 收到 ACK,不再受限

    def on_app_data_available(self, data_size, now):
        """应用有新数据要发时调用"""
        # 如果之前被判定为空闲,但现在应用立刻有数据,
        # 说明之前的"空闲"只是 RTT 等待,不应触发收缩
        if self.last_app_limited_time is not None:
            time_since_app_limited = now - self.last_app_limited_time
            if time_since_app_limited < self.rtt:
                # 取消之前的空闲判定
                self.last_app_limited_time = None

这段代码的关键改动有三点:

  1. app_limited_time 而不是"最后一次发包时间"来定义空闲起点。 只有当 cwnd 被用满后应用不再发数据,才算空闲开始。
  2. 空闲时长超过一个 RTT 才触发收缩。 RTT 等待通常小于一个 RTT(显而易见),所以不会被误判。
  3. 收缩目标设为 ssthresh 而不是硬地板值。 即使真的空闲了很久,也不应该把窗口钉死到 10 个 MSS,而是降到历史阈值,让 CUBIC 曲线较快恢复。

内核层面的关联优化

这个问题虽然出现在 QUIC 用户态实现中,但和 Linux 内核的 TCP 空闲优化有直接渊源。内核在 tcp_cubic 模块中有一个 tcp_cwnd_reduction 逻辑,在特定条件下也会做类似收缩。如果你同时跑 TCP 和 QUIC,可以检查内核参数避免 TCP 侧的类似问题:

# 查看 CUBIC 相关的内核参数
sysctl net.ipv4.tcp_congestion_control
# 输出应该是 cubic

# 禁止内核在空闲后做激进的窗口收缩
# tcp_no_metrics_save = 1 阻止内核缓存旧的 cwnd 指标
sysctl -w net.ipv4.tcp_no_metrics_save=1

# 如果你的 QUIC 实现跑在容器中,确保容器内核版本 >= 5.8
# 旧内核的 UDP 发送路径有 GSO 相关的性能问题,可能加剧 RTT 等待
uname -r

修复落地时的几个坑

实际修复这个 bug 时,有几个容易踩的细节:

坑一:app_limited 标记的时机不对。 如果只在"发送队列为空"时标记,会漏掉 cwnd 已满但应用还有数据排队的情况。正确做法是在 bytes_in_flight >= cwnd 时标记。

坑二:RTT 估计值不准。 如果用初始 RTT 做空闲判定阈值,在 RTT 波动时可能误判。应该用平滑后的 srtt(smoothed RTT),并且加一个安全系数,比如 idle_threshold = srtt * 1.5

坑三:收缩后恢复太慢。 即使正确区分了空闲和 RTT 等待,真正的应用空闲后恢复也不应该从最小值爬。CUBIC 的凹曲线增长在低窗口时极慢——从 10 个 MSS 恢复到 100 个 MSS 可能需要几十个 RTT。把收缩目标设为 ssthresh 而不是 MIN_CWND,恢复速度会快得多。

检查清单

如果你的 QUIC 服务出现吞吐量异常跌落,按这个顺序排查:

  1. 确认 cwnd 钉死:在拥塞控制模块加日志,打印每次 ACK 后的 cwnd 值,看是否反复跳到最小值。
  2. 确认是空闲误判:对比 cwnd 跌落的时间点和 RTT 等待周期,看是否每次跌落都发生在 ACK 间隔之后。
  3. 检查 app_limited 逻辑:确认空闲起点用的是"应用受限时间"而不是"最后一次发包时间"。
  4. 检查空闲阈值:确认判定阈值是平滑 RTT 而不是固定值。
  5. 检查收缩目标:确认收缩后 cwnd 降到 ssthresh 而不是硬编码的 MIN_CWND

这个 bug 的教训是:拥塞控制算法的每一条"保护性规则"都有它的假设前提。CUBIC 的空闲收缩假设"不发数据 = 应用不需要带宽",在 TCP 的内核态上下文中基本成立,但在 QUIC 的用户态实现中,RTT 等待打破了这条假设。移植算法时,不能只搬公式,必须重新审视每条规则的触发条件在新的执行环境中是否还成立。


相关推荐