13. 可靠性的拓扑重构:从单链到三层网
第 6 章从全局视角回答了"QUIC 为什么要在 UDP 上另起炉灶"——三道伤疤、两层信封、一条从 Datagram 到 Frame 的拆包路径。结论是一个结构模型:Packet 层负责路由和保护,Frame 层负责语义,每一层只做一件事。
我们现在要做的是把镜头对准可靠性的内部结构,回答一个更精确的问题:TCP 的可靠性机制内部到底长什么样?QUIC 为什么要把它拆成完全不同的样子?
答案不是"TCP 太慢"或"TCP 太老"——这些是症状,不是诊断。真正的诊断是一个结构性问题:TCP 把三个本该独立的职责——确认、恢复、交付——绑在了同一个序号上。 这种绑定让整条可靠性链变成了一个牵一发动全身的单点故障结构。QUIC 的回答也不是修补某个环节,而是一次拓扑重构:把一条链拆成三层网。
一句话概括这次重构的结果:TCP 的可靠性是一条链,QUIC 的可靠性是三层网。确认不拖交付,交付不拖恢复,恢复不拖确认。
13.1 TCP 的可靠性是一条链
大多数程序员对 TCP 可靠性的理解是一堆散装功能的集合:有序号、有 ACK、有重传、有超时。但如果你退后一步俯瞰,这些功能不是散装的,而是一条严格的线性链:
五个环节首尾相连,前一个环节的输出直接驱动下一个环节的输入。发送端给每个字节编号,接收端用 ACK 告诉发送端"我收到了哪些字节",发送端如果超过一定时间没有收到某些字节的确认就判定丢失,丢失后触发重传把那些字节重新发出去,接收端收到后按序号顺序排列并交付给应用层。
这条链有一个极其诱人的特性:整条链只有一个命名空间。 从头到尾只有一套字节序号——序号既用来标记数据在字节流中的位置,也用来确认哪些数据已经收到,还用来决定重传从哪里开始。一套序号走天下,逻辑清晰,实现简洁,沿着这条链走一遍就能理解全部。
这种简洁曾经如此成功,以至于几代程序员养成了一个朴素的信念:可靠性是免费的。 你调用 send(),字节就一定会到达对端;你调用 recv(),读到的字节一定是按顺序排列的。丢包、乱序、重传——这些脏活全部被内核协议栈消化掉了,你的代码里看不到任何痕迹。
但"简洁"和"简单"是两回事。这条链之所以简洁,是因为它把所有复杂性都压缩到了一个序号上。当这个序号承载的职责越来越多、需要服务的场景越来越复杂的时候,简洁就变成了脆弱。
13.2 一条链的代价:为什么同一个序号不能身兼三职
TCP 的序号不只是一个编号。它同时承担着三个完全不同维度的职责——而这种绑定,正是整条可靠性链的结构性代价。
序号的第一职:定位。 它标记"这段数据在字节流中的位置"。接收端收到数据后,根据序号把数据放到字节流中正确的位置,然后按顺序交付给应用层。这是交付用的。
序号的第二职:确认。 ACK 报文告诉发送端"我已经收到了序号 X 之前的所有数据"。发送端据此判断哪些数据已经被收到、哪些还在路上、哪些可能已经丢了。这是确认用的。
序号的第三职:恢复。 当发送端判定某些数据丢失后,重传机制根据序号决定"从哪里开始重新发送"。重传包携带的仍然是原来那个序号——因为接收端需要用这个序号把数据放回字节流中的正确位置。这是恢复用的。
一个序号,同时服务于交付、确认和恢复三个维度。在简单场景下,这种设计运转良好——但一旦这三个维度的需求开始冲突,问题就来了。
第一个冲突:确认和恢复相互污染。 当一个包丢了,TCP 重传时用的是同一个序号。这看起来合理——接收端需要知道这是哪段数据嘛。但问题出在发送端这边:当发送端收到一个 ACK 确认了序号 1000-2000 的数据时,它无法区分这个 ACK 确认的是第一次发送的原始包,还是后来重传的那个包。这就是经典的 TCP 重传歧义(retransmission ambiguity)。
重传歧义带来的直接后果是:RTT 采样被污染了。 RTT 的计算依赖于"发送时刻"和"确认时刻"之间的差值。如果发送端不知道 ACK 确认的是原始包还是重传包,它就不知道该用哪个发送时刻来计算 RTT。Karn's Algorithm 的做法是:一旦发生重传,就放弃这次 RTT 采样。这意味着在丢包频繁的网络里——恰恰是最需要精确 RTT 的时候——TCP 反而没法好好采样。
第二个冲突:确认和交付互相拖拽。 TCP 的按序交付是"全局"的——它不是针对某一路数据,而是针对整条连接上的所有字节。当 HTTP/2 在一条 TCP 连接上跑多个 Stream 时,如果 Stream A 的数据丢了一段,即使 Stream B、Stream C 的数据已经完好无损地到达了接收端,它们也必须在 TCP 缓冲区里干等——因为它们的序号排在 Stream A 那个洞的后面,TCP 的交付逻辑不允许跳过这个洞。
确认还没完成的事,拖住了交付已经完成的事。 这不是某个实现的 bug,而是"一个序号身兼三职"这种结构注定的副作用。
打个比方:一个员工同时兼任前台接待、财务会计和安保巡逻。平时三个岗位轮着来,勉强也能转。但一旦有个快递丢了需要追查(恢复),前台必须停下来核对发送记录,会计也得停下来核对账目,安保巡逻更是暂停——因为这三件事全绑在同一个人身上,一件事出了状况,另外两件事也得跟着停下来等。
TCP 的可靠性链就是这个状态。它的结构性问题不是"性能不好"——在单流、低丢包的简单场景里,这条链跑得很好。问题是:当确认、恢复、交付这三个维度各自有独立演进的需求时,同一个序号成了它们共同的瓶颈。 一个维度被卡住,另外两个维度跟着陪葬。
QUIC 要做的,就是解开这个绑定。
13.3 QUIC 的回答:三层各自命名,各自演进
理解了 TCP 的问题——"一个序号身兼三职导致三个维度互相拖拽"——QUIC 的核心设计决策就变得自然了:把确认、恢复、交付分配到三个独立的命名空间,让每个维度用自己的序号、按自己的节奏演进。
这不是一次优化,而是一次拓扑重构。TCP 的可靠性是一条链,所有环节串在一条线上;QUIC 的可靠性是三层网,每层有自己的坐标系,彼此独立又有精确的协作边界。
第一层:Packet Number——确认层
Packet Number 只回答一个问题:这个包到了没有?
QUIC 给每个发出去的包分配一个 Packet Number(PN),这个编号单调递增,永不复用。即使某个包丢了需要重传,重传的新包也会使用一个全新的 PN——它和原来那个丢失的 PN 是两个完全不同的数字。
这个看似简单的规则,带来了一个深远的后果:ACK 永远没有歧义。
当接收端发回一个 ACK 说"我收到了 PN 42",发送端可以百分之百确定这指的是 PN 42 那次发送——不可能是别的任何一次。因为 PN 42 在整个连接的历史中只出现过一次,它唯一对应一个发送时刻。RTT 采样因此变得干净了:用 PN 42 的发送时刻减去收到 ACK 的时刻,就是这次传输的精确 RTT。不需要 Karn's Algorithm,不需要猜,不需要在丢包时放弃采样。
注意 Packet Number 的职责边界:它只管确认,不管交付。PN 不告诉接收端"这段数据应该放在字节流的什么位置"——那是另一层的事。PN 也不决定"丢了之后应该恢复什么"——那也是另一层的事。PN 只做一件事:标记一次发送事件,让 ACK 能够无歧义地指向它。
对比 TCP:TCP 的序号既管确认又管交付又管恢复,三个维度绑在一起。QUIC 的 Packet Number 只管确认——它把自己的职责收窄到了极致。
第二层:Frame 语义——恢复层
当一个包被判定丢失后,QUIC 需要恢复的不是那个 Packet Number——PN 只是一次发送事件的标签,它已经永久过期了。QUIC 需要恢复的是那个 PN 里装的语义。
这就是"恢复层"的含义:丢包之后,真正被恢复的是帧的语义,而不是包的外壳。
而且,不同类型的帧有完全不同的恢复策略:
- STREAM Frame 丢了:发送端把同样的 Stream 数据(相同的 Stream ID 和 Offset 范围)重新装进一个新 PN 的包里发出去。新的 PN 意味着确认层能无歧义地跟踪这次重传。
- ACK Frame 丢了:根本不需要重传。为什么?因为 ACK 表达的是"截至此刻我收到了哪些包"这个状态——下一次发 ACK 时,最新的确认状态会自然覆盖掉旧的。旧的 ACK 丢了就丢了,没有信息损失。
- MAX_DATA Frame 丢了:发送端重新发一个 MAX_DATA,但携带的是当前最新的窗口值,而不是丢失时的旧值。因为流控窗口只会变大不会变小,发最新值永远是正确的。
- CRYPTO Frame 丢了:必须精确重传相同的握手数据——因为加密握手的语义不允许丢失任何一个字节。
看到了吗?恢复的粒度是"语义"而不是"包"。有些语义需要精确重传(STREAM、CRYPTO),有些只需要发送最新值(MAX_DATA、MAX_STREAM_DATA),有些根本不需要重传(ACK)。这种差异化的恢复策略,只有在恢复层和确认层分离之后才有可能实现。 如果像 TCP 那样把确认和恢复绑在同一个序号上,你就只能做一件事:把那段序号对应的字节原样重发。
第三层:Stream Offset——交付层
接收端的每个 Stream 有自己独立的 Offset 序号空间。STREAM Frame 携带的 (Stream ID, Offset, Length) 三元组告诉接收端:"这段数据属于 Stream X,从 Offset Y 开始,长度是 Z 字节。" 接收端根据这个信息,把数据放到对应 Stream 的正确位置。
关键在于:交付只看 Stream Offset,不看 Packet Number。
Stream A 缺了 Offset 100-200 的数据?没关系,Stream B 的 Offset 0-500 已经全部到齐,可以立刻交付给应用层。Stream C 的 Offset 300-400 还在路上?不影响 Stream D 的完整交付。每个 Stream 的交付只取决于自己的 Offset 有没有连续,和其他 Stream、和 Packet Number 都没有关系。
这就是 QUIC 消除跨流队头阻塞的结构基础——不是靠什么巧妙的调度算法,而是靠把交付层从确认层中彻底独立出来。交付只认 Stream Offset,确认只认 Packet Number,两者各自演进,互不拖拽。
拓扑变化的一句话总结
TCP 是"一个序号驱动一条链"——序号决定确认、确认驱动判丢、判丢触发重传、重传恢复的还是那个序号、序号最终决定交付顺序。任何一个环节被卡住,整条链都跟着停转。
QUIC 是"三个命名空间各管一层"——Packet Number 管确认,Frame 语义管恢复,Stream Offset 管交付。确认不拖交付,交付不拖恢复,恢复不拖确认。 三个维度不再是串联在一条链上的环节,而是在各自独立的坐标系中演进,又通过精确的接口协同工作。
这不是简单的"拆分"或"优化"。如果你画一张依赖关系图,TCP 的是一条直线,QUIC 的是一个网——连锁反应不再是单向传导,而是在多个维度上独立展开。这是拓扑学意义上的结构变化。
13.4 三层之间的协作合约
三层分开了,但它们不是三个互不往来的孤岛。恰恰相反,三层之间有精确的协作边界——正是这些边界把三层从"各自独立的碎片"组织成了一个有机运转的整体。
第一个接口:确认层 → 判丢层。
当接收端发回 ACK,确认层会更新每个 Packet Number 的确认状态。如果某些 PN 长时间没有被确认——比如后面连续三个 PN 都收到了 ACK,但中间那个 PN 始终没有动静(包间隔阈值),或者超过了一定的时间阈值——判丢逻辑就会介入,把那些 PN 标记为"已丢失"。
注意:判丢层的输入是 Packet Number 级别的——它只关心"哪些 PN 丢了",不关心那些 PN 里装的是什么。
第二个接口:判丢层 → 恢复层。
判丢的输出不是"重发那个包"。判丢层做的事情是:把丢失的 PN 里携带的帧语义提取出来,标记为"需要恢复",放入语义恢复队列。
这是一个关键的转换点——从 Packet Number 的维度切换到了 Frame 语义的维度。判丢层说的是"PN 42 丢了,里面有一个 STREAM Frame(Stream 5, Offset 1000-1200)和一个 MAX_DATA Frame(窗口 65536)",恢复层接到这个信息后,会根据帧类型决定恢复策略:STREAM 数据需要重发,MAX_DATA 需要用当前最新值重发,ACK 不需要重发。
第三个接口:恢复层 → 发送层。
恢复层决定了"需要恢复哪些语义",但它不直接发包。发送层(PacketBuilder)拿到恢复队列后,要做一系列装配决策:这些语义应该装进哪个加密级别的包?一个包能装多少?需不需要和其他待发的控制帧一起搭车?最终用什么 PN?发送层把这些决策落实为一个个真实的 Packet,赋予新的 PN,交给 UDP 发出去。
把这三个接口串起来,就是 QUIC 可靠性的完整工作流程:
每一个箭头都是一次维度切换——从 PN 空间切到帧语义空间,再从帧语义空间切到新 PN 空间。这条流水线的结构值得记住,后续章节展开细节时,沿着这张图就能定位自己在哪个节点、上游传来了什么、下游期望什么。
13.5 quicX 为什么必须自己维护这套系统
三层模型是协议规范里的结构。但 quicX 作为 QUIC 的 C++ 实现,面对的是一个更实际的问题:为什么这套机制必须自己在用户态从零搭建?
答案藏在一个简单的事实里:一旦选择了 UDP,可靠性就不再是系统免费赠送的礼物。
TCP 的可靠性由操作系统内核提供。你用 TCP socket 写代码时,序号管理、ACK 确认、重传调度、按序交付——这些复杂度全部被内核封装掉了,你的应用代码看不到任何痕迹。但 QUIC 选择了 UDP 作为底层传输,而 UDP 的语义只有一个:"把这个数据报从 A 搬到 B。" 到了没有?不知道。顺序对不对?不知道。丢了怎么办?自己想办法。
这就意味着,quicX 必须在用户态自建整套三层体系。不是自建一条链——那是 TCP 的路线——而是自建一张三层网:
-
确认层: quicX 的
SendControl维护着每个 Packet Number Space 的发送记录——每个包什么时候发的、PN 是多少、里面装了什么帧。当 ACK 回来时,OnPacketAck()更新确认状态;当某些 PN 超过阈值未被确认时,DetectLostPackets()执行判丢逻辑。RTT 采样、PTO 定时器也都在这一层。 -
恢复层:
SendManager管理着语义恢复队列。当SendControl判定某些包丢失后,丢失包里的帧语义会被提取出来,进入待恢复队列。SendManager根据帧类型决定恢复策略——STREAM 数据需要精确重传,控制帧需要发送最新值——然后通过GetPendingFrames()把待恢复的语义交给发送层。 -
交付层:
RecvStream维护着每个 Stream 自己的 Offset 空间。收到 STREAM Frame 后,OnStreamFrame()根据 Offset 把数据放到正确位置;如果 Offset 连续了,就立刻交付给应用层的回调。Stream A 的 Offset 有洞,不影响 Stream B 的完整交付——这是交付层独立于确认层的直接体现。 -
发送层:
PacketBuilder负责最终的组包装配。它拿到恢复层的待发语义和新数据,在不同加密级别(Initial / Handshake / 1-RTT)之间做调度,考虑包容量、Padding 要求、Coalescing 策略,最终把这些语义装进一个个真实的 Packet 发出去。
这些模块不能各写各的,因为三层之间有紧密的协作合约。判丢会影响恢复队列的内容,恢复队列的内容会影响 PacketBuilder 的组包决策,PacketBuilder 的发包行为又会产生新的 PN 记录反馈给确认层。三层是一个有机整体,任何一层的边界定义不清,都会导致连锁的状态混乱。
quicX 的可靠性子系统,是整个连接层的核心骨架。 ACK 解析、PTO 探测、语义重传、PacketBuilder 组包——所有这些模块都围绕这套三层骨架协同工作。骨架的边界定义不清,各模块之间的状态传递就会出现连锁混乱。
这不是"重复造轮子"。这是选择 UDP 架构后的必然代价,也是 QUIC 能够实现"确认不拖交付"这个设计目标的工程基础。
13.6 四章各打开一层
沿着 13.4 那张流水线图往下走,后续四章各自负责打开一个节点。
第 14 章打开确认层——三个 Packet Number Space 怎么划分,ACK 如何在各空间内精确表达,以及为什么 ACK 不能跨空间说话。没有准确的确认,后面一切都无从谈起。
第 15 章打开判丢接口——发送方凭什么认定一个包"已经丢了"。在一个本来就乱序、抖动的网络里,"没收到 ACK"远远不等于"已经丢"。时间阈值和包间隔阈值的并存逻辑,以及 PTO 为什么像一个幽灵不断逼着连接试探对端,都在这一章拆解。
第 16 章打开恢复层——丢了之后到底该补什么。STREAM 数据、CRYPTO 数据、控制帧、ACK 帧,不同类型的语义有完全不同的恢复策略。这里有卷三最关键的一次认知转折:丢的是包,恢复的是语义。
第 17 章打开发送层——知道该恢复什么语义是一回事,真正把这些语义装进 Packet 发出去是另一回事。控制帧和流数据怎么竞争有限的包容量,恢复中的语义和全新数据怎么排优先级,加密级别怎么影响组包策略——PacketBuilder 是可靠性真正落地的装配线。