16. 重传的边界:重发的不是 Packet,而是语义
上一章回答了"什么时候算丢"——时间阈值、包阈值、PTO 三条线交叉验证,给出一个概率推断。但判丢只是起点。真正的问题紧随其后:既然一个包被判定失踪了,该重发的到底是包本身,还是包里承载的语义?
这个问题的答案,决定了 QUIC 重传机制与 TCP 的根本差异。TCP 可以把丢失的字节区间原样补发,因为它的可靠性单位就是字节位置。QUIC 不行——它的传输单元是 Packet,但 Packet 只是一次投递尝试,里面承载的 Frame 才是连接真正想表达的意图。丢了一个 Packet,QUIC 恢复的不是那个 Packet 的物理副本,而是那些未送达的协议语义。
本章从 TCP 的字节流旧法则讲起,推导出 QUIC 为什么必须走语义重传这条路,再拆解不同帧类型在丢失后的不同恢复命运,最后落到 quicX 的具体实现路径。
16.1 字节流的旧法则:TCP 为什么可以直接重发那一段
要理解 QUIC 的做法,先回到 TCP 的旧世界看看。
TCP 的重传逻辑建立在一个简单的事实上:TCP 传输的是字节流。数据从一端流进去,从另一端流出来。发送端写入了 1000 字节,接收端按顺序收到这 1000 字节。每一个字节都有一个位置——序列号(Sequence Number)标记它在流中的绝对偏移。
当某个字节丢失时,TCP 的处理方式非常直接:重新发送丢失的那个字节区间。比如发送了字节 1000-2000,中途丢了 1000-1500,TCP 重新发送这 500 个字节。接收端收到后,把它放到正确的位置——它知道这 500 个字节应该填补在"1000"这个偏移后面。重传包使用相同的序列号范围,和原始包在语义上完全等价。
TCP 字节流重传:
发送端缓冲区: [====== 字节 0~2000 ======]
↓
原始发送: SEQ=1000, LEN=500 ───→ ✗ 丢失
↓
重传: SEQ=1000, LEN=500 ───→ ✓ 到达
(相同序列号,相同数据)
接收端:把字节填入 offset 1000 的位置 → 完成
这种方式的优点是简单直观。发送端只需要记住"我发送到了哪个字节位置",接收端只需要记住"我接收到了哪个字节位置"。重传时双方都知道该补什么——序列号就是身份证,同一个序列号永远对应同一段数据。
但这种简单是有代价的。TCP 的可靠性和"字节流"这个传输模型绑定在一起。如果换一种传输模型——比如不再是一根水管,而是多根水管同时流——TCP 的重传逻辑就会出问题。Stream A 丢了一个包,Stream B、C 的数据也被卡住,因为 TCP 只有一条字节流,序列号是全局递增的。这就是 HTTP/2 在 TCP 之上遭遇队头阻塞的根源。
QUIC 不走这条路。它选择了另一条更灵活、代价也更高的道路。
16.2 包已经死了,语义还活着:QUIC 为什么拒绝"整包复刻"
QUIC 重传的不是 Packet,而是 Packet 里面承载的语义——这是 QUIC 和 TCP 最本质的区别之一。要理解这一点,需要从 QUIC 的协议结构特征出发做推导。
第一个结构约束:Packet Number 单调递增,不复用。RFC 9000 §17.1 规定,每个 Packet Number 在同一个 Packet Number Space 内只使用一次。这意味着重传一个包时,不可能使用原来的 Packet Number——它必须获得一个新的编号。既然编号都变了,这个包在协议层面就是一个全新的包,不是原始包的"副本"。
第二个结构约束:多加密级别共存。QUIC 连接在不同阶段使用不同的加密密钥(Initial、Handshake、1-RTT)。一个在 Handshake 阶段丢失的包,在 1-RTT 密钥可用后重传时,可能需要换用新的加密级别。同一个语义内容(比如一个 CRYPTO Frame),前后两次发送可能用完全不同的密钥加密——包的物理形态已经不同了。
第三个结构约束:帧独立编码,包只是容器。一个 QUIC Packet 可以携带多个不同类型的 Frame——STREAM、ACK、MAX_DATA、PING 可能共存于同一个包里。当这个包丢失时,并非所有帧都需要重传:ACK 帧不需要重传(RFC 9000 §13.3 明确列出),PADDING 帧不需要重传,而 STREAM 帧和控制帧则需要。恢复的单位是帧,不是包。
这三个约束叠加在一起,让"把原来的包再发一遍"在协议层面既不可能也不合理。QUIC 的做法是:重新构造包含相同语义的新包。如果丢失的包里有一个 STREAM Frame 承载了 offset 200-400 的数据,重传时 QUIC 从 Stream 的发送记录里取出相同偏移范围的数据,装进一个新 Packet Number 的包里发出去。从接收端的角度看,它收到的不是"同一个包的副本",而是"同一个语义的新表达"。
QUIC 语义重传:
原始发送: PN=5 [STREAM(sid=1, off=200, len=200) + MAX_DATA(limit=8000)]
↓
PN=5 ───→ ✗ 丢失
↓
重传: PN=12 [STREAM(sid=1, off=200, len=200) + MAX_DATA(limit=9500)]
(新 PN,STREAM 数据相同,MAX_DATA 已更新为最新值)
接收端:按 stream_id + offset 匹配 → 填入正确位置 → 完成
上图揭示了一个关键细节:重传包里的 MAX_DATA 不是原始值 8000,而是当前最新值 9500。因为 MAX_DATA 的语义是"告诉对端我当前的接收窗口上限",重传时发送最新值比复制旧值更合理——RFC 9000 §13.3 对此有明确要求:"the most recent value is sent"。这正是"语义重传"而非"字节复制"的体现。
16.3 同样是丢失,恢复的命运却不同:控制帧、流帧与握手帧的差异
QUIC 的语义重传不是"一视同仁"的。不同类型的 Frame,在丢失后的恢复方式截然不同。RFC 9000 §13.3 对此做了系统性的分类。
首先是不需要重传的帧。ACK 帧、PADDING 帧不携带需要可靠传输的语义,丢失后直接丢弃即可。PATH_CHALLENGE 和 PATH_RESPONSE 也不重传——它们用于路径验证,丢失后由上层逻辑决定是否发起新的验证。CONNECTION_CLOSE 帧虽然重要,但它的重传由专门的 closing 状态机处理,不走常规重传路径。
然后是"最新值覆盖型"控制帧。MAX_DATA、MAX_STREAM_DATA、MAX_STREAMS 这类帧携带的是一个单调递增的限制值。如果丢失了一个 MAX_DATA(limit=8000),但此后接收端的窗口已经扩大到 9500,重传时应当发送 MAX_DATA(limit=9500),而不是过时的 8000。RFC 9000 §13.3 明确说:"new frames are sent with updated information"。这些帧不是简单的幂等重发——它们需要用当前最新状态替换历史值。
还有"真正幂等型"控制帧。PING 帧没有参数,发多次和发一次效果一样。NEW_CONNECTION_ID、RETIRE_CONNECTION_ID 等帧携带固定的语义内容,重传时原样发送即可。HANDSHAKE_DONE 也是如此——它只是一个信号,告诉对端握手已完成。
握手帧(CRYPTO Frame)是一个特殊类别。CRYPTO Frame 承载 TLS 握手数据,使用独立于 Stream 的偏移空间。如果一个 CRYPTO Frame 丢失,重传时从加密层的发送缓冲区取出相同偏移位置的握手数据,装进新包发送。握手数据的特殊性在于它们绑定特定的加密级别——Initial 阶段的 CRYPTO Frame 不能在 1-RTT 阶段重传,因为对端可能已经丢弃了 Initial 密钥。
流帧(STREAM Frame)是最复杂的。STREAM Frame 携带应用数据,数据在 Stream 内部通过 offset 定位。一个 STREAM Frame 丢失后,重传的不是"原来的 Frame 对象",而是 Stream 发送缓冲区中对应偏移范围的数据。
用一个具体例子来说明:
Stream #1 发送缓冲区内容(5 个帧,每帧 200 字节):
PN=3: STREAM(off=0, len=200) ──→ ✓ ACKed
PN=4: STREAM(off=200, len=200) ──→ ✗ 丢失
PN=5: STREAM(off=400, len=200) ──→ ✓ ACKed
PN=6: STREAM(off=600, len=200) ──→ ✓ ACKed
PN=7: STREAM(off=800, len=200) ──→ 在途
收到 ACK{largest=6, ranges=[3,3],[5,6]} 后:
→ PN=4 后面有 3 个包被确认(6-4≥3)→ 判丢
→ 重传:PN=9: STREAM(off=200, len=200) ← 同一 offset,新 PN
→ 接收端按 stream_id=1, offset=200 填入正确位置
下表汇总了 RFC 9000 §13.3 中各类帧在丢失后的恢复策略:
| 帧类型 | 恢复策略 | 说明 |
|---|---|---|
| ACK, PADDING | 不重传 | 无需可靠传输 |
| PATH_CHALLENGE, PATH_RESPONSE | 不重传 | 路径验证由上层重新发起 |
| CONNECTION_CLOSE | closing 状态机处理 | 不走常规重传路径 |
| MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS | 发送最新值 | 单调递增,旧值无意义 |
| DATA_BLOCKED, STREAM_DATA_BLOCKED | 发送最新值 | 阻塞点可能已变化 |
| PING, HANDSHAKE_DONE | 原样重发 | 真正幂等 |
| NEW_CONNECTION_ID, RETIRE_CONNECTION_ID | 原样重发 | 固定语义内容 |
| CRYPTO | 从加密缓冲区按偏移重发 | 绑定特定加密级别 |
| STREAM | 从 Stream 发送缓冲区按偏移重发 | 最复杂的恢复路径 |
这种分类的意义在于:不同类型的语义有不同的生命周期和重传约束。控制帧可能需要更新值,握手帧绑定加密级别,流帧按偏移定位。QUIC 的语义重传设计,让每种 Frame 都能找到最适合自己的恢复路径。
16.4 重发的自由,也是实现的负担:这种设计到底换来了什么,又付出了什么
QUIC 的语义重传设计,是一场带成本的结构交换。先看收益,再看代价。
多路复用不再被可靠性绑架。在 TCP 里,Stream A 的数据丢了,整个连接的字节流都要停下来等重传——因为 TCP 只有一条序列号空间。在 QUIC 里,Stream A 丢了一个包,Stream B、C 完全不受影响。每个 Stream 的语义独立维护,重传独立进行。这直接消除了 TCP 层面的队头阻塞。
重传包使用新的 Packet Number,带来了额外的收益。这里有一个 TCP 长期头疼的问题:重传歧义(retransmission ambiguity)。考虑以下场景:
TCP 重传歧义:
t=0ms 发送 SEQ=1000
t=100ms 超时,重传 SEQ=1000(相同序列号)
t=150ms 收到 ACK(SEQ=1000)
问题:这个 ACK 确认的是 t=0 的原始包(RTT=150ms)
还是 t=100 的重传包(RTT=50ms)?
→ 无法区分,RTT 测量失效
TCP 通过 Karn 算法绕过:放弃用重传包的 ACK 更新 RTT。代价是在丢包频繁时 RTT 估算长时间得不到更新,PTO/RTO 的计算会偏离实际网络状态。
QUIC 不存在这个问题。重传包使用全新 PN,接收端 ACK 这个新 PN 时,发送端能精确计算这次重传的 RTT——PN 唯一标识一次发送行为,ACK 唯一对应一个 PN,没有歧义。
下表对比了两种重传机制的核心差异:
| 维度 | TCP 字节流重传 | QUIC 语义重传 |
|---|---|---|
| 恢复单位 | 字节区间(相同 SEQ) | 帧语义(新 PN) |
| 多路复用影响 | 全连接阻塞 | 仅受损 Stream 重传 |
| 重传歧义 | 存在(Karn 算法规避) | 不存在(PN 唯一) |
| RTT 测量 | 重传包不可用于测量 | 重传包可正常测量 |
| 控制信息 | 随字节流一起重传 | 可更新为最新值 |
| 实现复杂度 | 低(维护字节偏移即可) | 高(帧级别追踪) |
但这种设计也带来了实现复杂度的显著上升。
发送端需要维护大量状态。每个 Packet 需要记录它携带了哪些帧、哪些 Stream 的哪些 offset 范围——这样在收到 ACK 时才能精确推进每个 Stream 的确认进度。quicX 中 PacketTimerInfo 结构体就承担了这个角色,它保存了每个未确认包的发送时间、包大小、携带的 Stream 数据信息(StreamDataInfo)以及完整的包对象。
接收端需要做语义重组。收到重传的 STREAM Frame 后,接收端需要按 stream_id 和 offset 把它放到正确的 Stream 位置,可能需要和已经收到的其他 Frame 合并、排序。这比 TCP 的"收到字节就放入缓冲区"要复杂得多。
拥塞控制需要协调。重传的 Packet 和新 Packet 一样——它们占用拥塞窗口,参与拥塞控制计算。如果拥塞窗口不足,重传包也要排队等待(quicX 中 TrySend() 会检查 GetAvailableWindow(),窗口不足时将丢失包放回队列)。
16.5 quicX 的恢复路径:发送记录、语义重组与再次投递如何落地
协议层面讲清楚之后,来看 quicX 如何在代码里组织这套系统。重传涉及三个核心阶段:丢包后的包记录保留、重传触发链路、以及语义的再次投递。
第一阶段:发送时的状态记录。 每个包发送时,SendControl::OnPacketSend() 会创建一个 PacketTimerInfo 条目,保存发送时间、包大小、关联的 Stream 数据信息(哪些 Stream 的哪些 offset 范围),以及完整的 IPacket 对象引用。这些条目按 Packet Number Space 组织在 unacked_packets_[ns] 哈希表中,等待后续的 ACK 确认或丢包判定。同时为每个包注册一个 PTO 定时器,超时则触发丢包处理。
第二阶段:丢包判定与重传触发。 当 ACK 到达时,SendControl::OnPacketAck() 处理完确认逻辑后调用 DetectLostPackets()。该方法遍历对应 Packet Number Space 中所有未确认的包,用包阈值(kPacketThreshold = 3)和时间阈值(9/8 × smoothed_rtt)交叉检查。被判丢的包经历以下处理:
DetectLostPackets() 判定 PN=4 丢失:
→ 取消 PN=4 的 PTO 定时器
→ 将 IPacket 对象加入 lost_packets_ 队列
→ 通知拥塞控制模块 OnPacketLost()
→ 触发 packet_lost_cb_ 回调
→ 从 unacked_packets_[ns] 中移除
packet_lost_cb_ 在 SendManager 构造时注册,它的作用是调用 send_retry_cb_()——这个回调绑定到 BaseConnection::ActiveSend(),将连接加入 Worker 的活跃发送队列,触发 TrySend() 的下一轮执行。
第三阶段:语义的再次投递。 BaseConnection::TrySend() 是重传的执行入口。它优先检查 lost_packets_ 队列——如果有丢失包等待重传,从队列头部取出 IPacket 对象,执行以下操作:
TrySend() 重传路径:
→ 检查拥塞窗口(窗口不足则放回队列,标记 cwnd 受限)
→ 分配新 Packet Number:new_pn = PacketNumber::NextPacketNumber(ns)
→ 设置新加密器:lost_pkt->SetCryptographer(cryptographer)
→ 重新编码:lost_pkt->Encode(buffer)
→ 在 SendControl 中重新注册追踪(以便追踪这次重传的 ACK/丢失)
→ 发送到网络
quicX 的实现策略值得注意:它保留了丢失包的完整 IPacket 对象(包括其中的帧和 payload),重传时只替换 Packet Number 和加密器,然后重新编码发送。这和"逐帧拆解再重新组包"的方案不同——quicX 选择了更简洁的整包重编码路径。
这个设计选择有明确的取舍。RFC 9000 §13.3 描述的是帧级别的语义恢复——理论上最灵活的做法是:拆出丢失包中的每个帧,过滤掉不需要重传的(ACK、PADDING),将需要重传的帧重新组合到新包中,并更新 MAX_DATA 等控制帧为最新值。quicX 的整包重编码方案牺牲了这种灵活性(比如不会在重传时更新 MAX_DATA 为最新值),换来了实现的简洁和更低的 CPU 开销——不需要拆帧、过滤、重组,只需换号、换密钥、重编码。对于一个追求清晰可审计的实现来说,这是合理的取舍。
ACK 确认如何推进 Stream 状态。 当重传包的 ACK 到达时,SendControl::OnPacketAck() 遍历该包关联的所有 StreamDataInfo,对每个 Stream 调用 stream_data_ack_cb_。这个回调链最终到达 SendStream::OnDataAcked(),将 acked_offset_ 推进到已确认的最大偏移量。当所有数据(包括 FIN)都被确认后,CheckAllDataAcked() 将 Stream 状态机推入终态。
下面这张流程图概括了 quicX 中从丢包判定到语义恢复的完整链路:
收到 ACK
│
▼
SendControl::OnPacketAck()
├── 更新 RTT
├── 推进 Stream acked_offset
└── DetectLostPackets()
│
判定 PN=4 丢失
│
▼
lost_packets_.push(pkt)
packet_lost_cb_()
│
▼
SendManager → send_retry_cb_()
│
▼
BaseConnection::ActiveSend()
│
▼
BaseConnection::TrySend()
├── 从 lost_packets_ 取出包
├── 分配新 PN=12
├── 设置新加密器
├── Encode() 重新编码
├── OnPacketSend() 重新注册追踪
└── 发送到网络
16.6 真正被重发的,是连接想说的话
回到本章开头的问题:如果包真的丢了,QUIC 怎么把它"捞"回来?
答案是:QUIC 恢复的不是那个 Packet 的物理副本,而是 Packet 里承载的协议语义。是 Stream 里特定偏移位置的应用数据,是握手过程中需要重传的 CRYPTO 消息,是需要更新为最新值的 MAX_DATA 窗口通告,是不需要重传的 ACK 和 PADDING。每种语义有自己的生命周期和恢复约束,不可能用一个统一的"再发一遍"来处理。
这种设计让多路复用摆脱了可靠性的绑架,让重传包的 RTT 测量不再有歧义,让控制信息能携带最新状态而非过时副本。代价是发送端必须为每个包维护帧级别的发送记录,为每个 Stream 追踪确认偏移——quicX 中 PacketTimerInfo、StreamDataInfo、SendStream::acked_offset_ 这些结构正是为此而存在。
Packet 只是一次说话机会,语义才是连接真正想表达的内容。判丢是上半篇,语义恢复是下半篇——接下来要回答的是:这些需要恢复的语义,最终怎样被重新组织进新的 Packet 里发出去?这就是第 17 章要进入的话题。