为什么控制信号和视频走不同的传输通道:一个被忽视的架构决策

0 阅读12分钟

凌晨 3 点 17 分,告警短信把工程师老王从梦里拉起来。

告警内容:某批 T-Box 的控制信号 P99 延迟从基准线 85ms 飙升到 430ms,持续 12 分钟后自行恢复。

睡眼惺忪地打开监控大屏,第一眼就觉得奇怪:网络没问题,RTT 曲线完全平坦,丢包率是零。不是 4G 信号问题,不是运营商网络问题。那是什么?

翻日志,发现那 12 分钟里有人在做视频流测试——用的是同一个 QUIC 连接,同一个 QUIC 实例,只是开了一个新的 stream 来传视频帧。

"QUIC 不是解决了 HoL blocking 吗?不同 stream 之间不是独立的吗?"

这是那个凌晨最大的困惑。而这篇文章,就是解释这个困惑的答案——以及它逼出的那个架构决策。


第一章:那次告警的完整排查过程

1.1 初步排除:网络不是问题

P99 延迟告警的标准排查流程,第一步是看网络指标。

# 查看告警时段的 RTT 历史(从 qlog 里提取)
cat /tmp/tquic_qlog/*.sqlog | python3 - << 'EOF'
import sys, json

events = []
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    try:
        e = json.loads(line)
        if isinstance(e, list) and len(e) >= 3:
            events.append(e)
    except:
        pass

# 输出 RTT 时序
for e in events:
    if len(e) >= 4 and "min_rtt" in str(e[3]):
        print(f"t={e[0]}ms rtt={e[3].get('smoothed_rtt', 'N/A')}ms")
EOF

告警时段(凌晨 3:05-3:17),RTT 均值 82ms,P99 88ms,和基准线几乎完全一致。网络层没有异常。

第二步:看服务端处理延迟。从云控网关的指标里,控制信号包在服务端的处理时间(从收包到入队)始终在 2ms 以内。服务端没问题。

第三步:用 ss 看 T-Box 端的 UDP socket 发送队列:

# 实时观察 UDP socket 发送队列积压
watch -n 0.1 "ss -unp | grep 443"
# 输出格式:Recv-Q Send-Q ...
# 正常情况:Send-Q 接近 0
# 异常情况:Send-Q 持续 > 0,说明应用层写入速度 > 内核发送速度

Send-Q 在视频测试开始后持续维持在 30-50KB,控制信号测试期间是 0。这是一个信号:发送端有积压。

1.2 定位根因:cwnd 是共享的

最终的定位来自 qlog 里的 cwnd 数据。

recovery:metrics_updated 事件的 cwnd 值画出来,得到一条清晰的曲线:视频流启动后,cwnd 从 180KB 增长到 400KB(BBR 的带宽探测阶段),然后一个视频 I 帧发出(约 150KB),触发了 2% 的丢包,cwnd 从 400KB 降到了 200KB,又从 200KB 降到了 80KB——两次连续丢包。

cwnd 缩到 80KB 之后,视频帧的后续数据(P 帧,每帧约 20-40KB)继续占用 cwnd,留给控制信号小包(每包约 300 字节)的空间虽然在数字上足够,但调度顺序上控制信号被排在视频帧数据后面——发送队列是 FIFO 的,视频帧的数据先进队列,控制信号在后面等。

这就是那 430ms 的来源:控制信号包等了 3 个 RTT(3 × 80ms = 240ms)加上队列调度延迟,总计 350-430ms。

fig02.png


第二章:QUIC 多 stream 为什么解决不了这个问题

2.1 QUIC 解决了什么 HoL,没有解决什么 HoL

QUIC 的"解决 HoL blocking"是一个常被误用的说法。需要精确区分 QUIC 解决了哪种 HoL,没有解决哪种。

TCP 的 HoL blocking(已解决)

TCP 是字节流协议,所有数据按序交付。如果包 N 丢失,包 N+1、N+2……即使已经到达接收方,也必须等包 N 重传成功才能交给应用层。这是 TCP 协议设计的固有约束。

QUIC 使用独立的 stream,每个 stream 内部按序交付,但 stream 之间彼此独立。stream A 的包 N 丢失,不影响 stream B 的包 M 交付给应用层。这确实解决了 TCP 的 HoL blocking 问题。

连接级拥塞窗口的 HoL(没有解决)

QUIC 的拥塞控制在连接(connection)级别,不在流(stream)级别。这意味着:

  • 所有 stream 共用同一个拥塞窗口(cwnd)
  • 当 cwnd 被大流量 stream 填满,小流量 stream 的数据虽然已经生成,但必须等 cwnd 有空间才能发出
  • stream 优先级影响的是调度顺序(哪个 stream 的数据先进入发送缓冲区),但当 cwnd = 0 时,所有 stream 都发不出去,无论优先级多高

用类比来说:TCP 的 HoL blocking 是"同一条车道,前面的车堵住了后面的车"。QUIC 消除了这个问题,让不同 stream 走不同车道。

但连接级 cwnd 的问题是:所有车道共用同一个隧道(cwnd),隧道满了,所有车道的车都进不去。给控制信号车道设置更高的优先级,只是让它排在隧道入口前面——但隧道满了,排第一也没用。

2.2 HTTP/3 的优先级机制为什么救不了这个场景

有工程师会说:QUIC/HTTP3 支持 stream 优先级(RFC 9218 Extensible Prioritization Scheme),给控制信号 stream 设置最高优先级不就行了?

这个方案有两个问题:

第一,优先级影响的是数据进入发送缓冲区的顺序,不影响 cwnd 的分配。 当 cwnd 耗尽,即使控制信号的数据排在最前面,也要等一个 RTT 收到 ACK、cwnd 增长,才能发出去。等待时间与优先级无关。

第二,cwnd 缩减是触发式的,触发因素是连接总发送量,不是单个 stream 的发送量。 视频 stream 持续发大包,推高了连接的总发送速率,增加了触发拥塞的概率。一旦触发,所有 stream 受影响。控制信号 stream 没有发大包,但它要承担视频 stream 引起的 cwnd 下降后果。

这不是 QUIC 的问题,是 QUIC 协议设计的基本约束。quiche、msquic、ngtcp2 面对同样的场景都会有同样的问题。

2.3 为什么这个问题在云控场景特别严重

在浏览器场景下,多个 HTTP/3 请求共用一个 QUIC 连接,即使某个请求的优先级高,整体上也是网页加载场景,用户对 200ms 的 P99 延迟感知不强。

云控场景的要求完全不同:

  • P99 延迟 >250ms:直接违反 SLA
  • 单次延迟尖峰:可能导致控制指令丢失或超时,影响车辆响应
  • 持续抖动:比单次延迟更严重,因为控制算法依赖稳定的指令频率

在这个约束下,把控制信号和视频放在同一个 QUIC 连接里,相当于在 SLA 的刀刃上走钢丝——平时可能没问题,但一旦视频帧触发拥塞,P99 就直接超标。

# 验证 cwnd 变化与控制信号延迟的相关性
# 从 qlog 中同时提取 cwnd 和包发送延迟

cat /tmp/tquic_qlog/*.sqlog | python3 - << 'EOF'
import sys, json

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    try:
        e = json.loads(line)
        if not isinstance(e, list) or len(e) < 4:
            continue
        t = e[0]  # 时间戳(ms)
        category = e[1] if len(e) > 1 else ""
        event_type = e[2] if len(e) > 2 else ""
        data = e[3] if len(e) > 3 else {}

        # 输出 cwnd 变化
        if "congestion_window" in str(data):
            cwnd = data.get("congestion_window", "N/A")
            print(f"[cwnd] t={t}ms cwnd={cwnd}bytes")

        # 输出包发送延迟(从 packet_sent 事件)
        if event_type == "packet_sent" and isinstance(data, dict):
            length = data.get("length", 0)
            if length < 500:  # 控制信号小包
                print(f"[ctrl] t={t}ms size={length}bytes")
    except:
        pass
EOF
# 把输出导入 Excel/Python 可视化,查看 cwnd 下降时刻是否对应控制信号延迟上升

第三章:控制信号和视频的流量特征——为什么它们根本不兼容

理解了 cwnd 共享的问题,下一步是量化两种流量的特征差异,看看它们到底有多不兼容。

3.1 控制信号的流量特征

来自真实部署的测量数据:

包大小:绝大多数控制指令(方向、速度、刹车等)序列化后 100-500 字节。加上 QUIC 头部(约 30-50 字节)和应用层协议头(通常 20-50 字节),单包总大小在 150-600 字节,不会超过 1KB。

发送频率:云控指令通道在激活状态下,10-50 条指令/秒。按 50 条/秒、每条 300 字节计算,上行带宽约 120Kbps。这是一个非常小的数字——4G 上行带宽的 1% 不到。

延迟要求:端到端 P99 <250ms,P999 <600ms。单次超标可以接受,持续超标不可接受。

丢包容忍:几乎为零。丢一条指令,车辆可能执行上一条过时的指令,或者等待超时后停车保护。

抖动容忍:极低。帧间延迟抖动(jitter)直接影响控制算法的稳定性,云控 PID 控制器对稳定的指令频率有依赖。

3.2 视频回传的流量特征

同样来自实测:

包大小:H.265 编码后,实际码率随光线条件变化明显——白天场景纹理丰富,典型码率约 200Kbps;夜间噪点增加,编码器输出反而更大,典型码率约 400Kbps。单帧包大小:I 帧(关键帧,每 GOP 1 个)典型 10-50KB;P 帧(预测帧)典型 2-8KB;B 帧 1-3KB。这些都比控制信号包大一到两个数量级。

发送频率:15fps 连续流,帧间隔约 67ms。以白天码率 200Kbps 计算,平均 200,000 / (15 × 8) ≈ 1,667 字节/帧,约每帧 1.6KB;夜间 400Kbps 时约每帧 3.3KB。I 帧发送时,单次发送量是均值的 5-10 倍。

带宽消耗:单路视频实际占用上行带宽约 1Mbps(含视频码率 200-400Kbps、I 帧突发、RTP/传输协议开销、重传等)。占 4G 可用上行带宽(2-20Mbps)的 5%-50%。多路视频或信号弱时,视频流可以打满上行带宽。

延迟要求:端到端 <250ms(比控制信号宽松),但更关键的是帧间抖动 <67ms(保证 15fps 流畅,超过一个帧间隔就会出现卡顿感)。

丢包容忍:I 帧不能丢(丢了一个 GOP 的参考帧,后续 P/B 帧都无法解码)。P 帧可以少量丢失(H.265 的错误隐藏机制可以处理),但连续丢包影响画质。

特征控制信号视频回传兼容性
包大小150-600 字节2-50KB(P帧到I帧)❌ 差 10-100 倍
带宽占用<200Kbps~1Mbps(实际上行带宽消耗)❌ 差 5-10 倍
延迟要求<250ms P99<250ms表面相同,实质不同(控制信号不能抖动)
丢包容忍极低I帧不能丢类似但原因不同
拥塞时行为需求抢先发出平稳降码率❌ 完全相反

最后一行是关键:控制信号在拥塞时需要"抢先发出"(宁可丢别的包,也要让控制信号先走),视频在拥塞时需要"平稳降码率"(适当降低发送速率,保持流畅而非中断)。这两种需求对拥塞控制算法的要求完全相反,无法用同一套参数满足。

fig01.png

# 在开发机上统计测试包的大小分布
# 控制信号测试(通过 QUIC 测试工具发送小包)
tcpdump -i lo 'udp port 4433' -l -q 2>/dev/null | \
  awk '{print $NF}' | grep -E '^[0-9]+$' | sort -n | \
  awk '{
    if ($1 < 500) a["<500"]++
    else if ($1 < 1500) a["500-1500"]++
    else if ($1 < 5000) a["1.5K-5K"]++
    else a[">5K"]++
  }
  END {for (k in a) print k, a[k]}' | sort

# 视频仿真(用 dd 生成大包通过 nc 发送到测试服务)
# dd if=/dev/urandom bs=100000 count=1 | nc -u localhost 9999

第四章:混用代价量化——P99 被推高 3-5 倍的数学

4.1 建立分析模型

为了量化共用 cwnd 的代价,建立一个简化的排队模型。

前提条件

  • 4G 网络,RTT = 80ms(典型值)
  • QUIC 使用 BBR 拥塞控制
  • 稳定状态下 cwnd ≈ 带宽 × RTT = 5Mbps × 0.08s = 50KB(BBR 的 BDP 估计,保守取值)

这里取 50KB 是保守的,实际 BBR 探测阶段 cwnd 可能到 200KB+,但在路况不稳定的 4G 网络中,实测 cwnd 经常在 50-150KB 之间波动。

视频持续流量挤占 cwnd 的机制

视频码率约 200-400Kbps,15fps,帧间隔 67ms,每帧平均 1.6-3.3KB。稳定状态 cwnd ≈ 带宽 × RTT = 5Mbps × 0.08s = 50KB。

单个 I 帧约 10-30KB,远小于 cwnd,可以一次发完,不会因为单帧撑爆 cwnd。真正的问题是视频流的持续性:15fps 意味着每 67ms 就有一帧进入发送队列,视频数据持续占用 cwnd 的一部分空间。

稳定态下 cwnd = 50KB,视频每帧约 3KB,每 67ms 消耗 3KB,剩余给控制信号的空间理论上充足。但 BBR 的带宽探测阶段(PROBE_BW)会周期性地把发送速率拉高到估计带宽的 1.25 倍,这段时间 cwnd 被视频数据快速消耗,控制信号的 300 字节排在队列末尾,等待时间 = 1 个 RTT = 80ms。

如果恰好在探测阶段触发 2% 的随机丢包,cwnd 减半到 25KB:

cwnd = 25KB,视频下一帧(约 3KB P 帧)进入队列,加上已在途数据,cwnd 剩余空间被占满。控制信号 300 字节需要等待:等当前 RTT 结束,ACK 回来 cwnd 增长,才能发出。等待时间 = 1-2 个 RTT = 80-160ms。

总延迟 = 正常传播延迟 80ms + 等待时间 80-160ms = 160-240ms,P99 估计在 200-250ms 之间。

4.2 极端情况:P99 到 430ms 的推导

上面是"轻度拥塞"的情况。凌晨那次告警的 430ms 是怎么来的?

那次测试视频刚开始推流,BBR 处于 Startup 阶段,以指数速度探测带宽,发送速率从 0 快速拉升到链路上限,丢包率约 5%。

推导过程:

  1. BBR Startup 阶段 cwnd 快速增长,视频数据积压在发送队列,触发连续丢包
  2. 第一次丢包:cwnd 减半,但 BBR Startup 继续探测,cwnd 很快又涨上来
  3. 连续两次丢包后 cwnd 收敛到稳定值,约 30KB
  4. cwnd = 30KB,视频帧持续进入队列(每 33ms 一帧,4-8KB),cwnd 始终处于高水位
  5. 控制信号 300 字节排在队列末尾,连续等待多个 RTT 才能发出

等待时间计算:

  • 等第一个 RTT(当前在途数据发完,ACK 回来):80ms
  • cwnd 增长,但视频下一帧紧接着又进来,再次占满
  • 再等一个 RTT:80ms
  • 控制信号发出,单程传播:80ms

总延迟:80ms(传播) + 80ms(等待 1)+ 80ms(等待 2)+ 排队延迟 ≈ 320-430ms

这和实测的 430ms P99 完全吻合。

fig04.png

4.3 为什么 stream 优先级治标不治本

这里要直接反驳一个常见的"修补"方案:给控制信号 stream 设置最高优先级(urgency=0),让它总是比视频 stream 先调度。

问题是:当 cwnd = 0 时,优先级无效。

QUIC 的发送逻辑(简化)是:

if cwnd > 0:
    按优先级从发送队列取数据发送
    cwnd -= 发送字节数
else:
    等待 ACK 使 cwnd 增长

当视频帧把 cwnd 消耗完时,整个发送队列停止,控制信号 stream 不管优先级多高,都要等 cwnd 恢复。

优先级只有在 cwnd > 0 的情况下才有意义——它决定的是"cwnd 还够用时,先发谁",不是"cwnd 不够用时,谁可以发"。

在视频+控制信号混用的场景里,视频流几乎总是在把 cwnd 打到极限,"cwnd > 0 但有剩余"的时间窗口很短。优先级虽然让控制信号在理想情况下快一点,但无法解决拥塞窗口共享的根本问题。

# 在开发机上验证:混用场景下控制信号的延迟
# 方法:用 tc netem 模拟 4G 网络,同时发视频流和控制信号,测量各自延迟

# 步骤 1:配置 netem(模拟 4G:40ms 延迟,2% 丢包)
sudo tc qdisc add dev lo root netem delay 40ms loss 2%

# 步骤 2:启动 QUIC 测试服务端(需要 QUIC 工具)
# tquic_server --listen 127.0.0.1:4433 --cert cert.pem --key key.pem

# 步骤 3:发送混合流量(控制信号:300字节,50次/秒;视频仿真:100KB,30次/秒)
# tquic_client --host 127.0.0.1 --port 4433 \
#   --stream-multiplex 2 \
#   --stream-0-size 300 --stream-0-rate 50 \
#   --stream-1-size 100000 --stream-1-rate 30 \
#   --qlog-dir /tmp/mixed_test

# 步骤 4:解析 stream-0(控制信号)的端到端延迟
# 从 qlog 中提取 stream-0 的发送时刻和 ACK 时刻,计算差值

# 对照组:两条独立连接的控制信号延迟
sudo tc qdisc del dev lo root 2>/dev/null
sudo tc qdisc add dev lo root netem delay 40ms loss 2%
# tquic_client --host 127.0.0.1 --port 4433 --stream-multiplex 1 \
#   --stream-0-size 300 --stream-0-rate 50 --qlog-dir /tmp/ctrl_only

第五章:视频通道的工程选型

5.1 为什么视频不适合走 QUIC

视频不走 QUIC,不仅仅是因为 cwnd 共享的问题(如果走独立的 QUIC 连接,cwnd 共享问题就消失了)。还有几个 T-Box 场景特有的理由:

理由一:QUIC 的加密 overhead 在高码率视频场景代价较高

QUIC 对每个 UDP 包做 AEAD 加密(AES-128-GCM 或 ChaCha20-Poly1305)。控制信号每包 300 字节,加密 overhead 相对可以接受。但视频每秒几十个包、每包 30-150KB,加密的 CPU 消耗在 ARM Cortex-A55 上约为 10-30MB/s 的吞吐。

4Mbps 的视频码率 = 500KB/s。AES-GCM 在 A55 上(带 AES 硬件加速)加密速度约 200-500MB/s,从这个角度看其实不算大。但加上包头处理、ACK 生成、拥塞控制计算,QUIC 的总 CPU 开销在 A55 双核上大约占 10-20%。

对于内存 512MB、ARM A55 双核的 T-Box,10-20% 的单 CPU 占用是可接受的,但要注意这是视频通道独占的代价,叠加控制信号的 QUIC 进程会超过 20%。

理由二:视频需要定制化的码率自适应(ABR),QUIC 拥塞控制不适合直接用于视频

视频编码器有自己的码率控制逻辑(Bitrate Adaptation),需要基于网络状况动态调整编码参数。这需要和传输层的拥塞控制紧密协作——理想状态是编码器直接感知可用带宽,按需调整 I/P 帧比例和量化参数。

QUIC 的拥塞控制算法(BBR/CUBIC/Copa)是为通用数据传输设计的,不暴露"当前可用带宽估计"这样的接口给上层。视频编码器要感知网络状况,需要额外的信令,增加了系统复杂度。

相比之下,专用的视频传输协议(如 SRT、RTP with RTCP)有成熟的带宽估计和反馈机制。

理由三:视频丢包处理和 QUIC 的可靠传输假设冲突

QUIC 默认提供可靠传输(所有包都要重传直到成功)。视频对可靠性的需求更复杂:I 帧必须可靠,P 帧在部分场景下可以选择丢弃而不是重传(因为重传会增加延迟,反而让画面更差)。

在 QUIC 的 stream 语义下,要实现这种"按帧类型差异化重传"的逻辑需要在应用层做额外处理,相当于在 QUIC 上面再实现一套应用层 FEC 或选择性重传。这比直接用 UDP + 自定义协议更复杂。

5.2 本系列的架构决策

基于以上分析,视频回传走独立的 UDP 通道,协议层独立于 QUIC 之外,有专门的码率自适应和 I/P/B 帧差异化重传逻辑。这部分不是本系列的讨论范围。

fig03.png

从第 04 篇起,本系列所有内容均针对控制信号链路

  • 流量特征:小包(<1KB)、低带宽(<200Kbps)、高可靠、低延迟
  • 传输方案:QUIC MPQUIC 冗余发送
  • 拥塞控制:针对小包、低延迟场景优化
  • 平台:ARM Cortex-A55,内存 512MB-2GB

视频通道的选型和实现,本系列不涉及。

# 验证当前 T-Box 上是否已经分离了两条通道
# 控制信号走 443 端口(QUIC),视频走独立端口

# 查看所有 UDP socket 和对应的进程
ss -unp | grep -v "UNCONN"

# 输出示例(正确的分离状态):
# udp ESTAB  127.0.0.1:12345  云控服务器IP:443  users:(("tquic_ctrl",pid=1234,...))
# udp ESTAB  127.0.0.1:23456  云端视频服务器IP:9999  users:(("video_sender",pid=5678,...))

# 如果两个 socket 对应不同进程 -> 通道已分离
# 如果两个流量来自同一个进程的同一个 socket -> 未分离,有风险

# 进一步验证:用 tcpdump 观察包大小分布
tcpdump -i rmnet0 'udp port 443 or udp port 9999' -l -q 2>/dev/null | \
  awk '{
    if ($NF+0 < 1000) ctrl++
    else video++
    total++
  }
  END {print "total:", total, "ctrl (<1KB):", ctrl, "video (>=1KB):", video}'

第六章:分离决策对后续设计的影响

6.1 连锁效应:每个约束如何影响后续章节

把控制信号从视频中分离出去,这个单一决策带来了整个系统设计的连锁效应:

QUIC 的内存可以按控制信号规格分配(影响第 04 篇选型)

控制信号的 cwnd 最大约 200KB,加上 QUIC 内部缓冲区,整个 QUIC 进程稳定运行约需 20-50MB 内存。这意味着在 512MB 内存的 T-Box 上,可以给 QUIC 配置合理的资源预算,不需要担心视频流的大缓冲区耗尽内存。

拥塞控制算法可以针对小包优化(影响后续调优篇)

视频流需要拥塞控制算法有好的带宽利用率(尽量跑满带宽)。控制信号需要的是低延迟(宁可少用带宽,也要延迟可控)。两者对拥塞控制的需求相反。分离后,可以给控制信号选择更激进的低延迟算法(如 Copa),而不需要兼顾视频的带宽效率。

云端网关架构简化(影响系统集成设计)

视频包大(>10KB)、控制信号包小(<1KB)。如果混用同一个接收端,需要处理两种完全不同大小的包,内存分配策略、处理线程的负载特征都不一样。分离后,控制信号网关只处理小包,可以用更高效的 CPU 绑定 + 无锁队列架构;视频网关处理大包,用 DMA 和环形缓冲区优化。

fig05.png

6.2 一句话总结这个架构决策的价值

把控制信号从视频中分离出去,是做极致优化的前提。只有划定了"控制信号"的边界,才能在这个边界内做针对性的设计——小包优化的拥塞控制、冗余发送的带宽预算、精确的延迟 SLA 保证。

如果控制信号和视频混在一起,系统设计就不得不在"高吞吐"和"低延迟"之间妥协,最终两边都不极致。