你用 QUIC 搭了一个高吞吐服务,压测时吞吐量看起来正常,但一旦进入稳态长连接——比如持续推送视频帧或批量数据——吞吐量突然跌到地板。抓包看没有丢包,RTT 也稳定,但 CUBIC 的拥塞窗口(cwnd)死死卡在最小值,怎么也涨不起来。
这不是网络问题,也不是你的应用逻辑有 bug。问题出在内核对"空闲"的判定逻辑上:它把 RTT 等待时间误判成了应用空闲,触发了 CUBIC 的窗口重置机制。
CUBIC 的空闲检测:初衷是好的
CUBIC 拥塞控制算法有一条规则:如果连接长时间没有发送数据,说明应用不再需要这么大的窗口。重新开始发送时,不应该立刻用旧的大窗口去冲击网络——毕竟网络状况可能已经变了。
RFC 8312 的建议是:空闲超过一个 RTT 后,cwnd 应该收缩到一个保守值(通常是 ssthresh),然后通过正常增长机制重新爬升。短空闲则不触发收缩。
这个逻辑在 TCP 场景下运行多年,基本没问题。因为 TCP 的"空闲"判定相对简单——应用不调用 send(),内核就不会往网络发包,时间戳差异清晰可辨。
QUIC 场景下的误判
QUIC 在用户态实现拥塞控制,但底层仍然依赖内核的 UDP 发送路径。问题出在一个微妙的时间差上:
发送一批数据后,应用在等待 ACK 返回的 RTT 期间不会主动发新数据。 这段等待是协议正常的节奏,不是应用空闲。但内核的空闲检测机制只看"有没有数据经过发送队列",不看"为什么没有数据"。
于是发生了这样的链条:
- 应用发送一批 QUIC 包,cwnd 正常增长。
- 等待一个 RTT,应用层没有新数据可发——内核标记为"空闲开始"。
- RTT 到了,ACK 回来了,但空闲检测已经判定"空闲超过阈值"。
- CUBIC 触发窗口收缩,cwnd 被钉到最小地板值(通常是 10 个 MSS 或更低)。
- 下一次发送只能用极小窗口,吞吐量暴跌。
- 小窗口导致更多 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
这段代码的关键改动有三点:
- 用
app_limited_time而不是"最后一次发包时间"来定义空闲起点。 只有当 cwnd 被用满后应用不再发数据,才算空闲开始。 - 空闲时长超过一个 RTT 才触发收缩。 RTT 等待通常小于一个 RTT(显而易见),所以不会被误判。
- 收缩目标设为
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 服务出现吞吐量异常跌落,按这个顺序排查:
- 确认 cwnd 钉死:在拥塞控制模块加日志,打印每次 ACK 后的 cwnd 值,看是否反复跳到最小值。
- 确认是空闲误判:对比 cwnd 跌落的时间点和 RTT 等待周期,看是否每次跌落都发生在 ACK 间隔之后。
- 检查 app_limited 逻辑:确认空闲起点用的是"应用受限时间"而不是"最后一次发包时间"。
- 检查空闲阈值:确认判定阈值是平滑 RTT 而不是固定值。
- 检查收缩目标:确认收缩后 cwnd 降到
ssthresh而不是硬编码的MIN_CWND。
这个 bug 的教训是:拥塞控制算法的每一条"保护性规则"都有它的假设前提。CUBIC 的空闲收缩假设"不发数据 = 应用不需要带宽",在 TCP 的内核态上下文中基本成立,但在 QUIC 的用户态实现中,RTT 等待打破了这条假设。移植算法时,不能只搬公式,必须重新审视每条规则的触发条件在新的执行环境中是否还成立。