集成了三天,TQUIC 连接跑通了,qlog 显示两条路径都是 active 状态,工程师翻了又翻,确认 path_id=0 和 path_id=1 都出现过 path_updated 事件。他打开两个终端,一个跑 tcpdump -i rmnet0 udp port 443,一个跑 tcpdump -i rmnet1 udp port 443。
rmnet0 的 tcpdump 滚得飞快,每秒几十行。rmnet1 的 tcpdump 安静得像被遗忘了一样。
不对劲。明明两条路径都 active,为什么数据只走一条?他开始排查防火墙规则,检查路由表,最后在 TQUIC 的 GitHub issue 里找到了答案:
"You need to call
quic_config_set_multipath_algorithm(config, QUIC_MULTIPATH_ALGORITHM_REDUNDANT). Adding a path doesn't change the scheduler."
这个坑,凡是第一次集成 MP-QUIC 的工程师,大概率都会踩。它不是文档写错了,而是文档没把"路径管理"和"调度策略"这两个正交概念讲清楚。本文的目标是弥补这个空白——基于 TQUIC develop 分支源码,把冗余调度器的真实工作机制、完整 API 调用链、以及三个最容易踩的陷阱一次性讲清楚。
第一章:冗余调度器的真实工作机制
在讲 API 之前,先把 RedundantScheduler 的内部机制搞清楚。很多工程师以为冗余发送是"同时往两条路径发同一个包",这个理解不准确。
1.1 调度器的两个核心回调
TQUIC 的调度器接口定义在 src/multipath_scheduler/multipath_scheduler.rs:
pub(crate) trait MultipathScheduler {
// 选择一条路径发送新数据包
fn on_select(&mut self, paths, spaces, streams) -> Result<usize>;
// 数据包发出后的通知(默认空实现)
fn on_sent(&mut self, packet, now, path_id, paths, spaces, streams) {}
// 路径状态变化通知(默认空实现)
fn on_path_updated(&mut self, paths, event) {}
}
RedundantScheduler 实现了其中两个:on_select 和 on_sent。
1.2 on_select:选第一条能发包的路径
// src/multipath_scheduler/scheduler_redundant.rs
fn on_select(&mut self, paths, spaces, streams) -> Result<usize> {
for (pid, path) in paths.iter_mut() {
// 跳过未激活或拥塞窗口不足的路径
if !path.active() || !path.recovery.can_send() {
continue;
}
return Ok(pid); // 返回第一条满足条件的路径
}
Err(Error::Done)
}
on_select 只返回一条路径 id。发送循环用这条路径发出原始数据包。
1.3 on_sent:把帧注入其他路径的缓冲队列
原始包发出后,on_sent 立即被调用:
fn on_sent(&mut self, packet, now, path_id, paths, spaces, streams) {
// 如果这个包本身是从缓冲队列发出的(副本),不再二次注入
if packet.buffer_flags.has_buffered() {
return;
}
// 遍历所有其他活跃路径
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,
};
// 把 STREAM 帧克隆,注入该路径的 High 优先级缓冲队列
for frame in &packet.frames {
if let Frame::Stream { .. } = frame {
space.buffered.push_back(frame.clone(), BufferType::High);
}
}
}
}
关键点:on_sent 不是"立即发包",而是把 STREAM 帧克隆后放进其他路径的 space.buffered 队列(High 优先级)。
1.4 buffered 队列:副本的发出时机
下一次发包循环中,try_write_buffered_frames 会从 buffered 队列取出帧,写入新的包发出:
// connection.rs:发包流程中会调用这个函数
fn try_write_buffered_frames(&mut self, out, st, pkt_type, path_id) -> Result<()> {
// 只在 MPQUIC 启用时生效
if !self.flags.contains(EnableMultipath) { return Ok(()); }
let space = self.spaces.get_mut(path.space_id)?;
if space.buffered.is_empty() { return Ok(()); }
while let Some((frame, buffer_type)) = space.buffered.pop_front() {
match frame {
Frame::Stream { stream_id, offset, length, fin, data } => {
// 检查该 offset 区间是否已经被 ACK(去重)
if let Some(r) = stream.send.filter_acked(offset..offset+length) {
Self::write_buffered_stream_frame_to_packet(...)?;
}
}
_ => continue, // 只处理 STREAM 帧
}
}
Ok(())
}
完整流程:
发包循环触发
│
├─ on_select() → 选出 path_id=0(4G)
│
├─ 写 STREAM 帧到包(同时保留数据副本,见 1.5)
│
├─ 通过 path_id=0 发出包(packet_number=N on path 0)
│
├─ on_sent() 被调用 → 把 STREAM 帧注入 path_id=1 的 buffered 队列
│
└─ 下次发包循环(通常是同一轮 endpoint_send 的下一次迭代)
│
├─ path_id=1 有 buffered 帧 → try_write_buffered_frames()
└─ 通过 path_id=1 发出副本(packet_number=M on path 1,独立 PN)
结论:冗余发送不是"同时发",而是"原始包发出后,副本在下一次发包机会发出"。两个包的 packet number 属于各自路径的独立 PN space,互不相关。
1.5 为什么 Redundant 模式需要 buffer_required
在写 STREAM 帧到包时,connection.rs 会检查是否需要保留数据副本:
// connection.rs
let data = if self.flags.contains(EnableMultipath)
&& buffer_required(self.multipath_conf.multipath_algorithm)
{
// Redundant 模式:保留数据副本,供 on_sent 注入其他路径
Bytes::copy_from_slice(&out[start..start + frame_data_len])
} else {
Bytes::new() // 其他模式:不保留副本,节省内存
};
buffer_required 的返回值:
pub(crate) fn buffer_required(algor: MultipathAlgorithm) -> bool {
match algor {
MultipathAlgorithm::MinRtt => false,
MultipathAlgorithm::Redundant => true, // 只有冗余模式需要
MultipathAlgorithm::RoundRobin => false,
}
}
这意味着:如果没有设置 REDUNDANT 调度器,data 字段是空的 Bytes::new(),on_sent 注入的 Frame::Stream 没有数据内容,副本发不出去。这就是"只设置了 enable_multipath 但没有设置调度器"时静默失败的根本原因。
第二章:冗余发送 API 的完整调用链
把这一章当作 checklist——五个步骤,跳过任何一步都会导致冗余发送不生效。
步骤 1:Config 阶段——两个 API 必须同时调用
quic_config_t *config = quic_config_new();
// ① 启用 MPQUIC 协议能力(握手时带 enable_multipath transport parameter)
quic_config_enable_multipath(config, true);
// ② 设置调度器为冗余模式(*** 这行缺失就是文章开头那个坑 ***)
// 不设置这行,默认调度器是 MinRtt——选延迟最低的单条路径,不双发
quic_config_set_multipath_algorithm(config, QUIC_MULTIPATH_ALGORITHM_REDUNDANT);
quic_config_enable_multipath 只影响协议握手(Transport Parameter),不影响调度行为。
quic_config_set_multipath_algorithm 才决定数据怎么分配到路径上。
两者正交,必须同时设置。
步骤 2:建立连接,主路径自动创建
// local_addr1 必须是 rmnet0 的实际 IP(需 SO_BINDTODEVICE,见第07篇)
quic_conn_t *conn = quic_endpoint_connect(
endpoint,
(struct sockaddr *)&local_addr1, sizeof(local_addr1),
(struct sockaddr *)&remote_addr, sizeof(remote_addr),
server_name,
NULL, 0 // session_data(0-RTT,初次连接传 NULL)
);
主路径(path_id=0)在 quic_endpoint_connect() 时自动创建,绑定到 local_addr1。
步骤 3:握手完成后添加第二条路径(陷阱 1 的位置)
// 在 on_handshake_complete 回调中调用,不要在 on_connected 中调用
void on_handshake_complete(quic_conn_t *conn, void *ctx) {
my_ctx_t *myctx = (my_ctx_t *)ctx;
uint64_t path_index = 0;
int ret = quic_conn_add_path(
conn,
(struct sockaddr *)&myctx->local_addr2, // rmnet1 的 IP
sizeof(myctx->local_addr2),
(struct sockaddr *)&myctx->remote_addr,
sizeof(myctx->remote_addr),
&path_index // 返回新路径的 index(可选)
);
if (ret != 0) {
// 降级为单路径,等待下次重连
log_error("add_path failed: %d", ret);
}
}
陷阱 1:add_path 必须在握手完成后调用
TQUIC 源码中 add_path 的前置检查:
// connection.rs
pub fn add_path(&mut self, local_addr, remote_addr) -> Result<u64> {
if self.is_server {
return Err(Error::InvalidOperation("disallowed".into()));
}
if !self.flags.contains(HandshakeCompleted) {
return Err(Error::InvalidOperation("disallowed".into()));
}
// ...
}
HandshakeCompleted 标志只有在收到 HANDSHAKE_DONE 帧后才置位。在 on_connected 回调触发时,这个标志可能还没有置位(差约一个 RTT)。如果在 on_connected 中调用 add_path,会返回 InvalidOperation 错误。
步骤 4:等待第二条路径探测完成
add_path 内部调用 path.initiate_path_chal(),触发 PATH_CHALLENGE 发送。PATH_CHALLENGE 往返约 1 个 RTT(4G 典型 80-150ms)。
在此期间,path_id=1 处于 VALIDATING 状态:on_select 会跳过它(!path.active()),buffered 副本不会发到这条路径。
路径验证完成后,on_path_resp_received 返回 true,connection.rs 调用:
if self.paths.on_path_resp_received(path_id, data) {
if let Some(ref mut scheduler) = self.multipath_scheduler {
scheduler.on_path_updated(&mut self.paths, PathEvent::Validated(path_id));
}
}
此时 path_id=1 进入 active 状态,冗余发送开始真正生效。
步骤 5:epoll 同时监听两个 socket(陷阱 2 的位置)
陷阱 2:只监听一个 socket,PATH_RESPONSE 被漏掉
双路径意味着 T-Box 有两个 UDP socket(sock1 绑定 rmnet0,sock2 绑定 rmnet1)。云端回来的包(PATH_RESPONSE、ACK、数据包)分别到达这两个 socket。
// ❌ 错误:只监听 sock1,漏掉 sock2 上的 PATH_RESPONSE
while (1) {
ssize_t len = recvfrom(sock1, buf, sizeof(buf), 0, &peer, &peer_len);
quic_endpoint_recv(endpoint, buf, len, &local1, local1_len, &peer, peer_len);
}
// ✅ 正确:epoll 同时监听两个 fd
int epfd = epoll_create1(0);
struct epoll_event ev1 = {.events = EPOLLIN | EPOLLET, .data.fd = sock1};
struct epoll_event ev2 = {.events = EPOLLIN | EPOLLET, .data.fd = sock2};
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev1);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev2);
struct epoll_event events[2];
uint8_t buf[2048];
while (1) {
int n = epoll_wait(epfd, events, 2, 10); // 10ms 超时触发 TQUIC 定时器
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
struct sockaddr_storage peer, local;
socklen_t peer_len = sizeof(peer), local_len = sizeof(local);
getsockname(fd, (struct sockaddr *)&local, &local_len);
ssize_t len = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr *)&peer, &peer_len);
if (len > 0) {
// 两个 fd 共用同一个 endpoint,TQUIC 按 CID 路由到正确连接
quic_endpoint_recv(endpoint, buf, len,
(struct sockaddr *)&local, local_len,
(struct sockaddr *)&peer, peer_len);
}
}
quic_endpoint_on_timeout(endpoint);
}
漏掉 PATH_RESPONSE 的典型症状:qlog 中出现三次 path_challenge_sent,没有对应的 path_response_received,然后 path_validation_failed。
# 验证冗余发送是否生效
tcpdump -i rmnet0 'udp port 443' -l -q 2>/dev/null | \
awk '{a++} END {print "rmnet0:", a}' &
tcpdump -i rmnet1 'udp port 443' -l -q 2>/dev/null | \
awk '{a++} END {print "rmnet1:", a}' &
sleep 10 && kill %1 %2 2>/dev/null; wait
# 冗余发送生效时,两个网卡包数量接近
# rmnet1 包数接近 0 → 调度器未设为 REDUNDANT,或陷阱 2 未修复
第三章:NAT 穿越——T-Box 一定在 CGNAT 后面
T-Box 的两张 SIM 卡都处于运营商 CGNAT 后面,这是国内公网 APN 的基础架构,不是配置问题。
3.1 CGNAT 会话超时导致路径"幽灵断线"
CGNAT 维护 UDP 会话表。一旦 T-Box rmnet1 超过一段时间没有发包,NAT 表项过期,云端直接发来的包(PATH_RESPONSE、ACK)被丢弃。
国内运营商公网 APN 的 CGNAT 超时时间(T-Box 实测,仅供参考,不同地区/时段可能不同):
- 某 4G 运营商公网 APN:30-60 秒
- 某 5G 运营商公网 APN:60-90 秒
- 企业专线 APN:通常 120-300 秒
30 秒意味着:低流量场景(车辆静止等待)下,rmnet1 路径会周期性地"静默失效",qlog 中表现为周期性的 path_validation_failed → add_path 轮换。
解决方案:keepalive 间隔必须小于 CGNAT 最短超时时间:
quic_config_set_max_idle_timeout(config, 60000); // 60秒无数据才关闭连接
quic_config_set_ping_timeout(config, 20000); // 每20秒发 PING 保活
// 20s < 30s(CGNAT 最短超时),留 10s 余量
TQUIC 的 PING 帧会在两条路径上都发送,确保两个 CGNAT 表项都保持活跃。
3.2 PATH_RESPONSE 路由问题
T-Box 的 sock2(绑定 rmnet1)必须在 add_path 之前就加入 epoll 监听。如果 sock2 比 sock1 晚 100ms 加入 epoll,这段时间内到达的 PATH_RESPONSE 会丢失,导致路径探测要等到第二次重试(额外 200ms 延迟)。
3.3 Connection ID 分配——双端必须都启用 MPQUIC
陷阱 3:云端未启用 MPQUIC,add_path 后连接异常
MPQUIC 要求每条路径使用不同的 Destination Connection ID(防路径关联攻击)。握手阶段,服务端通过 NEW_CONNECTION_ID 帧预分配多个 CID。
如果云端是标准 QUIC 实现(未启用 MPQUIC),当 T-Box 的 rmnet1 用新 CID 发包时,云端不认识这个 CID,会回 CONNECTION_CLOSE。
这个问题在 add_path 后约 1 个 RTT 才会暴露,qlog 中表现为 connection_closed 事件,error code 为 INVALID_TOKEN 或 PROTOCOL_VIOLATION。
结论:MPQUIC 冗余发送需要 T-Box 端和云端都使用支持 MPQUIC 的 TQUIC(或其他 MPQUIC 实现),不能单端启用。
# 验证 CGNAT 超时时间(用实际 SIM 卡测试)
# 建立 QUIC 连接后停止 PING,观察 PATH_CHALLENGE 重试出现的时间间隔
# 或用 nc 做简单测试:
nc -u <server_ip> 12345 < /dev/null &
sleep 25 && echo "test" | nc -u -w1 <server_ip> 12345
# 有回包:25s 内 CGNAT 未超时
sleep 10 && echo "test2" | nc -u -w1 <server_ip> 12345
# 无回包:CGNAT 超时在 25-35s 之间 → keepalive 需设为 <25s
第四章:接收端去重——为什么不能用 Packet Number
冗余发送产生两份相同内容的数据,接收端怎么去重?
直觉错误:用 packet number 去重。
实际原因:MPQUIC 每条路径有独立的 Packet Number Space。add_path 时:
// connection.rs
if self.flags.contains(EnableMultipath) {
let space_id = self.spaces.add(); // 为新路径创建独立的 PN space
path.space_id = space_id;
}
path_id=0 的包编号:0, 1, 2, 3... path_id=1 的包编号:也从 0 开始:0, 1, 2, 3...
同一份 STREAM 数据,在 path_id=0 上是 packet_number=15,在 path_id=1 上是 packet_number=8,完全不同。用 packet number 去重会把两个包都当成新数据,应用层收到两次同一条控制指令。
正确机制:STREAM offset 区间表
QUIC STREAM frame 有 offset 字段标识字节位置(RFC 9000 §19.8)。冗余发送的两个包里,STREAM frame 的 offset 和 length 完全相同。接收端维护已收到的 offset 区间表:
- path_id=0 的包到达,offset=[0,50) → 加入区间表,交付给应用层
- path_id=1 的副本到达,offset=[0,50) → 区间表已有,直接丢弃
这个机制是 QUIC 协议本身的规定(RFC 9000 §2.2:"实现必须能处理重叠的 STREAM 帧"),不是 MPQUIC 新增的。quic_conn_stream_recv() 只会把每段数据交付一次,应用层完全透明。
内存开销估算:4G RTT=150ms,5G RTT=30ms,差值 120ms,100pps 下积压约 12 个 offset 区间项,每项 16 字节,单 stream 约 192 字节。云端 10000 条连接 × 4 stream = 7.7MB,可以接受。
# 用 qlog 验证两路包的 packet number 独立,stream offset 相同
cat /tmp/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, cat, name, data = e[0], e[1], e[2], e[3]
if name != "packet_sent": continue
path_id = data.get("path_id", "?")
pn = data.get("header", {}).get("packet_number", "?")
for f in data.get("frames", []):
if f.get("frame_type") == "stream":
print(f"t={t}ms path={path_id} pn={pn} "
f"stream={f.get('stream_id')} "
f"offset={f.get('offset')} len={f.get('length')}")
except: pass
EOF
# 预期:path=0 和 path=1 的包,pn 各自独立递增,但 offset 相同(冗余发送)
第五章:已知局限与工程建议
5.1 MPQUIC API 不稳定
TQUIC 的 Multipath 功能在 develop 分支,源码注释明确标注:
/// Note: The API of MultipathScheduler is not stable and may change in future versions.
pub(crate) trait MultipathScheduler { ... }
C API 中 quic_config_enable_multipath 也标注 (Experimental)。生产部署需要锁定 TQUIC 版本,并在升级前检查 MPQUIC 相关 changelog。
5.2 冗余发送是连接级配置
RedundantScheduler 是连接级的,该连接上所有 stream 的数据都会冗余发送。如果需要"只有控制信号 stream 冗余,状态上报 stream 不冗余",当前选项是拆成两个独立连接(分别配置调度策略)。
5.3 副本发出时机不是"同时"
如第一章所述,副本是在下一次发包机会发出,不是与原始包"同时"发出。在极端情况下(path_id=1 的 cwnd 为 0),副本会在 buffered 队列中等待,直到 path_id=1 的 cwnd 恢复。
这意味着:冗余发送不能保证两条路径的包"同时到达",只能保证"都会发出"。P99 延迟取决于两条路径中较快的那条,而不是两条同时发出的理想情况。
5.4 工程建议汇总
- 同时设置两个 API:
quic_config_enable_multipath(true)+quic_config_set_multipath_algorithm(REDUNDANT) - 在
on_handshake_complete中调用add_path,不要在on_connected中调用 - epoll 同时监听两个 socket fd,在
add_path之前就把 sock2 加入 epoll - keepalive 间隔 ≤ 20 秒(针对 30 秒的 CGNAT 最短超时)
- 双端都启用 MPQUIC,云端必须也使用支持 MPQUIC 的实现
- 部署前用实际 SIM 卡测试 CGNAT 超时时间
回到文章开头那位工程师的困境:三天集成,两条路径都显示 active,数据却只走一条网卡。
根因是两个正交概念没有分清楚:quic_config_enable_multipath 是协议层的开关,告诉对端"我支持多路径";quic_config_set_multipath_algorithm(REDUNDANT) 是调度层的开关,告诉 TQUIC"把数据冗余发到所有路径"。而 Redundant 模式还需要 buffer_required=true 才能在写包时保留数据副本——这三个条件缺一不可,任何一个没有设置,冗余发送都会静默失败。
路径管理(有哪些路径可用)和调度策略(数据怎么分配到路径上)是两个正交的维度。 这个设计让系统可以在运行时动态切换调度策略——信道好时切换为 MinRtt(节省带宽),信道变差时切换为 Redundant(保证可靠性)——这是第10篇要讲的动态冗余策略的基础。
下一篇(第07篇)进入 T-Box 的真实 Linux 系统环境:用 SO_BINDTODEVICE 把 TQUIC 路径真正绑到指定的 SIM 卡网卡上——以及为什么不做这一步,两条"路径"实际上都走同一张网卡,冗余发送是假象。