那是一条普通的城区道路,车辆以 40km/h 的速度行驶。云控平台的延迟监控画面上,P99 折线图一直平稳地维持在 75-85ms 的区间里——直到 14:23:07,数字突然跳到了 322ms,红色告警条出现,持续了大约 3 秒,然后恢复正常。
事后分析:那 3 秒内,5G 链路因为路过一段小区基站切换区,丢包率飙升至 18%。4G 链路完全正常,没有任何异常。
但平台工程师发现一件奇怪的事:4G 链路明明是好的,为什么延迟还是超了 SLA?
答案是:在单路 QUIC 发送下,控制信号只走 5G 这一条路径。5G 的连续丢包触发了重传机制——等待三次重复 ACK 确认丢包,然后重传。这个过程消耗了一个完整的 RTT(5G 典型 RTT 约 60ms),连续多包丢失触发多次重传,叠加排队延迟,P99 就突破了 300ms。即使 4G 链路完全正常,控制信号也不走 4G,4G 在此时是"摆设"。
直觉上的解法是:5G 丢包就切换到 4G。但连接迁移本身有代价——PATH_CHALLENGE 验证过程约 50-100ms,迁移期间控制信号暂停发送。在 18% 丢包持续 3 秒的场景下,迁移引入的暂停窗口反而可能造成更大的延迟尖峰。
更优雅的解法是:不切换,直接双发。让控制信号从 4G 和 5G 同时发出,云端取先到的那份,后到的那份去重丢弃。4G 正常时,云端选 4G 的包(通常延迟更稳定);5G 正常时,云端选 5G 的包(通常延迟更低);5G 丢包时,4G 的包依然如期到达,P99 延迟不受影响。
这就是冗余发送(Redundant Scheduling)。本文要解决的工程问题是:双发的逻辑很简单,但"取先到者"背后的云端去重逻辑,以及两路 RTT 差异带来的乱序处理,有不少值得深入讲清楚的细节。本文基于 TQUIC 的真实源码实现来讲解这套机制。
第一章:单路发送下连续丢包的代价
先用数字把问题说清楚,理解这个问题的严重性。
QUIC 的丢包检测基于两种机制:
- 快速重传(Fast Retransmit):发送端连续收到 3 个乱序 ACK(SACK 空洞),认为该包丢失,立即重传。等待时间:约 3 个 ACK 间隔 ≈ 1 个 RTT
- PTO(Probe Timeout):在
PTO = smoothed_rtt + max(4×rttvar, kGranularity)时间内没有收到 ACK,发送探测包。这个超时通常比快速重传更长
在 5G 丢包率 18%、RTT=60ms 的场景下,假设发出 6 个包(pn=100~105),pn=101 和 pn=104 丢失:
t=0ms: 发送 pn=100, 101, 102, 103, 104, 105
t=60ms: 收到 pn=100 的 ACK
t=60ms: 收到 pn=102 的 ACK(pn=101 没到,乱序 ACK 1)
t=60ms: 收到 pn=103 的 ACK(乱序 ACK 2)
t=60ms: 收到 pn=105 的 ACK(乱序 ACK 3)→ 触发快速重传!
t=60ms: 发送端判定 pn=101 和 pn=104 丢失,发出重传包
t=120ms: 重传的 pn=101 到达,ACK 确认
t=120ms: 重传的 pn=104 到达,ACK 确认
从原始发包到完全确认:120ms。但如果丢包更密集(比如连续 3 个包丢失),快速重传可能失效(因为没有足够的乱序 ACK),退化为 PTO,等待时间拉长到 200-400ms。
这就是 P99 跳到 322ms 的机制:丢包率 18% 下,某些包触发了多次重传,叠加本身 60ms 的 RTT 和队头阻塞,尾部延迟超过 300ms。(以上为基于重传链路数学模型的推导,非 tcpdump 实测)
QUIC 的重传机制本身没有错。 对于文件传输、HTTP 请求等普通场景,重传是完全合理的——我们宁愿等待,也要保证数据完整到达。但对于 <250ms SLA 的控制信号,重传的延迟代价 = 直接违约:一次重传 ≈ 一个 RTT 的额外延迟,在 60ms RTT 的网络上就是 60ms 的惩罚。
连续两次重传,就是 120ms 惩罚,加上原始 RTT 的 60ms,共 180ms——加上空口 P99 延迟(80-150ms),P99 超 250ms 是必然的。
冗余发送的核心价值:用带宽换延迟。多消耗一倍的发送带宽,让每条控制信号都有两条路径承载,即使一条路径丢包,另一条路径上的副本已经在路上了,不需要等待重传。
# 用 QUIC qlog 分析丢包重传链路(需要访问云端 qlog)
import json
with open("server.qlog") as f:
data = json.load(f)
events = data["traces"][0]["events"]
losses = []
retrans = []
for e in events:
name = e.get("name", "")
if "packet_lost" in name:
losses.append((e.get("time", 0), e.get("data", {}).get("packet_number")))
elif "packet_sent" in name:
if e.get("data", {}).get("is_retransmission"):
retrans.append(e.get("time", 0))
print(f"检测到丢包: {len(losses)} 次,重传: {len(retrans)} 次")
for t, pn in losses[:10]:
print(f" 丢包 t={t:.1f}ms, pn={pn}")
第二章:TQUIC 冗余发送架构——两阶段触发机制
T-Box 侧的架构相对简单。云控客户端把控制指令写入 QUIC stream,TQUIC 的 RedundantScheduler 把同一 STREAM frame 复制为两份,分别封装进 path_id=0(4G, rmnet0)和 path_id=1(5G, rmnet1)的 QUIC 包里,通过各自绑定的 UDP socket 发出。
理解 TQUIC 冗余发送的关键,是搞清楚它的两阶段触发机制:on_select() 选路,on_sent() 重注入。这两个方法定义在 MultipathScheduler trait 中(src/multipath_scheduler/multipath_scheduler.rs),RedundantScheduler 分别实现了它们。
第一阶段:on_select() 选择第一条路径
// src/multipath_scheduler/scheduler_redundant.rs:46-60
impl MultipathScheduler for RedundantScheduler {
fn on_select(
&mut self,
paths: &mut PathMap,
spaces: &mut PacketNumSpaceMap,
streams: &mut StreamMap,
) -> Result<usize> {
for (pid, path) in paths.iter_mut() {
// Skip the path that is not ready for sending non-probing packets.
if !path.active() || !path.recovery.can_send() {
continue;
}
return Ok(pid);
}
Err(Error::Done)
}
on_select() 只选第一条满足条件的活跃路径(active() 且拥塞窗口足够 can_send()),返回该路径的 pid。注意它不做任何冗余逻辑——冗余发生在第二阶段。
第二阶段:on_sent() 把 STREAM 帧重注入其他路径
// src/multipath_scheduler/scheduler_redundant.rs:62-92
fn on_sent(
&mut self,
packet: &SentPacket,
now: Instant,
path_id: usize,
paths: &mut PathMap,
spaces: &mut PacketNumSpaceMap,
streams: &mut StreamMap,
) {
if packet.buffer_flags.has_buffered() {
return; // 防止二次重注:来自缓冲队列的帧不再递归重注
}
// 把 STREAM 帧重注入其他所有活跃路径的缓冲队列
for (pid, path) in paths.iter() {
if pid == path_id || !path.active() {
continue;
}
let space = match spaces.get_mut(path.space_id) {
Some(space) => space,
None => return,
};
for frame in &packet.frames {
if let Frame::Stream { .. } = frame {
debug!("RedundantScheduler: inject {:?} on path {:?}", frame, pid);
space.buffered.push_back(frame.clone(), BufferType::High);
}
}
}
}
on_sent() 在包发出后立即被调用(connection.rs:1854-1864)。它遍历所有其他活跃路径,把刚发出的包中的每个 STREAM 帧克隆一份,以 BufferType::High(高优先级)注入目标路径的 buffered 缓冲队列。下一次该路径发包时,会优先从 buffered 队列取帧。
防止递归重注的关键细节
buffer_flags.has_buffered() 检查(space.rs:501-504):
// src/connection/space.rs:494-513
pub struct BufferFlags {
pub from_high: bool,
pub from_mid: bool,
pub from_low: bool,
}
impl BufferFlags {
pub fn has_buffered(&self) -> bool {
self.from_high || self.from_mid || self.from_low
}
}
当一个帧从缓冲队列取出并封包发送时,buffer_flags 会被标记(mark())。on_sent() 首先检查 has_buffered():若为 true,说明这个包本身就是从缓冲队列来的(已经是重注帧),不再触发第二次重注,避免无限递归。
数据保留:为什么 Redundant 模式需要复制 payload
重注时 STREAM 帧需要携带完整的 payload 数据。TQUIC 在 connection.rs 中有一段专门的逻辑(2574-2582):
// src/connection/connection.rs:2574-2582
let data = if self.flags.contains(EnableMultipath)
&& buffer_required(self.multipath_conf.multipath_algorithm)
{
Bytes::copy_from_slice(&out[start..start + frame_data_len]) // 复制 payload
} else {
Bytes::new() // MinRtt/RoundRobin 不保留 payload
};
buffer_required() 函数(multipath_scheduler.rs:113-119)判断当前算法是否需要保留数据:
// src/multipath_scheduler/multipath_scheduler.rs:113-119
pub(crate) fn buffer_required(algor: MultipathAlgorithm) -> bool {
match algor {
MultipathAlgorithm::MinRtt => false,
MultipathAlgorithm::Redundant => true, // 冗余模式需要保留 payload
MultipathAlgorithm::RoundRobin => false,
}
}
只有 Redundant 模式会把 payload 字节复制到 Frame::Stream { data } 字段中,这份数据在 on_sent() 重注时被克隆到其他路径的缓冲队列。MinRtt 和 RoundRobin 不需要跨路径重注,所以不保留 payload,节省内存。
三优先级缓冲队列
冗余重注帧使用 BufferType::High 优先级。BufferQueue 内部维护三个 VecDeque(space.rs:448-492):
// src/connection/space.rs:447-492
pub struct BufferQueue {
queues: [VecDeque<frame::Frame>; 3], // [High, Mid, Low]
count: usize,
}
impl BufferQueue {
pub fn pop_front(&mut self) -> Option<(frame::Frame, BufferType)> {
for (i, queue) in self.queues.iter_mut().enumerate() {
if !queue.is_empty() {
self.count -= 1;
return Some((queue.pop_front().unwrap(), BufferType::from(i)));
}
}
None
}
}
pop_front() 从 High 队列开始取,High 空了才取 Mid,Mid 空了才取 Low。冗余重注帧用 High 优先级,确保它们优先于普通控制帧发出,最大化冗余发送的时效性。
多路径包号空间隔离
两条路径发出的包有以下特征:
- STREAM frame 完全相同:相同的 stream_id、相同的 offset、相同的 data(控制指令的字节序列)
- QUIC 包头不同:不同的 Destination Connection ID(DCID),不同的 Packet Number
Packet Number 的独立性来自 SpaceId::DataExt(space.rs:47):
// src/connection/space.rs:36-48
pub enum SpaceId {
Initial = 0,
Handshake = 1,
Data = 2,
DataExt(u64), // 多路径下每条额外路径独立的包号空间
}
多路径模式下,第一条路径用 SpaceId::Data,每条额外路径用 SpaceId::DataExt(path_id),各自维护独立的 next_pkt_num。两路同一份数据的 Packet Number 完全独立递增,互不干扰。
# 在 T-Box 侧验证两路包的内容相同(packet 级别的验证)
# 分别抓两路 QUIC 包并保存
tcpdump -i rmnet0 -w /tmp/sim1.pcap udp port 443 &
tcpdump -i rmnet1 -w /tmp/sim2.pcap udp port 443 &
sleep 10
kill %1 %2
# 对比两路的包大小分布(冗余发送时应相同)
tshark -r /tmp/sim1.pcap -Y "quic" -T fields -e frame.len 2>/dev/null | \
sort | uniq -c | sort -rn > /tmp/pktsize_sim1.txt
tshark -r /tmp/sim2.pcap -Y "quic" -T fields -e frame.len 2>/dev/null | \
sort | uniq -c | sort -rn > /tmp/pktsize_sim2.txt
echo "=== rmnet0 包大小分布 ==="
head -5 /tmp/pktsize_sim1.txt
echo "=== rmnet1 包大小分布 ==="
head -5 /tmp/pktsize_sim2.txt
# 两路分布相同(误差来自 PATH_CHALLENGE 和 PING 等控制包)
第三章:去重机制——RecvBuf 的 BTreeMap 区间表
云端去重的核心工程问题是:用什么数据结构记录"已交付的数据范围",以便检测重复?
首先要明确:不能用 Packet Number 去重。MPQUIC 的每条路径有独立的 PN space(SpaceId::DataExt),两路同一份数据的 PN 不同,用 PN 去重检测不到重复,反而会把两份数据都交付给应用层。
TQUIC 的正确答案是:基于 QUIC STREAM 的 offset 区间表,用 BTreeMap<u64, RangeBuf> 实现。
RecvBuf 的数据结构
接收端的 stream 重组模块是 RecvBuf(stream.rs:2000-2031):
// src/connection/stream.rs:2000-2031
/// Receive-side stream buffer.
///
/// The stream data received from peer is buffered in a BTreeMap ordered by
/// offset in ascending order. Contiguous data can then be read into a slice.
pub struct RecvBuf {
/// Chunks of data received from the peer ordered by offset
/// but have not yet been read by the application.
/// Note: The key is the maximum offset of the chunk, not the lowest.
data: BTreeMap<u64, RangeBuf>,
/// The lowest data offset that has yet to be read by the application.
read_off: u64,
/// The largest data offset that has been received on this stream.
recv_off: u64,
// ...
}
关键设计:data 是一个 BTreeMap<u64, RangeBuf>,key 是每个数据块的最大偏移(max_off = off + len),不是起始偏移。这个选择使得范围查询更高效——通过 range(buf.off()..) 可以快速找到所有可能与新到数据块重叠的已有块。
RangeBuf 是一个带偏移量的数据块(stream.rs:2889-2906):
// src/connection/stream.rs:2889-2906
pub struct RangeBuf {
data: Bytes, // 实际字节数据
off: u64, // 在 stream 中的起始偏移
fin: bool, // 是否是流的最后一块
pub time: Instant,
}
RangeBuf 的区间是 [off, off + data.len()),即 [off, max_off)。
RecvBuf::write() 的四种重叠处理
当冗余发送的第二份副本到达时,RecvBuf::write() 负责检测并丢弃重复数据(stream.rs:2043-2162):
// src/connection/stream.rs:2092-2162(精简版)
pub fn write(&mut self, offset: u64, data: Bytes, fin: bool) -> Result<()> {
let buf = RangeBuf::new(data, offset, fin);
// 情况1:完全已读——新块的最大偏移 <= 已读位置,直接丢弃
if self.read_off >= buf.max_off() {
if !buf.is_empty() {
return Ok(()); // 早已交付给应用层,直接丢弃
}
}
let mut tmp_bufs = VecDeque::with_capacity(2);
tmp_bufs.push_back(buf);
'outer_loop: while let Some(mut buf) = tmp_bufs.pop_front() {
// 跳过已读部分
if self.read_off() > buf.off() {
buf.advance((self.read_off() - buf.off()) as usize);
}
if buf.off() < self.recv_off() || buf.is_empty() {
for (_, b) in self.data.range(buf.off()..) {
if b.off() > buf.max_off() {
break; // 后续块不可能重叠
}
// 情况2:完全被覆盖——新块区间完全在已有块内,丢弃
else if off >= b.off() && buf.max_off() <= b.max_off() {
continue 'outer_loop;
}
// 情况3:前半重叠——新块起点在已有块内,跳过重叠部分
else if off >= b.off() && off < b.max_off() {
buf.advance((b.max_off() - off) as usize);
}
// 情况4:后半重叠——新块跨越已有块,分割处理
else if off < b.off() && buf.max_off() > b.off() {
tmp_bufs.push_back(buf.split_off((b.off() - off) as usize));
}
}
}
// 更新 recv_off 并插入
self.recv_off = cmp::max(self.recv_off, buf.max_off());
self.data.insert(buf.max_off(), buf);
}
Ok(())
}
四种重叠情况对应冗余发送的不同到达场景:
| 情况 | 场景 | 处理方式 |
|---|---|---|
| 完全已读 | 副本到达时数据已被应用读走 | return Ok(()) 直接丢弃 |
| 完全被覆盖 | 副本区间完全在已收到的块内 | continue 'outer_loop 跳过 |
| 前半重叠 | 副本起点在已有块内 | buf.advance() 跳过重叠字节 |
| 后半重叠 | 副本跨越已有块边界 | buf.split_off() 分割,前半插入,后半继续处理 |
这套机制的设计优雅之处:它是 QUIC STREAM 协议本身对重叠帧的处理要求(RFC 9000 §2.2 规定接收端必须能处理重叠的 STREAM 帧),冗余发送天然复用了这个机制,不需要任何额外的去重逻辑。应用层调用 tquic_conn_stream_recv() 只会看到数据一次,完全透明。
与 MPTCP 冗余模式的对比:MPTCP 的冗余模式需要在接收端处理 DSN(数据序列号,连接级)和 SSN(子流序列号,子流级)两层编号的对应关系,在接收端要做跨子流的 DSN 层去重,实现相对复杂。QUIC 的去重通过 STREAM offset 天然实现,不需要额外的跨路径 ID 映射逻辑,这是 QUIC 架构设计上的一个优雅之处。
BTreeMap 的内存特性
RecvBuf 只保留"已收到但未被应用读走"的数据块。一旦应用层读走数据(read_off 推进),对应的 RangeBuf 会从 data 中移除,内存自动释放。在 100pps 的控制信号场景下,每条 stream 的 data 中最多只有少量几个待读的 RangeBuf,内存占用极低。
两路 RTT 差值最大约 170ms,在此窗口内副本才可能到达。超过 170ms 后到的副本,其 offset 区间早已被应用读走(read_off 已推进),命中"情况1:完全已读",直接 return Ok(()),不占用任何内存。
# 用 tc netem 模拟两路延迟差,验证去重逻辑正确性
# 给 4G 路径(eth0,模拟 T-Box 的 rmnet0)增加 70ms 延迟
# 让 5G 路径(eth1,模拟 T-Box 的 rmnet1)不加延迟
# 这样 5G 先到,4G 延迟 70ms 后到,应该被去重丢弃
ip link add ifb0 type ifb && ip link set ifb0 up
tc qdisc add dev eth0 ingress
tc filter add dev eth0 parent ffff: protocol all u32 match u32 0 0 \
action mirred egress redirect dev ifb0
tc qdisc add dev ifb0 root netem delay 70ms
# 注意:netem 加在网关的入方向,模拟 4G 路径的额外延迟
# 发送冗余数据,检查应用层收到的次数
# 正确情况:每条控制指令只被应用层处理一次
grep "ctrl_cmd_received" /var/log/ctrl-relay-gw.log | \
awk '{print $NF}' | sort | uniq -c | sort -rn | head -10
# 预期:每个 cmd_id 出现 1 次(去重正确),如果出现 2 次说明去重失效
# 清理
tc qdisc del dev ifb0 root && tc qdisc del dev eth0 ingress && ip link del ifb0
第四章:乱序处理——两路 RTT 差异的量化分析
即使去重逻辑完全正确,冗余发送还有一个乱序问题:两条路径的数据不是同时到达云端的。
4G 典型 RTT 50-150ms(极端 194ms),5G 典型 RTT 30-100ms(极端 154ms)。两路 RTT 的差值最大可达 100ms 以上。T-Box 在同一时刻从两条路径发出相同的控制指令,云端收到的时间取决于各自路径的单向延迟(约 RTT/2):
- 5G 单向延迟约 15-50ms
- 4G 单向延迟约 25-97ms
- 最大到达时间差约 70-80ms(单向延迟的差值,不是 RTT 的差值)
这意味着在 70-80ms 的时间窗口内,"慢路径"的副本随时可能到达。RecvBuf 的 BTreeMap 必须在这段时间内保留已接收数据的 offset 区间记录,以便检测迟到的副本。
实际设计取 200ms 的有效窗口(考虑到极端 RTT 差值 194-154=40ms 的单向差值,加安全余量)。这个窗口由应用层的读取速度控制——应用层每次调用 tquic_conn_stream_recv() 读走数据后,read_off 推进,超出窗口的历史 offset 区间就不再保留。
TQUIC 对单条路径内部乱序的处理
5G 路径上,包序号 n+1 可能比包序号 n 先到达云端(因为路由路径不完全一样、不同包经过不同的核心网队列)。RecvBuf::write() 中有一个关键步骤处理这种情况:
// stream.rs:2111-2113
if self.read_off() > buf.off() {
buf.advance((self.read_off() - buf.off()) as usize);
}
以及最终的插入:
// stream.rs:2151-2158
self.recv_off = cmp::max(self.recv_off, buf.max_off());
if !self.shutdown {
self.data.insert(buf.max_off(), buf);
}
BTreeMap 以 max_off 为 key,天然有序。乱序到达的数据块会被正确插入到 BTreeMap 的对应位置,等待之前的空洞被填满后按序交付给应用层(有序模式)。冗余发送引入的"来自不同路径的相同 offset"这种新型乱序,被 write() 的重叠处理逻辑透明地消化掉,不需要额外的处理逻辑。
对控制信号场景的特殊优化:unordered 模式
视频流等对顺序敏感的 stream 需要"有序交付"——空洞不填满不能交付(否则视频帧乱序)。控制信号场景正好相反:每条控制指令是独立的命令,自带序号(应用层序号),云控平台按应用层序号处理,不依赖 QUIC STREAM 的字节连续性。
这意味着可以配置 QUIC STREAM 的"无序交付"(unordered delivery):空洞不阻塞,直接把收到的数据交给应用层,不等待之前编号的数据填满空洞:
// 对控制信号 stream 使用无序模式(应用层能处理乱序)
// 这样即使 5G 路径的包 n 比包 n-1 先到,也立即交付,不等 n-1
// 注意:视频流等不能用这个模式!
tquic_conn_stream_set_unordered(conn, ctrl_stream_id, true);
在无序模式下,去重窗口只需要覆盖"两路最大到达时间差"(约 80ms),而不是"等待最慢路径所有包都到齐"的时间。这进一步减少了云端的内存需求。
极端情况:两路延迟差超过 200ms(极端网络条件),先到的包已被应用层处理,后到的包进入 RecvBuf::write() 时命中"完全已读"分支(read_off >= buf.max_off()),直接 return Ok(())。对于 <250ms SLA 的控制信号,这是正确的行为(250ms 前发的指令对应的响应本来就超时了)。
# 统计两路到达时间差(从云端 qlog 提取)
import json
path0_times = {}
path1_times = {}
with open("server.qlog") as f:
data = json.load(f)
for e in data["traces"][0]["events"]:
if e.get("name") == "transport:packet_received":
path_id = e.get("data", {}).get("path_id", 0)
# 用 STREAM frame 的 offset 作为"相同数据"的 key
frames = e.get("data", {}).get("frames", [])
for f_item in frames:
if f_item.get("frame_type") == "stream":
key = (f_item.get("stream_id"), f_item.get("offset"))
t = e.get("time", 0)
if path_id == 0:
path0_times[key] = t
else:
path1_times[key] = t
# 计算到达时间差
arrival_diffs = []
for key in path0_times:
if key in path1_times:
diff = abs(path0_times[key] - path1_times[key])
arrival_diffs.append(diff)
if arrival_diffs:
arr = sorted(arrival_diffs)
print(f"两路到达时间差统计 ({len(arr)} 个样本):")
print(f" avg={sum(arr)/len(arr):.1f}ms")
print(f" P50={arr[int(len(arr)*0.5)]:.1f}ms")
print(f" P95={arr[int(len(arr)*0.95)]:.1f}ms")
print(f" P99={arr[int(len(arr)*0.99)]:.1f}ms")
print(f" Max={max(arr):.1f}ms")
第五章:P99 延迟改善的量化:冗余发送真的值得吗?
用数字来回答"冗余发送值不值"的问题。
数学模型:设 path1(4G)的丢包率为 p1,path2(5G)的丢包率为 p2。在两路信道完全独立的假设下,双路冗余发送的"有效丢包率"(两路都丢)= p1 × p2。
真实场景数字:城区测试路段,4G 丢包率 2%,5G 丢包率 3%:
- 单路 5G 发送,丢包率 = 3%
- 双路冗余,有效丢包率 = 2% × 3% = 0.06%(50 倍改善)
P99 延迟的具体变化(基于路测数据,30 分钟测试时长):
| 指标 | 单路5G发送 | 双路冗余发送 | 改善幅度 |
|---|---|---|---|
| P50 延迟 | 58ms | 52ms | -6ms |
| P99 延迟 | 242ms | 93ms | -149ms |
| P999 延迟 | 380ms | 185ms | -195ms |
| SLA 违约率 | 2.4% | 0.06% | -98% |
P99 从 242ms 降到 93ms,改善了 149ms。这 149ms 的来源分析:
- 原本需要等待一次重传:约 60ms(RTT)+ 排队延迟(约 20-60ms)= 80-120ms
- 冗余发送后,即使 5G 丢包,4G 的副本依然如期到达,不需要等重传
- 149ms = 避免了重传的等待 + 去掉了部分排队延迟
重要限制:两路信道独立性假设
P(双路丢包) = P(4G丢包) × P(5G丢包) 的公式成立需要两个前提:
- 4G 和 5G 的丢包是独立随机事件(不相关)
- 两条路径不共享可能导致共同丢包的瓶颈
在实际场景中,这个假设在以下情况下会失效:
- 进入隧道:物理遮挡,两路同时断,相关系数接近 1
- 4G/5G NSA 组网:5G NR 通道借用 4G 核心网,当 4G 核心网出现故障时,两路同时受影响
- 城区共站:4G 和 5G 的基站共站部署,共享回程链路,出现拥塞时两路同时劣化
在独立性失效的场景下,冗余发送的可靠性提升会打折。这也是为什么第10篇要讲"动态冗余"——在信道相关性高(进隧道、共站区域)时,继续双发只是浪费带宽,并不能显著提升可靠性。
带宽代价:100pps × 1KB/包 = 100KB/s 的控制信号,冗余发送后变成 200KB/s。在 4G 专线限速场景,这个额外的 100KB/s 可能触发 policer——这是第10篇要重点讨论的问题。
# 计算不同丢包率场景下的冗余改善效果
def calc_redundant_improvement(p1, p2):
"""
p1: 4G 路径丢包率
p2: 5G 路径丢包率
返回:单路最优 vs 双路冗余的丢包率对比
"""
single_best = min(p1, p2) # 单路选最好的那条
redundant = p1 * p2 # 双路冗余的有效丢包率(独立信道假设)
improvement = single_best / redundant # 改善倍数
return single_best, redundant, improvement
scenarios = [
("城区良好", 0.005, 0.008),
("城区一般", 0.02, 0.03),
("郊区波动", 0.05, 0.08),
("基站切换", 0.10, 0.15),
("隧道(相关性高)", 0.20, 0.20), # 相关时,冗余效果差
]
print(f"{'场景':<15} {'单路最优丢包率':>12} {'冗余丢包率':>10} {'改善倍数':>8}")
for name, p1, p2 in scenarios:
single, redundant, improvement = calc_redundant_improvement(p1, p2)
# 隧道场景:两路相关,实际冗余效果远差于理论值
if "隧道" in name:
actual_redundant = p1 * 0.9 # 假设相关系数=0.9,实际接近单路
print(f"{name:<15} {single:>12.1%} {actual_redundant:>10.1%} {'<2倍(相关)':>8}")
else:
print(f"{name:<15} {single:>12.1%} {redundant:>10.1%} {improvement:>7.0f}倍")
第六章:两路同时丢包时的降级策略
冗余发送的美好建立在"至少一路可用"的假设上。三种场景会破坏这个假设:
场景一:进隧道
物理遮挡,两路信号同时断,冗余发送无效。QUIC 会持续尝试重传,但在隧道里没有任何信号,重传也没用。
处理策略:
- 检测条件:两路 QUIC 路径连续 N 个包(推荐 N=5)在 T 毫秒内都没有收到 ACK(T < 250ms SLA = 200ms)
- 降级行为:停止发送新控制指令,等待任意一路恢复;触发告警上报
- 恢复条件:任意一路收到 ACK,立即恢复发送
场景二:同基站拥塞
4G 和 5G 共站部署,共享回程链路拥塞。两路丢包高度同步。
这种情况的检测更难——因为两路都有信号,RSSI 正常,但丢包率同步飙升。可以通过计算两路丢包事件的"时间窗口内相关性"来识别:
// 计算两路丢包的相关性(简化版)
float calc_path_correlation(uint64_t path0_loss_mask, uint64_t path1_loss_mask) {
// 计算两路在相同时间窗口内的丢包重合度
int both_lost = __builtin_popcountll(path0_loss_mask & path1_loss_mask);
int either_lost = __builtin_popcountll(path0_loss_mask | path1_loss_mask);
if (either_lost == 0) return 0.0f;
return (float)both_lost / either_lost; // 重合度:0=完全独立,1=完全相关
}
// 当相关性 > 0.7 时,冗余发送效果打折,考虑关闭冗余(节省带宽)
场景三:运营商网络故障
较少见但确实发生过(运营商核心网节点故障,影响特定区域的 4G 和 5G 服务同时中断)。处理方式与进隧道相同。
通用降级策略的完整实现:
typedef enum {
REDUNDANT_STATE_OK, // 双路正常,冗余有效
REDUNDANT_STATE_WARN, // 某路丢包,但另一路正常(冗余保护生效)
REDUNDANT_STATE_FAIL, // 双路都丢包,冗余失效
} redundant_health_t;
#define DUAL_LOSS_PKT_THRESHOLD 5 // 连续 5 个包两路都丢
#define DUAL_LOSS_TIME_THRESHOLD 200 // 200ms 内两路都未 ACK(< 250ms SLA)
typedef struct {
int path0_consecutive_loss;
int path1_consecutive_loss;
float path0_loss_rate_1s; // 最近 1 秒的丢包率
float path1_loss_rate_1s;
} path_health_stats_t;
redundant_health_t check_redundant_health(const path_health_stats_t *stats) {
bool path0_bad = (stats->path0_consecutive_loss >= DUAL_LOSS_PKT_THRESHOLD);
bool path1_bad = (stats->path1_consecutive_loss >= DUAL_LOSS_PKT_THRESHOLD);
if (path0_bad && path1_bad) {
return REDUNDANT_STATE_FAIL; // 双路都挂了
}
if (path0_bad || path1_bad) {
return REDUNDANT_STATE_WARN; // 一路挂,另一路在保护
}
return REDUNDANT_STATE_OK;
}
void on_redundant_fail(conn_context_t *conn_ctx) {
log_error("CRITICAL: Both paths lost! Stopping control signal transmission.");
// 1. 停止新控制指令的发送(等恢复)
conn_ctx->send_blocked = true;
// 2. 触发告警:通过独立的状态上报机制(如 SMS backup 或 OBD 诊断口)
// 注意:告警本身不走 QUIC,因为 QUIC 已经挂了
report_critical_alert(conn_ctx->vehicle_id, ALERT_DUAL_PATH_LOSS);
// 3. 设置恢复检测定时器
conn_ctx->recovery_check_time_ms = current_time_ms() + 1000;
}
void check_recovery(conn_context_t *conn_ctx) {
if (!conn_ctx->send_blocked) return;
if (current_time_ms() < conn_ctx->recovery_check_time_ms) return;
// 检查是否有任意一路收到了 ACK
if (conn_ctx->path0_acked_recently || conn_ctx->path1_acked_recently) {
log_info("Path recovery detected, resuming transmission");
conn_ctx->send_blocked = false;
// 立即重发错过的控制指令(应用层维护重发队列)
trigger_pending_retransmit(conn_ctx);
} else {
// 还没恢复,延迟 1 秒再检查
conn_ctx->recovery_check_time_ms = current_time_ms() + 1000;
}
}
# 模拟两路同时断网,验证降级告警触发
# 在 T-Box 上同时 DROP 两路 UDP 出站流量
iptables -I OUTPUT -o rmnet0 -p udp --dport 443 -j DROP
iptables -I OUTPUT -o rmnet1 -p udp --dport 443 -j DROP
# 等待 5 个包超时(约 150ms × 5 = 750ms,加上网络延迟实际约 2-3 秒可以看到告警)
sleep 3
# 检查告警日志
grep -E "DUAL_PATH_LOSS|dual_path_fail|redundant_fail" /var/log/tquic/client.log | tail -5
# 预期:看到 CRITICAL 级别告警,包含 vehicle_id 和时间戳
# 恢复后验证冗余发送重新启动
iptables -D OUTPUT -o rmnet0 -p udp --dport 443 -j DROP
iptables -D OUTPUT -o rmnet1 -p udp --dport 443 -j DROP
sleep 2
grep -E "path.*recover|redundant.*restore|send.*resumed" /var/log/tquic/client.log | tail -5
# 预期:看到恢复通知,控制信号重新发送
补充:冗余发送与 QUIC 拥塞控制的关系
冗余发送在提升可靠性的同时,也改变了 QUIC 拥塞控制的感知环境,这个交互关系值得单独讨论。
BBR 在冗余发送场景下的行为
QUIC 默认使用的拥塞控制算法是 BBR(Bottleneck Bandwidth and Round-trip propagation time)。BBR 通过持续测量带宽和 RTT 来估算链路的最大可用带宽,然后以略低于这个带宽的速率发送。
在冗余发送场景下,BBR 看到的是两条路径上分别独立的 ACK 反馈。path_id=0(4G)的 BBR 实例根据 4G 路径的 ACK 调整发送速率,path_id=1(5G)的 BBR 实例根据 5G 路径的 ACK 调整发送速率。由于两条路径发送相同的数据,接收端可能同时对两路 ACK(因为两路都收到了),或者只 ACK 先到的那路(因为后到的那路被 RecvBuf::write() 的重叠检测直接丢弃,不触发新的 ACK)。
后一种情况(只 ACK 先到的)会让慢路径(4G)看起来丢包率极高——因为 4G 的包几乎每次都"被 5G 抢先了",4G 的 BBR 实例看到大量"未 ACK 的包",会认为 4G 链路拥塞严重,大幅降低 4G 的发送速率。
这看起来是个问题,但实际上是有意为之的合理行为:慢路径(4G)本来就没必要以 5G 相同的速率发送(因为 5G 会先到,4G 的副本大概率被去重)。如果 4G 降速了,减少了带宽消耗,而 5G 正常,整体效果是冗余发送在带宽受限时自然降级为"主要走 5G,偶尔走 4G 兜底"——这正是动态冗余策略第10篇要讨论的方向。
但如果 5G 丢包率高(5G 质量变差),5G 的 BBR 实例会降速,此时 4G 的 BBR 实例(已经降速)可能来不及补充,导致整体吞吐量暂时下降。这是冗余发送下拥塞控制协调的已知问题,MPQUIC 草案在 draft-10 中新增了"多路径拥塞控制协调"的 Appendix,但 TQUIC 基于 draft-07,还没有合入这个特性。
实际影响和规避
对于控制信号(100pps、小包)场景,这个问题的影响很有限:控制信号的带宽远低于链路容量,BBR 不处于带宽限制状态,拥塞控制算法的行为对延迟影响微乎其微。主要需要关注的是大包传输场景(比如配置文件分发、OTA 更新预下载),这些场景建议不走 MPQUIC 冗余发送。
延迟收益的真实来源
冗余发送的 P99 延迟改善,有一个细节容易被忽略:改善来自于避免了重传等待,而不是来自于选择了更快的路径。
在 5G 丢包率 18% 的场景:
- 没有冗余:某个包丢失 → 等待 60ms 重传 → 重传包到达(共 120ms)
- 有冗余:5G 的包丢失 → 4G 的副本仍然如期 50-80ms 后到达(共 50-80ms)
改善的来源是"不等重传",不是"4G 比 5G 更快"(4G 通常比 5G 慢)。这也解释了为什么冗余发送对 P50 延迟的改善有限(P50 场景通常没有丢包,最快的路径直接到达),而对 P99、P999 的改善显著(这些尾部场景恰好是丢包重传的那些包)。
回到文章开头那次 322ms 的延迟尖峰。事后在下一个版本启用了冗余发送(MultipathAlgorithm::Redundant),同样的城区路段,同样的 5G 切换区域,5G 丢包率 18%,P99 延迟:93ms。
但随即发现了一个新问题:云控平台工程师收到报告,某辆车在测试期间执行了两次相同的转向命令。排查发现是云端网关的去重逻辑有 race condition——两路包同时到达,RecvBuf::write() 的写操作没有加锁,两条线程各自以为对方没有处理,都把数据交付给了应用层。
这是云控网关侧的接收架构问题,也是下一篇(第09篇)要深入讨论的话题:单进程多路径还是多线程 per-connection?以及那个让 5000 辆车把单核 CPU 打满的真实锁竞争 bug。