QUIC协议 - 4 - QUIC的可靠传输
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): 在连接建立过程中,不同加密级别的数据包使用独立的包编号空间。数据包通过明确的数据包类型区分。主要有三个空间:
    - Initial Space: 用于传输初始握手数据(如 ClientHello)。包号从 0 开始。
- Handshake Space: 用于传输握手后半段加密数据(如服务器证书)。包号也从 0 开始。
- Application Data Space (1-RTT): 用于传输握手成功后的所有应用数据。包号同样从 0 开始。
 
QUIC从根本上杜绝了重传二义性:只重传数据,不重传包。
- 新包,新号: 当 QUIC 判断包号为 N的数据包丢失后,它永远不会再发送一个包号为N的包。
- 内容重组: 它会将包 N中需要重传的帧(Frames) 拿出来,放进一个全新的数据包中,并给这个新包分配一个全新的、更大的包号M(其中M > N)。
- 明确的确认: 接收方收到包 M后,会在ACK帧中明确地确认“我收到了包M”。
- 清晰的 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 帧的内容将会是:
- Largest Acknowledged: 100
- First ACK Range: 2(表示包100,99,98已收到,范围长度为3,100 - 2 = 98)
- ACK Range Count: 2,因为除了第一个[98-100]的块,还有[94-95]和[90]两个块。
- 第一个 Gap: 2(因为包97和96没收到,这是第一个块 [98-100] 和第二个块 [94-95] 之间的间隙)
- 第一个 ACK Range Length: 1(表示包95和94已收到,范围长度为2,95 - 1 = 94)
- 第二个 Gap: 3(因为包93,92,91没收到)
- 第二个 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。这个延迟由两个条件共同决定,以先触发者为准:
    - max_ack_delay计时器: 在连接建立时,双方会通过传输参数协商一个- max_ack_delay值(默认为 25 毫秒)。当接收方收到一个需要确认的包后,会启动一个计时器。如果在这个时间内没有发送 ACK,计时器到期时就必须发送一个。这保证了 ACK 的延迟不会超过一个上限。
- 包计数阈值: 为了避免在高速率连接中因等待计时器而导致反馈过慢,接收方通常会实现一个策略:每收到 N个需要确认的数据包后,就发送一个 ACK。N通常是一个较小的值,例如2。
 
这种“乱序则快,顺序则缓”的策略,使得 QUIC 的 ACK 机制非常高效且自适应。
丢包检测
QUIC的丢包检测基于两大机制:
- 基于时间的丢包检测 (Time-Based): 当发送数据后,在一段合理的时间内没有收到确认,就认为包丢失了。
- 基于包阈值的丢包检测 (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:
- 空间为Handshake或ApplicationData
- 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;- 若空间为Initial或Handshake:不加max_ack_delay(视为0)。
 
- 若空间为
基于包阈值的丢包检测
等待PTO计时器有时会很慢。为了能更快地响应丢包,QUIC 还使用了基于包阈值的检测方法。
原理:如果一个较新的包都已经到了,那么一个比它早很多的旧包如果还没到,则就被判定为丢包:
- 
    QUIC 定义了一个乱序阈值 kPacketThreshold(通常为 3)。
- 
    当一个包号为 M的数据包被确认时,发送方会检查所有比M小的、尚未被确认的包。
- 
    对于其中任何一个包号为 N的包,如果满足M - N >= kPacketThreshold,那么发送方就立即宣告包N丢失,并准备重传其内容,而无需等待 PTO 计时器。
选取顺序
- 交替使用packet threshold与time threshold,有助于在不同重排序分布下保持灵敏度与鲁棒性。
- 计时精度不小于kGranularity,避免颤动与误判。
重传机制
TCP 的方式:重传“段” (Segment)
当 TCP 认为一个段丢失了,它会重传一个内容和序列号都完全相同的段。如我们之前讨论的,这会导致“重传二义性”,使 RTT 计算变得复杂。
QUIC 的方式:重传数据内容” (Data Content)
当 QUIC 的丢包检测机制宣告一个包号为 N 的数据包丢失时,它永远不会再创建一个包号为 N 的包。相反,它会执行以下操作:
- 将包 N标记为“丢失”。
- 检查包 N里面承载的帧 (Frames)。
- 将这些需要可靠传输的帧(的数据内容)放入一个全新的数据包,并为其分配一个全新的、更大的包号 M。
- 发送这个新包 M。
QUIC的有序性交付
QUIC 提供的有序性保证,作用范围是“流 (Stream)”,而不是“连接 (Connection)”。
这意味着 QUIC 承诺在一个独立的流中,数据会按顺序交付给应用层;但它不保证多个流之间数据的交付顺序。这与 TCP 完全不同,TCP 的有序性保证是作用于整个连接的。
核心机制:STREAM 帧的 Stream ID 和 Offset
- Stream ID(流 ID): 这是一个唯一标识符,用于区分不同的并发数据流。当应用层要发送多份独立的数据时,QUIC 会为每一份数据分配一个独立的- Stream ID。这个 ID 就像是高速公路上的不同车道,每个车道中的车流(数据)都是独立前行的。
- Offset(偏移量): 这是一个字节偏移量,用于标识当前- STREAM帧中的数据,在它所属的那个流中的确切位置。这完全等同于 TCP 序列号在字节流中的作用,但它的作用域被限定在了一个- Stream ID内部。
这两个字段如何协同工作?
- Stream ID负责分离 (Isolate):它将整个 QUIC 连接分解为多条并行的、互不干扰的逻辑通道。
- Offset负责排序 (Order):它在每一条独立的逻辑通道内部,为数据块提供了精确的排序依据。
当接收方的 QUIC 协议栈收到数据包并解密后,它会执行以下有序交付的流程:
- 为每个流维护独立缓冲区: 接收方会为每一个活跃的 Stream ID都开辟一个独立的接收缓冲区。
- 放入数据:
    - 当收到一个 STREAM帧时,接收方首先检查其Stream ID,找到对应的流缓冲区。
- 然后,它检查帧内的 Offset,将帧中的数据准确地放置到缓冲区的相应位置。
 
- 当收到一个 
- 处理乱序和空洞: 由于 UDP 的无序性,STREAM帧很可能不会按Offset的顺序到达。- 场景: 假设 Stream 5的缓冲区已经收到了Offset: 0, Length: 1000的数据,下一个到达的却是Offset: 2000, Length: 1000的数据。
- 操作: 接收方会将 [2000, 2999]这段数据先缓存起来,此时在[1000, 1999]的位置就形成了一个“空洞”。
 
- 场景: 假设 
- 按顺序交付给应用层:
    - 接收方的协议栈会持续检查每个流的缓冲区。
- 只有当缓冲区中从可交付的起始位置开始,出现连续的、没有空洞的数据时,这部分数据才会被交付给上层应用。
- 在上面的场景中,[0, 999]的数据会立即被交付。而[2000, 2999]的数据会一直被缓存,直到承载着[1000, 1999]数据的那个丢失的数据包被重传并抵达,填补了空洞之后,[1000, 2999]才会一起被交付给应用。