quiche 吞吐量忽高忽低?调参三天,凶手竟是路径 MTU 探测
【免费下载链接】quiche🥧 Savoury implementation of the QUIC transport protocol and HTTP/3项目地址: https://gitcode.com/GitHub_Trending/qui/quiche
深夜两点,压测曲线又一次出现"心电图":RTT 稳稳当当趴在 5ms 上下,丢包率低到可以忽略,可吞吐量却每隔几十秒就断崖式跌到谷底,再缓慢爬升。拥塞窗口、接收窗口、TLS 会话复用、流调度……能调的参数全调了一遍,问题依旧。最后打开 qlog 逐帧翻包,才发现罪魁祸首藏在最容易被忽略的角落里——路径 MTU 探测(PMTUD)。
这个坑并非 quiche 独有,但 quiche 的实现方式决定了它踩坑时的表现格外"诡异"。本文结合 Cloudflare quiche 仓库中 pmtud.rs、path.rs 与 lib.rs 的真实实现,拆解"吞吐量忽高忽低"的完整因果链,并给出可落地的修复与预防方案。
症状:RTT 正常,吞吐却断崖式下跌
先说结论:PMTUD 出问题时,最典型的表象就是"RTT 一切正常、吞吐量周期性崩塌"。原因在于,MTU 探测失败影响的是"单包能装多少数据",而不是"链路往返多快"——前者决定一条连接在单位时间内能推进多少字节,后者只决定确认反馈的时延。
QUIC 默认以 1200 字节(RFC 9000 规定的最小数据报大小)作为发送上限,对应 quiche 中的MAX_SEND_UDP_PAYLOAD_SIZE: usize = 1200(lib.rs)。如果实际路径 MTU 是 1500,1200 意味着每个包白白浪费 300 字节的载荷空间——理论上吞吐上限就打了八折。而一旦 PMTUD 探测的"乐观假设"落空(见下文),发送端可能把上限压低到 1200 甚至更低,带宽利用率进一步恶化,表现为吞吐量瞬间塌方。
更迷惑的是:吞吐崩塌时网络本身是健康的。因为探测包被"黑洞"静默丢弃(不产生 ICMP 反馈),拥塞控制根本感知不到拥塞信号,RTT、丢包率这些常规指标全部正常。你盯着 Grafana 上优雅的 RTT 曲线,怎么也不会怀疑到 MTU 头上。
定位:探测失败如何与"MTU 黑洞"绑定
默认关闭,开启后却是"乐观二分"
quiche 的 PMTUD 实现基于 RFC 8899 的 DPLPMTUD(数据包层路径 MTU 发现),且默认关闭:Config::discover_pmtu()的文档明确写着 "The default value isfalse"(lib.rs)。很多人调参三天,问题根源恰恰是忘了开 PMTUD——一直以 1200 字节的"最低保障"发送,吞吐天花板被锁死。
开了之后,算法本身很激进:Pmtud::new()初始化时直接把probe_size设为maximum_supported_mtu(pmtud.rs),也就是"先按最大 MTU 打一发,失败了再二分回退"。update_probe_size()在最大成功与最小失败之间做二分,直到差值 ≤ 1 字节才收敛(pmtud.rs)。
这种"乐观探测"正是问题源头:它假设路径 MTU 接近甚至等于最大值。一旦路径上存在 MTU 黑洞(中间设备静默丢弃超过其 MTU 的数据报,且不回复 ICMP),探测包就会像掉进黑洞一样有去无回,PMTUD 只能靠"超时 + 连续失败"来推断探测失败。
黑洞下的失败判定:3 次机会,然后二分
黑洞场景下,探测包被丢弃,Ping { mtu_probe: Some(size) }帧永远不会被 ACK。quiche 在丢包检测中捕获这一事件(lib.rs):
frame::Frame::Ping { mtu_probe } => { // Ping frames are not retransmitted. if let Some(failed_probe) = mtu_probe { trace!("pmtud probe dropped: {failed_probe}"); ... pmtud.failed_probe(failed_probe);注意这行注释:Ping 帧不重传。这意味着探测包丢了就是丢了,只能靠下一次探测重新验证。failed_probe()内部维护probe_failure_count,默认MAX_PROBES_DEFAULT = 3次失败后才把一个尺寸判死(pmtud.rs):
if self.probe_failure_count < self.max_probes { // 未达上限,下一轮重试同尺寸 return; } // 达到上限,记录 smallest_failed_probe_size 并二分回退如果黑洞恰好丢弃了某个尺寸的探测包,而该尺寸其实小于真实路径 MTU(比如中间设备的黑洞恰好卡在某个特定包长区间),PMTUD 就会把真实的可用 MTU 误判为不可用,向下二分收敛到更小的值——吞吐量随之"锚定"在错误位置。这就是"探测定调"与"吞吐崩塌"之间的直接因果。
更糟的情况:探测与业务流量互相干扰
再叠加一个细节:quiche 的探测发送条件是should_send_pmtu_probe,要求握手已确认、cwnd_available() > probe_size、out_len >= probe_size、无待发帧(path.rs)。也就是说,探测包要与业务流量抢占拥塞窗口。在负载高峰期,cwnd 被业务数据占满,探测迟迟排不上队;而在流量低谷期探测成功拿到一个偏大的 PMTU,紧接着业务恢复、黑洞开始丢弃大包——先升后崩,吞吐曲线就呈现出"忽高忽低"的锯齿。
被低估的副作用:cwnd 跟着 PMTU 一起缩放
吞吐崩塌的第二个放大器在拥塞控制侧。quiche 在探测成功/失败后都会调用pmtud_update_max_datagram_size(),它会按新的 max_datagram_size 重算拥塞窗口(congestion/recovery.rs):
fn pmtud_update_max_datagram_size(&mut self, new_max_datagram_size: usize) { if self.cwnd() == self.max_datagram_size * self.congestion.initial_congestion_window_packets { self.congestion.congestion_window = new_max_datagram_size * self.congestion.initial_congestion_window_packets; } self.max_datagram_size = new_max_datagram_size; }CUBIC 的w_cubic()、cubic_k()等计算全部以max_datagram_size为单位(cubic.rs)。MTU 从 1500 掉到 1200,意味着 cwnd 的"字节数"被同比例砍掉,同样的 RTT 下带宽直接掉 20%——而这一切在 RTT 指标上几乎看不出任何异常。
修复与预防:探测参数、黑洞检测与回退策略
1. 先确认 PMTUD 真的开了
discover_pmtu(true)是第一步,也是被忽略最多的一步。对应 C API 为quiche_config_discover_pmtu()(ffi.rs)。同时,把发送缓冲区分片对齐到路径 MTU,避免out_len >= probe_size条件长期不满足、探测永远发不出去。
2. 调好max_probes:在误判与迟钝之间取平衡
set_pmtud_max_probes()控制"一个尺寸失败多少次才算失败"(lib.rs),默认 3(RFC 8899 §5.1.2)。
- 值太小(如 1):偶发随机丢包就会被当成 MTU 限制,探测结果剧烈抖动,吞吐忽高忽低最严重;
- 值太大:遇到真实 MTU 黑洞时回退迟钝,长时间卡在错误尺寸上。
quiche 自己用测试证明了 3 的合理性:pmtud_revalidation_tolerates_random_packet_loss显示,在 max_probes=3 下,前两次失败不影响判定,第三次成功即重置计数(pmtud.rs)。如果你的链路随机丢包率偏高,可适当上调到 4~5;对 MTU 黑洞敏感的场景则保持默认即可。C API 为quiche_config_set_pmtud_max_probes()(ffi.rs)。
3. 主动探测黑洞:利用 revalidate 机制做"体检"
quiche 提供了revalidate_pmtu():它会清除已确认的 PMTU,发一个该尺寸的验证探测包,一旦被丢弃就从零重启 PMTUD(lib.rs、pmtud.rs)。
pub fn revalidate_pmtu(&mut self) { if let Some(pmtu) = self.pmtu { self.set_probe_size(pmtu); self.pmtu = None; self.probe_failure_count = 0; self.largest_successful_probe_size = None; } }这正是"黑洞检测"的正确姿势:链路 MTU 会随路由、隧道封装、中间设备变化而改变,一次探测的结果不是永久的。建议对长连接周期性调用revalidate_pmtu()(比如每隔若干分钟或在带宽异常回落时触发),把 PMTU 更新事件PathEvent::PmtuUpdated(path.rs)接进监控,让"探测降级"不再无声无息。
另外,PMTUD 内部也自带一致性保护:当新失败尺寸 ≤ 已有成功尺寸时,说明路径状态已变化,会打印警告并restart_pmtud()(pmtud.rs),防止旧结果污染新探测——这是回退策略的最后一道保险。
4. 配置兜底:限制最大探测尺寸,别让乐观假设放飞
PMTUD 的探测上限是min(max_udp_payload_size, max_send_udp_payload_size)(path.rs)。如果你的路径明确不会超过 1500(比如 IP 层之上还有 GRE/VXLAN 等隧道),务必通过set_max_send_udp_payload_size()显式压低上限:
- 避免算法从 1450/1500 这种"危险尺寸"开始探测,减少黑洞触发的概率;
- 避免探测包自身因为超过中间设备 MTU 而被静默丢弃,导致误判。
这也是唯一能让"乐观二分"变得不那么乐观的配置手段——把搜索空间收敛到路径真实允许的范围内。
5. 让 qlog 替你说出真相
排查这类问题,qlog 是最高效的取证工具。探测包 ACK 时 quiche 会写入QuicEventType::MtuUpdated事件,携带 old/new 尺寸(lib.rs);丢包时则有 "pmtud probe dropped" 的 trace 日志。复盘时重点看三个时间点:
- MTU 尺寸发生变化的时刻;
- 该时刻前后 cwnd / max_datagram_size 是否同步缩放;
- 吞吐崩落曲线与上述事件是否对齐。
对齐了,基本可以锁定 PMTUD;对不上,再回去查拥塞控制也不迟。
结语
"吞吐量忽高忽低"这个症状背后,是一套完整的因果链:默认关闭的 PMTUD → 乐观二分探测 → MTU 黑洞静默丢包 → Ping 帧不重传、3 次失败即判死 → PMTU 被错误锚定 → max_datagram_size 与 cwnd 同步缩水 → 带宽利用率骤降。而这条链路上的每一步,都能在 quiche 源码中找到确凿对应的实现。
调参三天不如读码一小时。先discover_pmtu(true),再想清楚你的链路 MTU 真实值是多少,最后用revalidate_pmtu()把"黑洞"变成可观测、可自愈的事件——吞吐曲线自然就平稳了。
【免费下载链接】quiche🥧 Savoury implementation of the QUIC transport protocol and HTTP/3项目地址: https://gitcode.com/GitHub_Trending/qui/quiche
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考