QUIC 的可靠传输依赖一套环环相扣的机制:

  • 包编号
  • ACK 确认机制
  • 丢包检测
  • 数据重传
  • 流的有序交付

包编号

Header:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+
     |0|S|R|R|K|P P|
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                Destination Connection ID (0/32..160)        ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                     Packet Number (8/16/24/32)              ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      Packet Payload (*)                     ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Data:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     | Frame Type (8)|
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                        Stream ID (i)                        ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                         [Offset (i)]                        ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                         [Length (i)]                        ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                        Stream Data (*)                      ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

与 TCP 使用基于字节流的序列号 (Sequence Number) 不同,QUIC 的基本单位是数据包 。

  • 唯一且单调递增的包号: QUIC 发送的每一个数据包Header都有一个唯一的、严格单调递增的包编号。在一个确定的包编号空间内,一个包号永远不会被重复使用。一旦发送,这个数字就被消耗掉了。
  • 独立的包编号空间 (Packet Number Spaces): 在连接建立过程中,不同加密级别的数据包使用独立的包编号空间。数据包通过明确的数据包类型区分。主要有三个空间:
    1. Initial Space: 用于传输初始握手数据(如 ClientHello)。包号从 0 开始。
    2. Handshake Space: 用于传输握手后半段加密数据(如服务器证书)。包号也从 0 开始。
    3. Application Data Space (1-RTT): 用于传输握手成功后的所有应用数据。包号同样从 0 开始。

QUIC从根本上杜绝了重传二义性只重传数据,不重传包

  1. 新包,新号: 当 QUIC 判断包号为 N 的数据包丢失后,它永远不会再发送一个包号为 N 的包。
  2. 内容重组: 它会将包 N 中需要重传的帧(Frames) 拿出来,放进一个全新的数据包中,并给这个新包分配一个全新的、更大的包号 M(其中 M > N)。
  3. 明确的确认: 接收方收到包 M 后,会在 ACK 帧中明确地确认“我收到了包 M”。
  4. 清晰的 RTT 计算: 发送方收到对包 M 的确认后,它毫不含糊地知道这是对它在特定时间点发送的包 M 的响应。因此,它可以立即用“收到 ACK 的时间”减去“发送包 M 的时间”,得到一次干净、准确的 RTT 样本。

ACK确认机制

QUIC 使用一个独立的 ACK 帧来传递确认信息。它的设计目标是用一个帧确认尽可能多的、即使是不连续的数据包。

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                     Largest Acknowledged (i)                ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                          ACK Delay (i)                        ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                       ACK Range Count (i)                     ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      First ACK Range (i)                      ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                         Gap (i)                             ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                       ACK Range Length (i)                    ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     ... (Gap and ACK Range Length fields repeat ACK Range Count times) ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                          [ECN Counts]                         ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

ACK 帧的核心内容:

  • Largest Acknowledged (最大确认包号): 是接收方在该包编号空间中收到的最大包号(并不代表Largest Acknowledged之前的包都收到了)
  • ACK Delay : 表示从接收到 Largest Acknowledged 那个包,到发送此 ACK 帧之间经过的时间(以微秒为单位)。 这允许发送方进行更精确的 RTT测量。发送方计算 RTT 时,可以用总时间减去这个 ACK Delay,从而排除了接收端因应用繁忙或延迟确认策略所引入的内部延迟,得到一个更纯粹的网络 RTT。
  • ACK Range Count: 表示 ACK 帧中包含了多少个不连续的确认块
  • First ACK Range: 表示从 Largest Acknowledged 开始,往数有多少个连续的包都被确认了。
  • Gap 和 ACK Range Length : 这两个字段成对出现,重复 ACK Range Count 次,用于描述其他不连续的确认块。
    • Gap: 表示当前确认块与前一个确认块之间有多少个未被确认的数据包。
    • ACK Range Length: 表示当前这个确认块包含了多少个连续的数据包。
  • ECN Counts (Optional): 如果类型是 0x03,则包含 ECN (显式拥塞通知) 计数。

假设接收方收到了以下包号的数据包:100, 99, 98, 95, 94, 90。那么 ACK 帧的内容将会是:

  1. Largest Acknowledged: 100
  2. First ACK Range: 2 (表示包 100, 99, 98 已收到,范围长度为3,100 - 2 = 98)
  3. ACK Range Count: 2,因为除了第一个[98-100]的块,还有[94-95]和[90]两个块。
  4. 第一个 Gap: 2 (因为包 9796 没收到,这是第一个块 [98-100] 和第二个块 [94-95] 之间的间隙)
  5. 第一个 ACK Range Length: 1 (表示包 9594 已收到,范围长度为2,95 - 1 = 94)
  6. 第二个 Gap: 3 (因为包 93, 92, 91 没收到)
  7. 第二个 ACK Range Length: 0 (表示只有包 90 这一个包收到,范围长度为1,90 - 0 = 90)

ACK 的发送策略

如果每收到一个数据包就立刻回送一个 ACK,会造成巨大的网络开销(ACK风暴)。因此,QUIC 设计了一套智能的延迟确认策略。

这个策略的核心是在“快速反馈”和“减少开销”之间找到平衡。

  • 规则1:立即确认 (Immediate ACK) 在某些情况下,接收方需要立即发送 ACK,以便让发送方尽快获得网络状态。主要场景是:
    • 收到乱序包时: 如果收到的包号不是期望的下一个包号(例如,刚收到包 10,下一个却收到了包 12),这强烈暗示包 11 可能丢失了。立即发送 ACK 可以让发送方通过“包阈值”机制快速检测到丢包并重传。
    • 打破静默时: 如果连接在一段时间内没有通信,收到的第一个需要确认的包应该被立即 ACK,以快速重启 RTT 测量。
  • 规则2:延迟确认 (Delayed ACK) 在大多数正常情况下(收到的包是连续的),接收方会延迟一小段时间再发送 ACK。这个延迟由两个条件共同决定,以先触发者为准
    1. max_ack_delay 计时器: 在连接建立时,双方会通过传输参数协商一个 max_ack_delay 值(默认为 25 毫秒)。当接收方收到一个需要确认的包后,会启动一个计时器。如果在这个时间内没有发送 ACK,计时器到期时就必须发送一个。这保证了 ACK 的延迟不会超过一个上限。
    2. 包计数阈值: 为了避免在高速率连接中因等待计时器而导致反馈过慢,接收方通常会实现一个策略:每收到 N 个需要确认的数据包后,就发送一个 ACK。N 通常是一个较小的值,例如 2

这种“乱序则快,顺序则缓”的策略,使得 QUIC 的 ACK 机制非常高效且自适应。

丢包检测

QUIC的丢包检测基于两大机制:

  1. 基于时间的丢包检测 (Time-Based): 当发送数据后,在一段合理的时间内没有收到确认,就认为包丢失了。
  2. 基于包阈值的丢包检测 (Packet-Based): 当一个较新的数据包已经被确认,而一个比它早很多的旧包还没被确认,就提前认为这个旧包丢失了,无需等待计时器超时。

基于时间的丢包检测

PTO

在TCP中,当发送方发送一个数据包后,会启动一个重传计时器 (RTO),如果在计时器超时之前没有收到对该数据包的确认,发送方会认为这个包丢失了,然后大幅降低发送速率。这种机制处罚过于严厉,即使只是暂时的网络抖动或一个 ACK 包丢失,TCP 也会认为网络发生了严重拥塞,导致传输速率急剧下降,需要很长时间才能恢复。


QUIC 的思想是,在没有收到 ACK 的情况下,最重要的事情不是立即重传可能丢失的数据,而是先确认网络的连通性是否还存在,并从对端获取最新的反馈

PTO(Probe Timeout)是 QUIC 的“探测超时”,用来在“收不到 ACK,无法判断是否丢包”的情况下主动发送少量探测包,促使对端回应,从而恢复前进。

PTO 的目标不是直接“判定丢包”,而是“打破僵局”:制造新的 ACK 或 ECN 反馈,让常规的丢包检测(基于报文号/时间阈值)得以继续工作。

QUIC 将连接分成三个独立的报文号空间:Initial、Handshake、Application Data;每个空间都有自己的未确认数据、定时器与PTO计数


何时启动/更新 PTO

  • 只要某个报文号空间内存在会触发 ACK 的未确认包,就需要为该空间维护一个 PTO 到期点。
  • 每次发送、被 ACK、或判定丢失都会改变该空间的在途数据队列,从而需要重新计算并重新设置 PTO到期点。
  • 多个空间同时存在未确认数据时,发送端取“最早到期”的那个空间作为全局的下一次 PTO 触发目标。

ACK-eliciting(触发确认)指会促使对端发送 ACK 的包/帧。


PTO超时后的动作

  • 发送探测包(probes):
    • 优先发送“新的、会触发 ACK 的数据”(最有效,因为一旦到达就能推进进度)。
    • 若无新数据可发,则重发“可重传的旧帧”(例如未确认的 STREAM 数据片段、CRYPTO 帧)或发送 PING,务必保证包是 ACK-eliciting。
    • 常见做法是在一次 PTO 到期时发送“1~2 个”ACK-eliciting 探测包,以降低单个探测包本身丢失导致再次 PTO 的风险。
  • 可以短暂“越过拥塞窗口”发送这些探测包:
    • 即使拥塞窗口已被占满,PTO 触发的探测包仍可少量发送(仅限探测用途),否则可能陷入“等不到 ACK、也发不出包”的死锁。

PTO 如何影响重传/恢复

  • PTO 本身不直接判丢,也不规定重传哪个包号。它让你发送新的 ACK-eliciting 包,从而:
    • 如果探测包或其随后的对端 ACK 抵达,正常的丢包检测机制就能根据“已确认的最高包号”和“时间阈值”去判定哪些更早的包已经丢失;
    • 被判定丢失的帧会被“重发到后续新包”里(QUIC 重发的是帧而不是原包)。
  • 当 PTO 触发且你选择“重发帧”作为探测内容时,这些重发并不是“按原包号重传”,而是把未确认的重要帧(如 STREAM/CRYPTO)重新装入新包发送。这样既当“探测”,又完成了实质性的重传。
  • 拥塞控制配合:
    • 进入 PTO 并不自动减少拥塞窗口;窗口的收缩通常来自“真正的丢包判定”或“持久性拥塞”的检测。
    • 探测包允许越过窗口的额度很小,目的只是解锁 ACK,避免完全停滞。
  • 指数回退:连续 PTO 触发而仍未收到有效 ACK 时,PTO 的等待时间会逐步拉长(指数级增加),避免过于频繁地无效探测。

如何计算PTO:

0. 核心状态量

  • latest_rtt: 最新一次测量到的 RTT 样本值。
  • min_rtt: 连接建立以来,观测到的最小 RTT(不扣除ack_delay)。
  • ack_delay: 对端在ACK中上报的延迟确认时间;仅在样本显著大于最小RTT时用于扣除。
  • smoothed_rtt (SRTT): 平滑的 RTT,这是一个加权移动平均值,代表了对连接 RTT 的长期、稳定的估计。
  • rttvar (RTT Variance): RTT 的变化量,代表了 RTT 的抖动程度。rttvar 越大,说明网络越不稳定。

1. RTT 样本 (latest_rtt)

当发送方发送一个需要被确认的数据包 P 时,它会记录下当前的发送时间 send_time

一段时间后,发送方收到了一个 ACK 帧,这个帧确认了包 P。接收 ACK 的时间是 ack_time。重要的是,这个 ACK 帧内部还包含一个 ack_delay 字段,表示接收方从收到包 P 到它发送这个 ACK 之间的内部处理延迟。

一次 RTT 样本的计算公式为:latest_rtt = ack_time − send_time

2. 最小RTT(min_rtt)

在首份RTT样本上,min_rtt必须被设置为latest_rtt。后续min_rtt = min(min_rtt, latest_rtt)

3. 调整后的RTT(adjust_rtt)

仅当下面条件都满足时,才调整RTT:

  • 空间为HandshakeApplicationData
  • ACK-eliciting样本,
  • latest_rtt - min_rtt > ack_delay时,

adjusted_rtt = latest_rtt - min(ack_delay, max_ack_delay); 否则:

adjusted_rtt = latest_rtt

4. 首次 SRTT/RTTVAR 测量

  • smoothed_rtt = adjusted_rtt
  • rttvar = adjusted_rtt / 2

5. 后续 SRTT/RTTVAR 测量

对于每一次新的 latest_rtt 样本,按以下步骤更新其变量:

  • smoothed_rtt = 7/8 * smoothed_rtt + 1/8 * adjusted_rtt

  • rttvar = 3/4 * rttvar + 1/4 * |smoothed_rtt - adjusted_rtt|

6. PTO的计算

  • PTO = smoothed_rtt + max(4 * rttvar, kGranularity) + ack_delay_term

kGranularity

  • 1ms(实现可取1–5ms),防止在极低 RTT 的网络中超时时间过短。

ack_delay_term

  • 若空间为ApplicationData:使用对端宣告的max_ack_delay

    • 若空间为InitialHandshake:不加max_ack_delay(视为0)。

基于包阈值的丢包检测

等待PTO计时器有时会很慢。为了能更快地响应丢包,QUIC 还使用了基于包阈值的检测方法。

原理:如果一个较新的包都已经到了,那么一个比它早很多的旧包如果还没到,则就被判定为丢包:

  • QUIC 定义了一个乱序阈值 kPacketThreshold(通常为 3)。

  • 当一个包号为 M 的数据包被确认时,发送方会检查所有比 M 小的、尚未被确认的包。

  • 对于其中任何一个包号为 N 的包,如果满足 M - N >= kPacketThreshold,那么发送方就立即宣告包 N 丢失,并准备重传其内容,而无需等待 PTO 计时器。

选取顺序

  • 交替使用packet thresholdtime threshold,有助于在不同重排序分布下保持灵敏度与鲁棒性。
  • 计时精度不小于kGranularity,避免颤动与误判。

重传机制

TCP 的方式:重传“段” (Segment)

当 TCP 认为一个段丢失了,它会重传一个内容和序列号都完全相同的段。如我们之前讨论的,这会导致“重传二义性”,使 RTT 计算变得复杂。

QUIC 的方式:重传数据内容” (Data Content)

当 QUIC 的丢包检测机制宣告一个包号为 N 的数据包丢失时,它永远不会再创建一个包号为 N 的包。相反,它会执行以下操作:

  1. 将包 N 标记为“丢失”。
  2. 检查包 N 里面承载的帧 (Frames)
  3. 将这些需要可靠传输的帧(的数据内容)放入一个全新的数据包,并为其分配一个全新的、更大的包号 M
  4. 发送这个新包 M

QUIC的有序性交付

QUIC 提供的有序性保证,作用范围是“流 (Stream)”,而不是“连接 (Connection)”。

这意味着 QUIC 承诺在一个独立的流中,数据会按顺序交付给应用层;但它不保证多个流之间数据的交付顺序。这与 TCP 完全不同,TCP 的有序性保证是作用于整个连接的。

核心机制:STREAM 帧的 Stream IDOffset

  • Stream ID (流 ID): 这是一个唯一标识符,用于区分不同的并发数据流。当应用层要发送多份独立的数据时,QUIC 会为每一份数据分配一个独立的 Stream ID。这个 ID 就像是高速公路上的不同车道,每个车道中的车流(数据)都是独立前行的。
  • Offset (偏移量): 这是一个字节偏移量,用于标识当前 STREAM 帧中的数据,在它所属的那个流中的确切位置。这完全等同于 TCP 序列号在字节流中的作用,但它的作用域被限定在了一个 Stream ID 内部。

这两个字段如何协同工作?

  • Stream ID 负责分离 (Isolate):它将整个 QUIC 连接分解为多条并行的、互不干扰的逻辑通道。
  • Offset 负责排序 (Order):它在每一条独立的逻辑通道内部,为数据块提供了精确的排序依据。

当接收方的 QUIC 协议栈收到数据包并解密后,它会执行以下有序交付的流程:

  1. 为每个流维护独立缓冲区: 接收方会为每一个活跃的 Stream ID 都开辟一个独立的接收缓冲区。
  2. 放入数据:
    • 当收到一个 STREAM 帧时,接收方首先检查其 Stream ID,找到对应的流缓冲区。
    • 然后,它检查帧内的 Offset,将帧中的数据准确地放置到缓冲区的相应位置。
  3. 处理乱序和空洞: 由于 UDP 的无序性,STREAM 帧很可能不会按 Offset 的顺序到达。
    • 场景: 假设 Stream 5 的缓冲区已经收到了 Offset: 0, Length: 1000 的数据,下一个到达的却是 Offset: 2000, Length: 1000 的数据。
    • 操作: 接收方会将 [2000, 2999] 这段数据先缓存起来,此时在 [1000, 1999] 的位置就形成了一个“空洞”。
  4. 按顺序交付给应用层:
    • 接收方的协议栈会持续检查每个流的缓冲区。
    • 只有当缓冲区中从可交付的起始位置开始,出现连续的、没有空洞的数据时,这部分数据才会被交付给上层应用。
    • 在上面的场景中,[0, 999] 的数据会立即被交付。而 [2000, 2999] 的数据会一直被缓存,直到承载着 [1000, 1999] 数据的那个丢失的数据包被重传并抵达,填补了空洞之后,[1000, 2999] 才会一起被交付给应用。