HTTP/2 与 HTTP/3

用生活化的比喻,让你理解 HTTP 协议的演进——多路复用、头部压缩、服务器推送,以及 HTTP/3 为什么基于 QUIC

前置知识:第06章 TCP/IP + 第07章 UDP与可靠传输(理解 TCP 队头阻塞和 QUIC)


阅读指南(初学者必看)

为什么你需要学习 HTTP/2 与 HTTP/3?

你每天都在用 HTTP——网页加载、游戏资源下载、API 请求都是 HTTP。但 HTTP 从 1.0 到 3.0 经历了巨大变革:

  • HTTP/1.1 的管道化为什么没有解决问题?
  • HTTP/2 的多路复用解决了什么问题?还有什么没解决?
  • HTTP/3 为什么不再基于 TCP?

学完本章,你能回答:

  • HTTP/1.0 → 1.1 → 2 → 3 每一代解决了什么问题?
  • HTTP/2 的多路复用和帧机制是怎么工作的?
  • HPACK 头部压缩的原理是什么?
  • HTTP/3 基于 QUIC 解决了什么问题?

本文结构

第一部分:HTTP 协议演进(理解每一代的改进)
第二部分:HTTP/2 多路复用(理解帧和流)
第三部分:头部压缩 HPACK(理解压缩原理)
第四部分:HTTP/3 与 QUIC
第五部分:WebSocket

8.1 HTTP 协议演进

生活比喻:HTTP 就像餐厅点餐。

HTTP/1.0:
每次点菜都要重新排队、找座位

HTTP/1.1:
一次排队,可以点多个菜(持久连接)

HTTP/2:
一次排队,多个服务员同时上菜(多路复用)

HTTP/3:
不用排队,直接扫码点餐(QUIC)
HTTP/1.0 → HTTP/1.1 → HTTP/2 → HTTP/3

HTTP/1.0 问题:
- 每个请求需要单独的 TCP 连接
- 10 个资源 = 10 次 TCP 握手

HTTP/1.1 改进:
- 持久连接(Keep-Alive)
- 管道化(Pipelining)── 但有队头阻塞问题
- 仍然:6~8 个并发连接限制

HTTP/2 核心改进:
- 多路复用:一个 TCP 连接上并行多个请求/响应
- 头部压缩:HPACK 算法压缩请求头
- 服务器推送:主动推送资源
- 二进制帧:不再是文本协议

HTTP/3 核心改进:
- 基于 QUIC(UDP)而非 TCP
- 解决了 TCP 层的队头阻塞
- 0-RTT 连接建立
- 连接迁移

8.2 HTTP/2 多路复用

HTTP/1.1:
连接1: 请求A ─────── 响应A ──────
连接2: 请求B ─────── 响应B ──────
连接3: 请求C ─────── 响应C ──────
→ 需要多个 TCP 连接,每个连接成本高

HTTP/2:
连接1: ── 请求A ── 请求B ── 请求C ──
       ── 响应A ── 响应B ── 响应C ──
→ 一个 TCP 连接,多个流并行

帧(Frame)结构

帧(Frame)结构:
┌──────────┬──────────┬──────────┬───────────┐
│ Length   │ Type     │ Flags    │ Stream ID │
│ (3bytes) │ (1byte)  │ (1byte)  │ (4bytes)  │
└──────────┴──────────┴──────────┴───────────┘

流(Stream)= 双向的字节流
消息(Message)= 一个完整的请求或响应
帧(Frame)= 最小通信单位

每个帧带 Stream ID,接收方按 Stream ID 重组
→ 不同流的帧可以交错发送
class HTTP2Frame {
  constructor(type, flags, streamId, payload) {
    this.length = payload ? payload.length : 0;
    this.type = type;
    this.flags = flags;
    this.streamId = streamId;
    this.payload = payload || Buffer.alloc(0);
  }

  static TYPES = {
    DATA: 0x00, HEADERS: 0x01, PRIORITY: 0x02,
    RST_STREAM: 0x03, SETTINGS: 0x04,
    PUSH_PROMISE: 0x05, PING: 0x06,
    GOAWAY: 0x07, WINDOW_UPDATE: 0x08,
    CONTINUATION: 0x09
  };

  static FLAGS = {
    END_STREAM: 0x01, END_HEADERS: 0x04,
    PADDED: 0x08, ACK: 0x01
  };
}

class HTTP2Connection {
  constructor() {
    this.streams = new Map();
    this.nextStreamId = 1;
    this.settings = {
      HEADER_TABLE_SIZE: 4096,
      ENABLE_PUSH: 1,
      MAX_CONCURRENT_STREAMS: 100,
      INITIAL_WINDOW_SIZE: 65535,
      MAX_FRAME_SIZE: 16384
    };
  }

  createStream() {
    let id = this.nextStreamId;
    this.nextStreamId += 2;
    let stream = { id, state: 'idle', data: [] };
    this.streams.set(id, stream);
    return stream;
  }
}

和游戏开发的关系

  • 游戏资源加载用 HTTP/2 可以大幅减少连接数
  • CDN 普遍支持 HTTP/2
  • HTTP/3 基于 QUIC,进一步减少延迟

8.3 头部压缩(HPACK)

HTTP/1.1 问题:
每次请求都带完整的头部
Cookie 可能达到数 KB
100 个请求 × 1KB 头部 = 100KB 纯头部开销

HPACK 算法:
1. 静态表:61 个常见头部字段预定义
   :method GET → 索引 2
   :path / → 索引 4
   
2. 动态表:双方维护,记录之前发过的头部
   第一次:cookie: abcdefg → 加入动态表(索引 62)
   第二次:只需发索引 62 → 1 字节!

3. 哈夫曼编码:字符串本身也压缩

压缩率:通常 80%~90%
class HPACKEncoder {
  constructor() {
    this.staticTable = [
      [':authority', ''],
      [':method', 'GET'],
      [':method', 'POST'],
      [':path', '/'],
      [':path', '/index.html'],
      [':scheme', 'http'],
      [':scheme', 'https'],
      [':status', '200'],
      ['accept', '*/*'],
      ['accept-encoding', 'gzip, deflate']
    ];
    this.dynamicTable = [];
    this.maxTableSize = 4096;
  }

  encode(headers) {
    let encoded = [];
    for (let [name, value] of Object.entries(headers)) {
      let index = this.findIndex(name, value);
      if (index !== -1) {
        encoded.push(this.encodeIndexed(index));
      } else {
        let nameIndex = this.findNameIndex(name);
        if (nameIndex !== -1) {
          encoded.push(this.encodeLiteralWithIndexing(nameIndex, value));
        } else {
          encoded.push(this.encodeLiteralWithNewName(name, value));
        }
      }
    }
    return Buffer.concat(encoded);
  }

  findIndex(name, value) {
    for (let i = 0; i < this.staticTable.length; i++) {
      if (this.staticTable[i][0] === name && this.staticTable[i][1] === value) {
        return i + 1;
      }
    }
    for (let i = 0; i < this.dynamicTable.length; i++) {
      if (this.dynamicTable[i][0] === name && this.dynamicTable[i][1] === value) {
        return this.staticTable.length + i + 1;
      }
    }
    return -1;
  }
}

8.4 HTTP/3 与 QUIC

HTTP/3 核心改进:
- 基于 QUIC(UDP)而非 TCP
- 解决了 TCP 层的队头阻塞
- 0-RTT 连接建立
- 连接迁移

为什么 HTTP/3 不用 TCP?
1. TCP 层的队头阻塞——多路复用解决了 HTTP 层的问题,但 TCP 丢包仍然阻塞所有流
2. TCP 的握手延迟——TCP 三次握手 + TLS 握手需要 2-3 个 RTT,QUIC 只需 1 个 RTT(重连 0-RTT)
3. TCP 不支持连接迁移——IP 变化导致连接断开,QUIC 用 Connection ID 不受影响
class QUICConnection {
  constructor() {
    this.connectionId = this.generateConnectionId();
    this.state = 'idle';
    this.streams = new Map();
    this.nextStreamId = 0;
    this.rtt = 0;
    this.congestionWindow = 10;
  }

  generateConnectionId() {
    // 生成随机 Connection ID
    return crypto.randomBytes(8);
  }

  createStream(bidirectional = true) {
    let id = this.nextStreamId;
    this.nextStreamId += bidirectional ? 4 : 2;
    let stream = { id, state: 'idle', data: [], sendOffset: 0, recvOffset: 0 };
    this.streams.set(id, stream);
    return stream;
  }
}

游戏开发应该用 HTTP/2 还是 HTTP/3? A:对于游戏资源加载,HTTP/3 更好:1)更快的连接建立(0-RTT 重连);2)丢包不影响其他流(WiFi/4G 环境尤其重要);3)连接迁移(WiFi→4G 不断连)。但 HTTP/3 的浏览器支持还在完善中。对于游戏实时通信,还是应该用 WebSocket 或自定义 UDP 协议。


8.5 WebSocket

WebSocket = 在 TCP 上的全双工通信协议
- 基于 HTTP 握手升级
- 连接建立后,双方可以随时发送消息
- 消息帧格式轻量

游戏场景:
- 实时聊天
- 游戏状态同步
- 服务器推送
class GameWebSocket {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.handlers = new Map();
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
  }

  connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.url);
      this.ws.onopen = () => {
        this.reconnectAttempts = 0;
        this.emit('connected');
        resolve();
      };
      this.ws.onmessage = (event) => {
        let message = JSON.parse(event.data);
        this.emit(message.type, message.data);
      };
      this.ws.onclose = () => {
        this.emit('disconnected');
        this.tryReconnect();
      };
      this.ws.onerror = (error) => {
        this.emit('error', error);
        reject(error);
      };
    });
  }

  tryReconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      setTimeout(() => this.connect(), 1000 * this.reconnectAttempts);
    }
  }

  emit(event, data) {
    let handler = this.handlers.get(event);
    if (handler) handler(data);
  }

  send(data) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

自问自答

Q:HTTP/2 的多路复用和 HTTP/1.1 的管道化有什么区别? A:HTTP/1.1 的管道化允许在同一个连接上连续发送多个请求,但响应必须按顺序返回(FIFO),如果第一个请求的响应慢,后面的响应都要等——这就是"HTTP 层队头阻塞"。HTTP/2 的多路复用通过 Stream ID 和帧机制,允许请求和响应交错传输,不同流的响应可以乱序返回,没有 HTTP 层的队头阻塞。

Q:HTTP/2 解决了队头阻塞吗? A:解决了 HTTP 层的队头阻塞,但没有解决 TCP 层的队头阻塞!HTTP/2 的多个流复用同一个 TCP 连接,如果 TCP 丢了一个包,所有流都要等重传——这就是 TCP 层的队头阻塞。HTTP/3 基于 QUIC(UDP),每个流独立,彻底解决了这个问题。

Q:服务器推送有什么用? A:服务器推送允许服务器在客户端请求之前主动发送资源。比如客户端请求 HTML,服务器知道还需要 CSS 和 JS,就主动推送。减少了一个 RTT 的等待时间。但对游戏来说用处不大——游戏资源通常预加载,不需要服务器推送。

Q:为什么 HTTP/3 不用 TCP? A:三个原因:1)TCP 层的队头阻塞——多路复用解决了 HTTP 层的问题,但 TCP 丢包仍然阻塞所有流;2)TCP 的握手延迟——TCP 三次握手 + TLS 握手需要 2-3 个 RTT,QUIC 只需 1 个 RTT(重连 0-RTT);3)TCP 不支持连接迁移——IP 变化导致连接断开,QUIC 用 Connection ID 不受影响。


实践任务

  • 任务1:在浏览器 DevTools Network 面板查看 HTTP/2 的流(Stream)信息,理解多路复用
  • 任务2:用 nghttp 工具查看 HTTP/2 帧详情,观察帧类型和 Stream ID
  • 任务3:配置 Nginx 开启 HTTP/2,用 Chrome DevTools 对比 HTTP/1.1 和 HTTP/2 的加载性能
  • 任务4:用 curl --http3 测试 HTTP/3,对比 HTTP/2 的连接建立时间
  • 任务5:在丢包环境下对比 HTTP/2 和 HTTP/3 的性能差异(用 tc netem 模拟)

与其他章节的关联

本章内容 关联章节 关联点
HTTP/2 多路复用 第06章 TCP/IP HTTP/2 的多路复用仍受 TCP 队头阻塞影响
HTTP/3 + QUIC 第07章 UDP与可靠传输 HTTP/3 基于 QUIC,解决了 TCP 的队头阻塞
TLS 第09章 网络安全 HTTP/2 和 HTTP/3 都强制 HTTPS
资源加载 第10章 实战篇 HTTP/2/3 优化游戏资源加载性能

上一章:07-UDP与可靠传输 | 下一章:09-网络安全基础