HTTP/3

生态分析

TCP队头阻塞

这是HTTP/2所拥有的根本性通病, 注意这里的队头阻塞不是指排队造成的阻塞, 而是指丢包造成的阻塞. TCP为了实现可靠有序, 采用了超时重传机制, 该机制可以确保可靠有序, 但是对于现代网络却有严重缺陷.

TCP中如果发送出现丢包, 必须要依靠超时重传等到当前包再次发来, 后面的包才能生效, 即使已经发来了, 后面的包也不能及时处理和使用, 这是在内核中就强制决定的事情, 上层根本无法干预, 其根本在于TCP”面向字节流“的本质.

因此HTTP/2的根本问题在于TCP队头阻塞, 也就是TCP本身, 而TCP想要升级是非常艰难的.

UDP vs TCP

HTTP/3最终选择了使用QUIC+UDP, QUIC先不详述, 可以理解为其可以利用UDP对TCP实现了约等于上位替代的效果. 下面将详述其原因 :

  • 升级因素 :

    • UDP是极简的协议, 在各个操作系统中的底层实现很少, “留白”非常多, 在应用层可以主导UDP协议的用户态升级.
    • TCP协议相对复杂, 有很多定死的规则, 这些是在操作系统中实现的, 应用层无法触及, 但是不同操作系统在底层的实现既庞杂又不尽相同, 很难实现操作系统层面统一的升级.
  • 商业因素 :

    基于上面的升级因素, TCP的升级会牵扯到整个操作系统社区, 基本无法有什么人来主导升级. 而UDP在不同操作系统中几乎统一, 不存在操作系统上的影响, 其大头始终在应用层的处理, 因此体量较大的公司就可以主导基于UDP的升级, QUIC就是谷歌主导开发的, 这种方式更受当下商业模式的青睐.

  • 设计局限 :

    也许这个才算最重要的原因, 就像上面说的TCP的问题导向可以简化如下 :

    1
    TCP队头阻塞 -> 超时重传低效 -> 面向字节流避无可避

    面向字节流这种无边界的设计必须要用这种类似超时重传的机制来补足, 效率必须为此让步, 这是设计上的局限.

    • 所以为什么一开始要使用TCP这种面向字节流的协议?

      因为设计者一开始的目的是为了设计一种既通用又安全可靠的通信协议, 面向字节流完美匹配了通用的特性, 因为字节是计算机铁打不动的特性, 可以适应所有的通信任务, 就算会在效率上做出让步, 也在很多地方可以做到一劳永逸, 减少特殊结构体的设计. 现在出现的QUIC完全是因为当下社会对于通信的效率要求迅速提高导致, 其虽然确实可以做到上位替代, 但编码成本,复杂度都会提高, 通用性也会降低. 这里补充一条ai出来的评价 “QUIC 是高性能系统通信中针对效率、低延迟、抗队头阻塞设计的现代方案,牺牲的是实现成本、通用性与对中间设备的透明兼容. “.


TLS 1.3 (安全加密协议)

TLS 1.2

这是最基础最广泛的加密协议版本.

  • 为了实现机密性, 其使用了 非对称密钥交换 + 对称加密 , 简单理解是使用了一些算法, 前者是通过公私钥使得双方在开始确定一个后期只有双方拥有的密钥, 后者则是利用这个密钥进行双方的交流.

  • 非对称密钥交换 :

    这里介绍最主流的ECDHE算法, 里面使用了椭圆曲线点乘.

    • d为私钥, Q为公钥, G为公开可知的一个生成点, 通信双方都会生成一份自己的公私钥(a 和 b).
    • da / db 都是1-n内的随机数.
    • Qa = da * G / Qb = db * G , 公钥将会发给双方.
    • k = Qa * db = Qb * da = da * db * G, k便是双方的共同密钥.

    至于为什么不可破解, 因为这里的 ***** 并非普通的乘法, 是椭圆曲线上的标量乘法, 肤浅理解就是 存在一种循环, 使得这种计算如果不知道对面的私钥, 仅凭计算破解可以有近乎无限的可能性, 是不可能的.

    • 前向保密 : ECDHE保证每次握手使用的私钥都是新的随机生成的私钥, 就算以前的私钥暴露也不会影响后面的通信.
  • 对称加密 :

    简单来说就是用先前获得的共享密钥实现双方信息的快速加解密.

    最主流的是AEAD模式, 不细究, 其实现了加密 + 完整性认证的一体原子化, 防止先解密再认证过程中可能出现的漏洞.

  • 握手流程 :

    握手建立连接共需2RTT.

    • 第一个RTT客户端发出请求, 服务端发出公钥给客户端.
    • 第二个RTT客户端进入加密态, 发出公钥, 服务端接受后匹配成功也进入加密态, 发送匹配成功的字段.
  • 使用证书用来进行身份认证, 确保没有被中间人篡改, 一般是将私钥提供给CA进行数字签名, 发送过去后再用CA的公钥进行验证, 确保没有被篡改.

具体变动

TLS1.3在TLS1.2的基础上主要是进行了简化和安全性的提升.

  • 将握手时间缩短到了1RTT :

    具体是客户端在请求同时直接发出自己的公钥, 服务端接收进行匹配并发出自己的公钥及各种证书签名, 随后进入加密态, 客户端在接收匹配成功后也进入加密态. 这样就实现了1RTT实现连接建立.

  • 会话恢复与0-RTT:

    在以往每次握手与结束都是独立的, 但是TLS1.3要求可对过往的共享密钥与各种状态在会话层进行记录, 假如支持对旧密钥的使用, 客户端可以直接向服务端发出使用旧密钥的请求, 并直接进行加密数据的发送, 这种形式被称为”0-RTT”. 当然也是有一定风险的, 其与前向保密机制本质相悖, 越长久地记录与使用旧密钥, 被外部盗取的可能就越大, 需要对安全性做出妥协.

  • HKDF(密钥派生函数) :

    ECDHE 负责“生成共享密钥”,HKDF 负责“把这个共享密钥变成一系列真正能加密通信的数据密钥”.

  • 重放攻击 :

    其本质在于欺诈服务器进行重复操作, 手段是重发截获的密文, 不去破解, 而是再次发送.


QUIC (Quick UDP Internet Connections)

QUIC重构了一个效果和TCP相近的以UDP为底层的网络通信协议, 抛去了TCP的缺点, 并且设计中与http/2有很多相似的地方, 可以理解为其整合了TCP + HTTP/2.

QUIC报文

  • QUIC的报文以为单位, 与UDP面向数据报的性质匹配.

  • 一个包分为 Header 和 Payload(data).

  • Header 分为两种基础类, Long Header 和 Short Header, 分别用于早期连接建立和数据传输.

  • Long Header : 下面是Long Header对应包的整体结构

    1
    2
    3
    4
    +--------+--------+----------------------+--------------+------------------+--------------+
    | 1B | 4B | DCID Len + DCID | SCID Len + SCID | Packet Number | Payload |
    | Flags | Version| Destination Conn ID | Source Conn ID | (1-4 bytes) | (frames) |
    +--------+--------+----------------------+--------------+------------------+--------------+
    字段名称 长度 类型说明 作用
    Flags(首字节) 1 byte Bit 字段,包含包类型、包号长度等信息 指定包头类型、包号长度等
    Version 4 bytes uint32 指定使用的 QUIC 协议版本
    DCID Len + DCID 1B + 可变 Length + byte[] 目标连接 ID(Destination Connection ID)
    SCID Len + SCID 1B + 可变 Length + byte[] 源连接 ID(Source Connection ID)
    Packet Number 1-4 bytes uint8~uint32(根据Flag中指定) 包序号,用于重传与流控
    Payload 可变 多个 Frame 的组合 包含握手数据(如 ClientHello)或流数据
    • Flags : 下面是在这个比特位中记录的信息

      比特位 名称 说明
      bits 7-6 固定 11 表示是 Long Header
      bits 5-4 Packet Type 包类型,决定是 Initial / 0-RTT / Handshake / Retry
      bits 3-2 Packet Number Length (PNLen) Packet Number 字段的长度(03 表示 14 字节)
      bits 1-0 Reserved 保留位,当前未使用(应设置为 0,接收方忽略)

      包类型后续详述, 包序号长度用于解析后面的包序号字段.

    • DCID(目标连接id) / SCID(源连接id) :

      其作用为标识唯一的一个连接, 在握手阶段还可以确立双向连接. 需要明确的是, UDP本身是无连接的, 这个连接机制是QUIC在上层为UDP额外设计的, 目的是为了在上层监视控制连接状态, 使其可以像TCP一样随时获取连接信息.

      在握手阶段中, 客户端发送的SCID(随机生成)将被服务端用作连接的DCID, 服务端发送的SCID也会被客户端用作DCID, 以此实现连接的建立.

      一个服务器可以和多个客户端有多条QUIC层面上的连接, 但是一对一情况下只能有一条连接.

    • Packet Number 相关的重传流控机制后面详述.

    • Payload 就是存放数据的字段, 但其中的单位为frame, frame有不同的类型, 后面详述.

  • Short Header : 下面是Long Header对应包的整体结构

    1
    2
    3
    4
    +--------+----------------------+--------------+--------------+
    | Flags | Destination Conn ID | Packet Number| Payload |
    | 1B | 8B | (1-4 bytes) | (frames) |
    +--------+----------------------+--------------+--------------+
    • Flags : Short Heder中的Flags和前者中的Flags有所不同.

      比特位 名称 说明
      bit 7 固定为 0 表示 Short Header
      bit 6 Key Phase (K) 用于密钥轮换:表示当前是否需要密钥轮换
      bit 5 固定为 0 保持格式一致(保留)
      bit 4 Spin Bit (S) 可用于 RTT 测量,非加密,中间设备可观测
      bits 3-2 PNLen Packet Number 长度(00 ~ 11 表示 1 ~ 4 字节)
      bits 1-0 Reserved 保留位,应随机设置,接收端忽略
  • Frame(帧)

    这就是payload字段中的基本单元, 也可以近似为数据传输的基本单元.

    • 形式通常为 Frame Type(1B) + 根据type追加的各种对应字段(如stream_id, length, data等).

    • Frame Type : 下面是各种类型对应的编码和说明

      十六进制 帧名(Frame Name) 说明
      0x00 PADDING 填充,无内容
      0x01 PING 心跳,调试用
      0x02 ACK 确认包,用于确认对方 packet
      0x03 ACK + ECN 带显式拥塞通知
      0x04 RESET_STREAM 中断某个流
      0x05 STOP_SENDING 请求对方停止发送某流
      0x06 CRYPTO 用于 TLS 握手数据的传输
      0x07 NEW_TOKEN 地址验证相关
      0x08+ STREAM(流帧) 实际的数据帧(带 Stream ID)
      0x1c MAX_STREAMS 通知对方允许更多流
      0x1e DATA_BLOCKED 数据流被流控阻塞
      0x1f STREAMS_BLOCKED 流的数量受限
    • CRYPTO 帧 : 其具体结构如下

      1
      2
      3
      4
      [Frame Type (1B)]
      [Offset (varint)] // TLS 数据在整个 crypto stream 中的偏移
      [Length (varint)] // 本次数据长度
      [Crypto Data (Length 字节)]
    • STREAM 帧 : 该帧是最基础的数据帧

      1
      2
      3
      4
      5
      [Frame Type (1B)]
      [Stream ID (varint)]
      [Offset (varint, optional)]
      [Length (varint, optional)]
      [Data (length 指定的字节数)]

      需要注意的是, stream帧相比于其他帧, 其Frame Type字段除了表明帧类型之外, 其前三位还有标记位的作用 :

      位名 含义
      FIN(0) 1 表示这是该流的最后一块
      LEN(1) 1 表示帧中包含 Length 字段
      OFF(2) 1 表示帧中包含 Offset 字段
  • Packet Type :

    在Long Header中的flags中我们可以发现Packet Type字段, 而Short Header中没有. 因为前者有四种包类型, 后者只有一种1-RTT. 因此包类型这个机制主要是为了控制握手阶段的各种机制而创造的.

    • 不同的包类型有不同的可用frame类型, 不同的密钥, 不同的加密方式.
    包类型 可用 frame 类型 是否加密 使用密钥类型 说明
    Initial CRYPTO, PADDING, ACK ❌(初始密钥) Initial Secret 主要用于传输 ClientHello
    Handshake CRYPTO, ACK, CONNECTION_CLOSE Handshake Secret ServerHello、Finished 等
    0-RTT STREAM, ACK, PADDING 0-RTT Secret(缓存) 客户端早发应用数据,不可靠
    Retry ❌ 无 frame 明文 特殊包,用于拒绝连接请求并附带 token
    1-RTT 所有 frame(包括 STREAM) Application Secret(1-RTT) 应用数据正式传输阶段

    这里我们需要结合前面学习的帧类型和TLS1.3中的握手机制加以理解, 不同的帧类型会被用在不同的包中, InitialHandshake分别被用来1RTT建立连接的来去程中, 而0-RTT则被用在0RTT恢复连接的过程中. 我们可以看见QUIC已经在底层将TLS1.3的机制融入到了设计中.

  • 加密相关 :

    QUIC几乎所有包和包中的帧都进行了加密, 但是Initial包并非, 毕竟还没有共享密钥, 虽然确实会加密, 但也是简易加密, 可以很快破解, 其核心作用更多是在于保证完整性. 直到回程服务器开始发送Handshake帧, 在确立了共享密钥之后, 连接才正式进入了完全加密阶段.

    另外在Short Header包头中的Flags中, 还有Key Phase标志位用于是否进行密钥轮换. 所谓密钥轮换, 其目的是防止长期使用同一个密钥所带来的泄露风险, 只要变更该标志位, 就代表需要进行密钥轮换, 降低泄露风险.

Stream管理与数据传输

QUIC的Stream机制和HTTP/2的流机制一脉相承, 但是使用UDP解决了HTTP/2 TCP队头阻塞的核心痛点, 并且QUIC在此基础上实现了更细粒度的控制.

为什么可以解决队头阻塞问题? 这里再细化一下最初对于TCP队头阻塞的理解, 只要发生丢包, 后面的包都要等丢的包重发完毕才可生效, 这就产生一个现象, 就算包是属于不同流的, 但是在HTTP/2底层仍属于同一个TCP连接, 也就是说不同流的丢包是会相互影响的. QUIC的设计将连接的有序细化为了流的有序, 也就是说一个流的丢包只会影响所属流, 流之间没有影响.

简单理解中, QUIC依旧依据stream_id进行每个流的单独管理, 但在很多机制上有所改变或细化 :

  • stream id :

    在HTTP/2中stream_id是根据双方按奇偶划分的, 但在QUIC中, stream_id一般大小被设置为62bit, 分为两个标志位和编码部分 :

    比特位 含义
    bit 0 发起者:0 = client, 1 = server
    bit 1 类型:0 = bidirectional(单向), 1 = unidirectional(双向)
    bits 2-61 编号部分(严格递增)

    也就是说流有了明确的发起者和方向类型的定义, 编号的递增规则会根据前面标志位有所变化, 这里不再细究.

  • 流的方向 :

    在stream_id中就需要声明流的方向, 我们可以自己设置流的流向.

    • 在HTTP/2中, 流向是固定双向的, 因为TCP本身固定双向, 两边都要设置流控资源和状态机.
    • 在QUIC协议中, 流向可单可双, 因为UDP是无方向的, 单向可只在接收方设置流控资源和状态机, 可以做到更细粒度的控制.
  • 流控手段 :

    和HTTP/2几乎相同, 都可以控制连接流量和单一流的流量, 存在特殊的帧类型MAX_DATA / MAX_STREAMS来控制流量.

    但是QUIC可以控制流的断开 :

    • STOP_SENDING : 这是一个特殊的帧类型, 用于中断对方在该流上的数据传输.

      主要用于接收方出于某些原因取消资源接收的情况, 在HTTP/2中没有办法中途取消, 只能将对方发来的包直接抛弃掉, 这会让服务端浪费很多资源, 而QUIC可以中断单独流并且不影响其他流.

重传/ACK 管理机制

核心就是实现类似TCP的保序 + 丢包重传机制.

  • Packet Number :

    每个包都会被分配到自己的包序号, 序号严格递增, 用来配合重传机制. 并且这个参数由于每包独立, 其还被用作AEAD加密算法进行密钥派生的参数, 可以确保密钥不被破解.

  • ACK帧 :

    这也是一个特殊的帧类型, 由接收方发送, 让发送方进行确认和丢包判断.

    • 只要收到对应包的ACK帧, 就代表该包已接收.
    • ACK帧中还会描述在当前包之前未收到的包序号范围(Range), 也就是说QUIC采用范围判断.
    • 一个ACK帧可以一次性ACK多个包, 不像TCP需要按序且一对一.
    • ack_delay字段是ACK帧的必带字段, 用于描述ACK帧故意延迟发送了多久. 延迟发送机制是为了配合上一条特性, 累计一定的ACK一并发出, 可以有效降低ACK帧的发送频率.
  • 丢包检测机制 :

    总共由两种丢包检测机制, 一种基于时间, 一种基于包序号.

    • 基于时间的超时检测 : 超过一定RTT的时间未收到ACK帧自动重传.

    • 基于包序号的检测 : 可以设置一个值k, 通过接收到的未到包序号范围, 只要包序号小于最大包序号 - k的包, 将直接重传.

      该检测对标TCP的快速重传机制, 判断更加简单且敏感.

拥塞控制

思想内核与TCP一致, 依旧是维护一个拥塞窗口, 即已发送但未被确认的字节值. 不过QUIC有以下三个额外的特点 :

  • QUIC规定下的拥塞控制算法是可拔插的, 也就是可以据情况随时更换拥塞控制算法, 这里不细究算法内核.

  • 所有拥塞控制算法离不开使用RTT, 因此为了计算准确的RTT, 会获取ACK帧中的ack_delay字段来辅助计算.

  • 由于QUIC的流有流向机制, 所以只需要在发送方设置拥塞控制机制, 接收方无需设置.

连接迁移与路径验证

QUIC允许客户端随时变更网络, 而不需要重新建立连接, 可以在原连接上进行. 经典例子就是wifi网络的替换(wifi1 -> wifi2 或 移动网络 -> wifi).

  • IP 欺骗攻击(Address Spoofing)

    简单来说就是攻击方可以伪造变更ip的数据包, 欺诈攻击方进行无用的传输, 也有可能带来更深层次的数据泄露.

  • 路径验证 :

    QUIC会在连接发生迁移时通过发送特殊帧的方式确立对迁移连接的重新信任, 以此防IP欺诈攻击.

    • 虽然在QUIC在设计上把连接用CID表示, 不再依赖于ip/port, 但是其内部依旧会存储CID对应的ip/port, 当发生实际迁移时, 就可以迅速感知到这种变化.
    • PATH_CHALLENGE帧 : 在服务器注意到连接迁移时向目标客户端发送, 用于确认.
    • PATH_RESPONSE帧 : 接收到PATH_CHALLENGE帧后发送, 只有拥有先前的共同密钥才能进行正确的响应, 服务器收到后确立信任.

HTTP/3 内核

HTTP/3完全基于QUIC协议设计, 可以理解为QUIC的完全上层建筑.

但是HTTP/3 是 HTTP/2 的语义继承者:方法、状态码、字段等基本一致, 各种设计理念一脉相承, 只在个别情况会有实际差别, 如果想要学习每个帧类型的作用可以看我HTTP/2的讲解.

QUIC是如何承载HTTP/3的?

  • QUIC实现的是传输层的工作.
  • HTTP/3也有自己的帧概念, 和HTTP/2基本一致, HTTP/3 所有帧都是“间接”地存在于 QUIC Packet 的 STREAM 帧内部的.
  • 每个 HTTP 请求/响应都使用一个 QUIC Stream, 多个流可以并发交错发送.

HTTP/3 与 HTTP/2 的兼容

  • 帧结构, 帧类型, 帧的处理几乎不变.
  • 流控机制没有变化.
  • 优先级机制不变, 但是在HTTP/3中几乎不再使用, QUIC认为”多帧就是高优先级”.

基于流向的流类型划分

HTTP/3在QUIC的流向基础上规定了四种流类型 :

  • 控制流 (Control Stream) :
    • 单向流, 第一帧必须是SETTINGS, 后续帧视情况添加.
    • 用于初始连接时协商参数, 双方必须在握手之前各开一条控制流进行交流.
  • 请求/响应流 :
    • 双向流, 最基础的HTTP流, 包含HEADERS/DATA等基础帧.
    • 一般会并发开很多这样的流进行数据传输.
  • QPACK encoder stream / decoder stream :
    • 两条单向流, 分别由客户端 / 服务端发起.
    • 其作用是配合实现报头压缩机制, 后面详述.
  • 还有一种用来配合Server Push机制, 但是该机制已经过时, 被高速缓存取代.

在实际HTTP/3连接中, 各种流的使用逻辑大致如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
QUIC连接建立成功

├─> 客户端创建控制流(发送 SETTINGS)

├─> 服务端创建控制流(发送 SETTINGS)

├─> 客户端创建 QPACK Encoder Stream(ID=2)

├─> 服务端创建 QPACK Decoder Stream(ID=3)

├─> 客户端创建请求流(ID=0, 4, 8...)
│ ├─> HEADERS(请求头)
│ ├─> DATA(请求体)
│ └─> END_STREAM

└─> 服务端在同一流上响应
├─> HEADERS(响应头)
├─> DATA(响应体)
└─> END_STREAM

QPACK报头压缩机制

其自然对标的是HTTP/2的HPACK机制, 相比于其他部分不同, 这一部分有相对大的改动.

  • 为什么不能沿用HPACK机制?

    因为动态表需要双方完全一致, 需要保证同步, 而HPACK的同步是依赖于TCP机制的有序性实现的, QUIC所依赖的UDP本身是无序的, 所以需要我们设置一定的机制保证动态表的一致性.

  • 在HTTP/2中, 报头压缩机制是融合在HEADERS帧中的, 可以理解为HTTP/2在用HEADERS帧控制动态表, 但在HTTP/3中将这一机制从其中拆解出来, 放到了独立的两条单向流中, 更加便于控制管理.

  • QPACK encoder stream / decoder stream :

    这是两条单向流, 双方在内核依旧会维护动态表, 会由encoder stream, 也就是客户端主导对于双方动态表的修改, 其会发送要求对端修改动态表的帧, 而decoder stream则用来回复各种修改的回应和动态表的使用情况.

  • 但是上面并没有解决动态表要实现双方同步的诉求, 由于UDP是无序的, 客户端修改了自身的动态表并发送对端的修改命令, 对端的修改会有不可避免的时差, 如果提前接收到了使用这个修改的HEADER帧, 就会产生不可逆的错误.

  • Required Insert Count :

    这是HEADERS帧中需要表明的一个数值, 代表这个HEADERS帧可使用的最小动态表范围, 简单来说就是不再要求双方动态表完全一致, 而是要求双方动态表一定范围内完全一致即可.

    具体描述就是假设动态表是双方维护的一组的vector(分为E方和D方), 一般都是E方先进行插入, 然后告诉D方进行插入, 假如这期间E方发起请求, HEADERS帧中的Required Insert Count就会记录当前E方vector的最大下标, D方接收到时会查看自己的vector最大下标是否大于等于这个值, 如果大于, 则进行读取, 反之则阻塞等到条件符合.

    至此QPACK解决了QUIC的无序传输动态表更新问题.

升级机制

客户端可以通过HTTP/1或HTTP/2探测服务端是否支持HTTP/3服务.

  • Alt-Svc : 其可作为字段出现在1/2的报头字段中, 用来表示自己支持HTTP/3, 也可以附带端口和时间.

    1
    Alt-Svc: h3=":443"; ma=86400

    表示支持 HTTP/3(版本 h3),端口是 443,缓存时间为 1 天。

  • 在接收到此字段后, 客户就可以尝试使用QUIC连接服务器并开始HTTP/3传输.

这里想要去学习HTTP3在C++中的实际使用, 简单看了ngtcp2和nghttp3库, 使用起来有些复杂, 之后可能会补充学习.


HTTP/3
http://example.com/2025/08/05/HTTP3/
作者
天目中云
发布于
2025年8月5日
许可协议