时间:2026-06-20 ~ 2026-06-21 分支:
quic记录一次「隧道单条 TCP 流吞吐上不去」的完整排查过程、最终根因、解决方案,以及从中 提炼出的调试网络服务的通用经验。后半部分(调试经验)与具体项目无关,可作为通法参考。
qtun 隧道单条 TCP 流(如一条 iperf)吞吐长期卡在 ~20Mbps,而:
- 直连(不走隧道)iperf 在同一路径上远高于此,且重传很少;
- 走隧道时重传一度很多、RTT 不断累积。
诉求:把单流吞吐打满。(多流聚合可以靠多连接堆上去,但那不是我们要的。)
| 动作 | 结果 / 结论 |
|---|---|
| 调大各级缓冲区 | 无改善 → 不是缓冲不足 |
| 怀疑串行处理、观察到 3000+ 次空闲唤醒 | 方向对但非根因 |
| 分析 pprof、观察重传 | 直连重传少、走隧道重传爆多 → 问题在隧道,不在路径 |
| threads=1 / 关加密 对照 | 重传依旧 → 不是并发数、不是加密 |
| 把数据面从可靠有序 stream 改为 QUIC DATAGRAM | 重传只在开头高、RTT 不再飙升;但带宽仍 ~20Mbps(比原来略好) |
| 加 flowhash(五元组流亲和) | 多连接聚合吞吐上去了,但单流更差 → 做成开关,默认关 |
Day 1 的关键转折:内层 TCP 套在可靠有序的 QUIC stream 里,会触发「队头阻塞 + 双重 重传」——线路上极小的丢包被 QUIC 在它那层重传时阻塞整条流,内层 TCP 随即超时重传,吞吐被 打垮。改用不可靠 datagram 后,隧道表现得像一条真实会丢包的链路,丢包恢复交还给内层 TCP,消除了双重重传与 HoL。RTT 稳了,但单流带宽仍没起来——遗留到 Day 2。
| 动作 | 结果 / 结论 |
|---|---|
| 用算术先证伪 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 源码确认机制 | SendDatagram→datagramQueue.Add 在 32 帧满时阻塞(≈40KB in-flight 上限);接收侧 rcvQueue 满 128 静默丢弃;DatagramTooLargeError 在超 MTU 时丢 |
| 判断 per-conn vs global cap | 开 flowhash 多连接聚合能超过单流 → 每连接上限,并行能堆叠 → 换掉传输层有戏 |
| 换裸 UDP + AES-GCM(类 WireGuard),先 spike 验证再实现 | 单流跑满链路 → 假设坐实:QUIC 那层的 CC/pacer/发送队列就是元凶 |
| 收尾 | 默认 MTU 1400 + 过大告警、UDP 陈旧路由修剪、优雅退出 |
单条 TCP 流被钉在一条 QUIC 连接上时,吞吐天花板来自 QUIC 传输层自身的限速,而非线路丢包 或 CPU:
- 单个拥塞窗口 + pacer:datagram 在 quic-go 里仍受连接级 CC + pacing 管制。单条连接 = 单个 CC 实例,app-limited 时不会向上探测窗口,pacer 又把突发摊平。
- 32 深的 datagram 发送队列:
Add满即阻塞,in-flight 被钉在 ≈40KB——若 BDP 大于它, 管道永远填不满。这正是 pprof 里大量cond_wait/usleep的来源(发送侧在等队列腾空)。 - 嵌套拥塞控制(nested CC)是反模式:把 TCP 套进带 CC 的 QUIC,等于两层拥塞控制互相 打架——外层的 pacing/排队抖动被内层 TCP 误读。隧道本应是哑管道,把拥塞控制完全交还 内层 TCP(WireGuard 就是「AES-GCM over 裸 UDP,传输层不做 CC」)。
flowhash 的本质是一个保序 ↔ 并行的权衡,治标不治本: 开 = 同流钉一条连接、保序但受单连接上限;关 = 同流撒多连接、单流快但跨连接乱序,内层 TCP 把乱序误判成丢包、虚假重传、cwnd 崩。两头都不是单流的解。
新增 transport/udp.go:裸 UDP + AES-GCM,去掉 QUIC 整层。
- 复用现有
frameDatagram/decodeDatagram加密封包与 protobufEnvelope,语义与 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 可保单流顺序)用于排查出向乱序。- 默认
--mtu1500→1400,留出 ~63B 封装开销,避免外层报文在 1500 路径上分片。
结果:单流跑满链路。
这部分是这次排查沉淀下来的通法,与 qtun 无关。
20Mbps@1280B ≈ 2000 pkt/s。单核 AES-GCM 是 GB/s 级,差着 ~1000 倍——「CPU/加密慢」在量级 上就不成立。任何「X 是瓶颈」的猜测,先用「目标吞吐 → pps/字节级开销」对一遍数量级,错的 当场出局,省下大量瞎试。
看 CPU profile 的总采样占比和栈顶函数:
- 占比高、栈顶是你的计算函数 → 算力受限,去优化热点;
- 占比低(如 35%)、大量
cond_wait/usleep/kevent、你以为的热点(加密/序列化)根本不在 榜上 → 不是算力问题,是在等(锁、队列、syscall、对端)。两类问题的修法完全相反。
直连 iperf vs 走隧道:直连快且低重传、隧道慢且高重传 → 问题被钉死在隧道这一层, 不是物理路径。没有对照就会在错误的层里反复打转。
- 高重传 + RTT 飙升 → 真丢包/排队,按 Mathis(吞吐 ∝ MSS/(RTT·√p))去降丢包、降 RTT;
- 低重传 + RTT 平 + 带宽却卡死 → 无丢包的限速:某层的 pacing / 拥塞窗口 / 固定队列 在节流。别再去找丢包,去找那个限速器。
把单条流撒到多条路径/连接 → 到达乱序 → 内层 TCP dup-ACK、虚假快重传。轻微乱序不触发
3-dup-ACK,不显示为 retr,却会悄悄压住 cwnd。所以「retr 不高」不能证明链路健康;要用
ss -ti 直接看内层 cwnd 是卡在很小、还是大但被限速。
quic-go 的 SendDatagram 是队列满即阻塞(不是丢),接收侧128 满即静默丢,超 MTU
返回 DatagramTooLargeError。这些直接决定了瓶颈在哪、丢包是否「隐形」。关键路径上库的
行为,一定翻源码确认,不要靠直觉。
应用层的丢弃(SendDatagram 返回 error、接收队列溢出、超 MTU)不计入传输层的重传统计,
但对内层 TCP 是实打实的丢包。这正是「retr 很低但带宽就是上不去」的常见成因。给每个丢弃
点加节流日志,排查时先 grep 它。
TCP-in-(QUIC/TCP) 把两层 CC 叠在一起互相打架。隧道、VPN、转发器不要自带 CC,把拥塞控制 留给最内层的端到端连接(WireGuard 的设计哲学)。
多连接聚合是否明显超过单连接?
- 是 → 每连接上限,并行能堆叠,换/调传输层有意义;
- 否 → 全局串行点(单写线程、单设备、单队列),换传输层白费,去找那个串行点。 这个判别能避免「改错地方」的大返工。
不要直接重写几百行去赌一个假说。先写 ~100 行最小验证(写死参数、复用现成封包、单读单写) 拿到那个决定性数字:有效再正式做,无效则省下整个重写。
内层 MTU + 封装开销(外层 IP/UDP + 加密 + 协议头)若超过路径 MTU,外层报文被分片,一个 分片丢 = 整包丢,极脆。默认值要安全(我们取 1400),并在过大时告警。
- 错误处理别让循环永久退出:连接型 UDP 在对端没起来时,内核把 ICMP port-unreachable
以
ECONNREFUSED返回;若读循环就此 return,入向永久失效,会被误判成「方案无效」。 - 共享路由状态的数据竞争:多 worker 并发读、读循环写同一字段——用不可变条目整体替换
或原子操作。
go test -race只在真有并发测试驱动时才有意义;没覆盖到的代码,绿勾不等于安全。
新方案用开关切换、旧路径留着,能随时对照、快速回退,也让「到底是不是这层的锅」可证。
# 两端必须同样 --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 验证。
- UDP 服务端目前单个读 goroutine处理所有客户端 + 全部入向;接收端单个
tunWriter保序写 TUN。现在不绑死,但都是更高吞吐下的下一个天花板。 - N 个 egress worker 抢读同一 TUN fd 并并发发送,会让单流出向乱序(与传输层无关)——必要时
用
--egress_workers 1或按流保序。 transport/仍缺单元测试;UDP 回环 round-trip 测试是最该补的覆盖。