冗余发送的"零感知"设计:两个 SIM 卡同时发,云端如何去重不乱序?

0 阅读27分钟

那是一条普通的城区道路,车辆以 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 1t=60ms:   收到 pn=103 的 ACK(乱序 ACK 2t=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() 重注时被克隆到其他路径的缓冲队列。MinRttRoundRobin 不需要跨路径重注,所以不保留 payload,节省内存。

三优先级缓冲队列

冗余重注帧使用 BufferType::High 优先级。BufferQueue 内部维护三个 VecDequespace.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::DataExtspace.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 重组模块是 RecvBufstream.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 的时间窗口内,"慢路径"的副本随时可能到达。RecvBufBTreeMap 必须在这段时间内保留已接收数据的 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);
}

BTreeMapmax_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 延迟58ms52ms-6ms
P99 延迟242ms93ms-149ms
P999 延迟380ms185ms-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丢包) 的公式成立需要两个前提:

  1. 4G 和 5G 的丢包是独立随机事件(不相关)
  2. 两条路径不共享可能导致共同丢包的瓶颈

在实际场景中,这个假设在以下情况下会失效:

  • 进入隧道:物理遮挡,两路同时断,相关系数接近 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 会持续尝试重传,但在隧道里没有任何信号,重传也没用。

处理策略:

  1. 检测条件:两路 QUIC 路径连续 N 个包(推荐 N=5)在 T 毫秒内都没有收到 ACK(T < 250ms SLA = 200ms)
  2. 降级行为:停止发送新控制指令,等待任意一路恢复;触发告警上报
  3. 恢复条件:任意一路收到 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。