Skip to content

Latest commit

 

History

History
182 lines (135 loc) · 10.9 KB

File metadata and controls

182 lines (135 loc) · 10.9 KB

单流吞吐排查复盘:从 QUIC datagram 到裸 UDP

时间:2026-06-20 ~ 2026-06-21 分支:quic

记录一次「隧道单条 TCP 流吞吐上不去」的完整排查过程、最终根因、解决方案,以及从中 提炼出的调试网络服务的通用经验。后半部分(调试经验)与具体项目无关,可作为通法参考。


1. 问题

qtun 隧道单条 TCP 流(如一条 iperf)吞吐长期卡在 ~20Mbps,而:

  • 直连(不走隧道)iperf 在同一路径上远高于此,且重传很少;
  • 走隧道时重传一度很多、RTT 不断累积。

诉求:把单流吞吐打满。(多流聚合可以靠多连接堆上去,但那不是我们要的。)


2. 排查时间线

Day 1:从「可靠 stream」到「QUIC datagram」

动作 结果 / 结论
调大各级缓冲区 无改善 → 不是缓冲不足
怀疑串行处理、观察到 3000+ 次空闲唤醒 方向对但非根因
分析 pprof、观察重传 直连重传少、走隧道重传爆多 → 问题在隧道,不在路径
threads=1 / 关加密 对照 重传依旧 → 不是并发数、不是加密
把数据面从可靠有序 stream 改为 QUIC DATAGRAM 重传只在开头高、RTT 不再飙升;但带宽仍 ~20Mbps(比原来略好)
加 flowhash(五元组流亲和) 多连接聚合吞吐上去了,但单流更差 → 做成开关,默认关

Day 1 的关键转折:内层 TCP 套在可靠有序的 QUIC stream 里,会触发「队头阻塞 + 双重 重传」——线路上极小的丢包被 QUIC 在它那层重传时阻塞整条流,内层 TCP 随即超时重传,吞吐被 打垮。改用不可靠 datagram 后,隧道表现得像一条真实会丢包的链路,丢包恢复交还给内层 TCP,消除了双重重传与 HoL。RTT 稳了,但单流带宽仍没起来——遗留到 Day 2。

Day 2:定位「QUIC 层在无丢包地限速」并换裸 UDP

动作 结果 / 结论
用算术先证伪 CPU 假说 20Mbps@1280B ≈ 2000 pkt/s;单核 AES-GCM 能跑几 GB/s(差 ~1000×)→ 不可能是 CPU/加密/protobuf
看 pprof(pprof.qtun.samples.cpu.001 总采样仅 35% CPU,AES-GCM/marshal 不在 top25,61% 在 syscall,大量 cond_wait/usleep被阻塞/空闲,不是算力瓶颈
综合证据:低重传 + RTT 不抬头 + 直连快 路径不丢包 → 不是 Mathis 丢包受限,而是 QUIC 层在无丢包地限速
读 quic-go 源码确认机制 SendDatagramdatagramQueue.Add32 帧满时阻塞(≈40KB in-flight 上限);接收侧 rcvQueue128 静默丢弃DatagramTooLargeError 在超 MTU 时丢
判断 per-conn vs global cap 开 flowhash 多连接聚合能超过单流 → 每连接上限,并行能堆叠 → 换掉传输层有戏
换裸 UDP + AES-GCM(类 WireGuard),先 spike 验证再实现 单流跑满链路 → 假设坐实:QUIC 那层的 CC/pacer/发送队列就是元凶
收尾 默认 MTU 1400 + 过大告警、UDP 陈旧路由修剪、优雅退出

3. 根因

单条 TCP 流被钉在一条 QUIC 连接上时,吞吐天花板来自 QUIC 传输层自身的限速,而非线路丢包 或 CPU

  1. 单个拥塞窗口 + pacer:datagram 在 quic-go 里仍受连接级 CC + pacing 管制。单条连接 = 单个 CC 实例,app-limited 时不会向上探测窗口,pacer 又把突发摊平。
  2. 32 深的 datagram 发送队列Add 满即阻塞,in-flight 被钉在 ≈40KB——若 BDP 大于它, 管道永远填不满。这正是 pprof 里大量 cond_wait/usleep 的来源(发送侧在等队列腾空)。
  3. 嵌套拥塞控制(nested CC)是反模式:把 TCP 套进带 CC 的 QUIC,等于两层拥塞控制互相 打架——外层的 pacing/排队抖动被内层 TCP 误读。隧道本应是哑管道,把拥塞控制完全交还 内层 TCP(WireGuard 就是「AES-GCM over 裸 UDP,传输层不做 CC」)。

flowhash 的本质是一个保序 ↔ 并行的权衡,治标不治本: 开 = 同流钉一条连接、保序但受单连接上限;关 = 同流撒多连接、单流快但跨连接乱序,内层 TCP 把乱序误判成丢包、虚假重传、cwnd 崩。两头都不是单流的解。


4. 解决方案

新增 transport/udp.go裸 UDP + AES-GCM,去掉 QUIC 整层。

  • 复用现有 frameDatagram/decodeDatagram 加密封包与 protobuf Envelope,语义与 QUIC 路径 一致;鉴权仍由 AES-GCM 解密成败充当(key 不对解不开即丢)。
  • server 按 ping 的 UDP 源地址维护 routes[vip]={addr,lastPing}(NAT 友好),发送选路 做 3s 新鲜度判断,1 分钟修剪陈旧条目。
  • --udp(默认开)切换;--udp=false 回退 QUIC 做 A/B。--egress_workers(默认自动 2×CPU, 设 1 可保单流顺序)用于排查出向乱序。
  • 默认 --mtu 1500→1400,留出 ~63B 封装开销,避免外层报文在 1500 路径上分片。

结果:单流跑满链路。


5. 调试网络服务的通用经验(可迁移)

这部分是这次排查沉淀下来的通法,与 qtun 无关。

5.1 先算术、后理论——量级估算能秒杀错误假说

20Mbps@1280B ≈ 2000 pkt/s。单核 AES-GCM 是 GB/s 级,差着 ~1000 倍——「CPU/加密慢」在量级 上就不成立。任何「X 是瓶颈」的猜测,先用「目标吞吐 → pps/字节级开销」对一遍数量级,错的 当场出局,省下大量瞎试。

5.2 pprof 先分清「算力受限」还是「阻塞/空闲」

看 CPU profile 的总采样占比栈顶函数

  • 占比高、栈顶是你的计算函数 → 算力受限,去优化热点;
  • 占比低(如 35%)、大量 cond_wait/usleep/kevent、你以为的热点(加密/序列化)根本不在 榜上 → 不是算力问题,是在等(锁、队列、syscall、对端)。两类问题的修法完全相反。

5.3 永远先建立 baseline 对照,用它定位「锅在哪一层」

直连 iperf vs 走隧道:直连快且低重传、隧道慢且高重传 → 问题被钉死在隧道这一层, 不是物理路径。没有对照就会在错误的层里反复打转。

5.4 区分「丢包受限」与「无丢包限速」——它们的修法相反

  • 高重传 + RTT 飙升 → 真丢包/排队,按 Mathis(吞吐 ∝ MSS/(RTT·√p))去降丢包、降 RTT;
  • 低重传 + RTT 平 + 带宽却卡死无丢包的限速:某层的 pacing / 拥塞窗口 / 固定队列 在节流。别再去找丢包,去找那个限速器。

5.5 「重传低」不等于「没乱序受害」

把单条流撒到多条路径/连接 → 到达乱序 → 内层 TCP dup-ACK、虚假快重传。轻微乱序不触发 3-dup-ACK,不显示为 retr,却会悄悄压住 cwnd。所以「retr 不高」不能证明链路健康;要用 ss -ti 直接看内层 cwnd 是卡在很小、还是大但被限速

5.6 别假设库的语义——去读源码

quic-go 的 SendDatagram队列满即阻塞(不是丢),接收侧128 满即静默丢,超 MTU 返回 DatagramTooLargeError。这些直接决定了瓶颈在哪、丢包是否「隐形」。关键路径上库的 行为,一定翻源码确认,不要靠直觉。

5.7 警惕「隐形丢包」——它对上层是真丢包、对你的计数器却不可见

应用层的丢弃(SendDatagram 返回 error、接收队列溢出、超 MTU)不计入传输层的重传统计, 但对内层 TCP 是实打实的丢包。这正是「retr 很低但带宽就是上不去」的常见成因。给每个丢弃 点加节流日志,排查时先 grep 它。

5.8 嵌套拥塞控制是反模式;隧道应是哑管道

TCP-in-(QUIC/TCP) 把两层 CC 叠在一起互相打架。隧道、VPN、转发器不要自带 CC,把拥塞控制 留给最内层的端到端连接(WireGuard 的设计哲学)。

5.9 判别「每连接上限」还是「全局上限」,再决定要不要动传输层

多连接聚合是否明显超过单连接?

  • 是 → 每连接上限,并行能堆叠,换/调传输层有意义;
  • 否 → 全局串行点(单写线程、单设备、单队列),换传输层白费,去找那个串行点。 这个判别能避免「改错地方」的大返工。

5.10 大改之前先 spike,用最小实验证伪/证实假说

不要直接重写几百行去赌一个假说。先写 ~100 行最小验证(写死参数、复用现成封包、单读单写) 拿到那个决定性数字:有效再正式做,无效则省下整个重写

5.11 隧道必须为封装留 MTU 余量

内层 MTU + 封装开销(外层 IP/UDP + 加密 + 协议头)若超过路径 MTU,外层报文被分片,一个 分片丢 = 整包丢,极脆。默认值要安全(我们取 1400),并在过大时告警。

5.12 改造时的两个隐蔽坑(会污染实验结论)

  • 错误处理别让循环永久退出:连接型 UDP 在对端没起来时,内核把 ICMP port-unreachable 以 ECONNREFUSED 返回;若读循环就此 return,入向永久失效,会被误判成「方案无效」。
  • 共享路由状态的数据竞争:多 worker 并发读、读循环写同一字段——用不可变条目整体替换 或原子操作。go test -race 只在真有并发测试驱动时才有意义;没覆盖到的代码,绿勾不等于安全。

5.13 保留旧路径做 A/B

新方案用开关切换、旧路径留着,能随时对照、快速回退,也让「到底是不是这层的锅」可证。


6. 验证手册

# 两端必须同样 --udp(默认即开),且先启服务端(连接型 UDP 客户端先跑会吃 ECONNREFUSED)
# 服务端
sudo ./bin/qtun qt --key secret --listen 0.0.0.0:8080 --ip 10.4.4.2/24 --mtu 1280 --server_mode
# 客户端
sudo ./bin/qtun qt --key secret --remote_addrs <srv>:8080 --ip 10.4.4.3/24 --mtu 1280

# 单流压测 + 读内层 TCP 状态
iperf3 -c 10.4.4.2 -t 20
ss -ti                      # 看 cwnd 是卡小还是大但被限速、retr 多少
# A/B:加 --udp=false 切回 QUIC 对照;--egress_workers 1 验证出向乱序是否影响

结果判读:单流 >> 20Mbps → 传输层是元凶(已确认);仍 ≈20 → 全局上限(单 tunWriter/ TUN),换传输层无效;居中 → 先怀疑 egress 乱序,用 --egress_workers 1 + ss -ti 验证。


7. 已知局限 / 后续

  • UDP 服务端目前单个读 goroutine处理所有客户端 + 全部入向;接收端单个 tunWriter 保序写 TUN。现在不绑死,但都是更高吞吐下的下一个天花板。
  • N 个 egress worker 抢读同一 TUN fd 并并发发送,会让单流出向乱序(与传输层无关)——必要时 用 --egress_workers 1 或按流保序。
  • transport/ 仍缺单元测试;UDP 回环 round-trip 测试是最该补的覆盖。