23. HTTP 3 и QUIC. Почему будущее уходит от TCP

Почему TCP стал узким местом

Исторически веб-стек строился на связке TCP + TLS + HTTP/1.1/2. На протяжении более чем четырех десятилетий протокол TCP (Transmission Control Protocol) служил незыблемым фундаментом цифровой цивилизации. Он обеспечивает гарантированную доставку и упорядоченность, но эта гарантия имеет высокую цену: Head-of-Line (HoL) blocking на транспортном уровне. Если в потоке данных теряется один пакет, ядро ОС блокирует чтение всех последующих до его повторной передачи.

В чем суть проблемы?
Представьте себе службу доставки, которая везет вам собрание сочинений из десяти томов. Если курьер уронил в лужу третий том и поехал за заменой, правила TCP категорически запрещают вам прикасаться к томам с четвертого по десятый, даже если они уже лежат у вас на столе и вам срочно нужен десятый том. Ядро операционной системы держит полученные байты в буфере приема (receive buffer) и не отдает их приложению, пока дыра в последовательности порядковых номеров (Sequence Numbers) не будет залатана.

HTTP/2 попытался решить проблему на прикладном уровне через мультиплексирование, но физически пакеты всё равно летят по одному TCP-соединению. Потеря пакета в stream 0 блокирует stream 1, 2, 3 и так далее. Для бэкенд-разработчика это означает, что при высокой задержке сети (например, мобильные клиенты или геораспределенные сервисы) производительность упирается не в CPU, а в алгоритмы контроля перегрузок ядра ОС и стоимость переключения контекста при ретрансмиссиях.

Кроме того, интернет столкнулся с явлением, которое сетевые архитекторы называют «окостенением сети» (Protocol Ossification). За десятилетия миллионы промежуточных сетевых узлов (роутеры, NAT, корпоративные файрволы, middleboxes) научились «понимать» заголовок TCP. Если попытаться изменить флаги или формат TCP-сегмента на уровне ядра, консервативные роутеры посчитают пакет поврежденным и молча сбросят его. Внедрить новый транспортный протокол в ядро операционных систем миллиардов устройств и маршрутизаторов по всему миру оказалось практически невозможно.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph StackH2 ["Классический стек HTTP/2"]
        H2App["HTTP/2 (Multiplexing)"]
        TLS12["TLS 1.2 / 1.3 (Crypto Handshake)"]
        TCPCore["TCP (Ядро ОС: упорядочивание + HoL Blocking)"]
        IP1["IP"]:::process
        H2App --> TLS12 --> TCPCore --> IP1
    end

    subgraph StackH3 ["Современный стек HTTP/3"]
        H3App["HTTP/3 (Streams)"]
        QUICLayer["QUIC (User-Space: Multiplexing + Loss Recovery + TLS 1.3)"]
        UDPCore["UDP (Ядро ОС: минимальный транспорт датаграмм)"]
        IP2["IP"]:::process
        H3App --> QUICLayer --> UDPCore --> IP2
    end
  

QUIC: Архитектура поверх UDP

Единственным способом обойти окостенение интернета было спрятать всю сложную транспортную логику внутрь простого и везде проходимого протокола — UDP (подробно разобранного в статье 14. UDP. Когда ненадежный транспорт лучше TCP).

QUIC (Quick UDP Internet Connections) — это протокол уровня 4, разработанный Google и стандартизированный как IETF RFC 9000. Его главная философская идея: перенести мультиплексирование, контроль перегрузок и шифрование в user-space, убрав ядро ОС из критического пути.

QUIC работает поверх UDP (см. 14. UDP. Когда ненадежный транспорт лучше TCP). Это позволяет:

  1. Обходить HoL-блокировку на транспортном уровне: каждый HTTP-запрос — независимый stream внутри QUIC-соединения.
  2. Интегрировать TLS 1.3 напрямую в протокол (вместо отдельного handshake поверх TCP).
  3. Реализовать контроль перегрузок и loss recovery в приложении, обходя ограничения ядра.
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    App["Приложение Go"]:::network -->|"HTTP/3 Frames (QPACK)"| QUIC["QUIC Layer"]
    QUIC -->|"Multiplexed Streams / TLS 1.3"| UDP["UDP Datagram"]
    UDP -->|"sendto / recvfrom"| Net["Сеть / NAT / Роутер"]
    Net -->|"UDP Packet"| ServerUDP["UDP Socket (Сервер)"]
    ServerUDP -->|"netpoller (epoll/kqueue)"| ServerQUIC["QUIC Stack (User-Space)"]
    ServerQUIC -->|"Stream ID routing"| ServerApp["Приложение Go"]
  

Под капотом: Как Go реализует QUIC

В стандартной библиотеке Go (net/http) нативная поддержка HTTP/3 «из коробки» отсутствует (в crypto/tls предоставлены лишь низкоуровневые хуки для QUIC). Стандартом де-факто в экосистеме Go является библиотека github.com/quic-go/quic-go и пакет github.com/quic-go/quic-go/http3.

1. User-Space vs Kernel-Space

В отличие от TCP, где ядро управляет буферами, окнами и таймерами, quic-go реализует весь стек в user-space:

  • Loss Recovery: Алгоритм отслеживает последовательные номера пакетов. При потере определяется, какой пакет не дошел, и инициируется ретрансмиссия без блокировки остальных потоков.
  • Congestion Control: Алгоритмы контроля перегрузки (по умолчанию в quic-go — NewReno-совместимый режим с Cubic-вариантом; BBR в quic-go нет) реализованы прямо в Go. Нет вызовов setsockopt для ядра. Алгоритм работает на уровне Go-планировщика, что дает предсказуемость в Kubernetes-нодах.
  • Connection Migration: QUIC использует Connection ID (до 20 байт в QUIC v1; на практике часто 64 бита). При смене IP (Wi-Fi ↔ 5G) соединение не рвется, так как ядро не привязывает сокет к конкретному IP, а quic-go маппит новые UDP-пакеты на существующий Connection ID.
sequenceDiagram
    autonumber
    participant Client as Клиент (Смартфон)
    participant NAT as Wi-Fi / Сотовая вышка
    participant Server as Go Сервер (QUIC Connection ID: 0x8F4A)

    Note over Client,Server: Подключение через домашний Wi-Fi (IP: 192.168.1.50)
    Client->>Server: UDP: IP_A:Port_A | CID: 0x8F4A | Stream 1: GET /video
    Server-->>Client: UDP: CID: 0x8F4A | Данные видео...

    Note over Client,Server: Пользователь вышел на улицу (Переключение на 5G!)
    Note over Client: IP изменился на 10.45.2.19!
    Client->>Server: UDP: IP_B:Port_B | CID: 0x8F4A | Stream 1: GET /video_next
    Note over Server: Ядро принимает UDP, а quic-go находит соединение по CID 0x8F4A!
    Server-->>Client: UDP: IP_B:Port_B | CID: 0x8F4A | Видео продолжается без обрыва!
  

2. Memory & Scheduler Sympathy

quic-go интенсивно работает с памятью. Каждый UDP-пакет парсится, распаковывается TLS, маршрутизируется по StreamID. Без оптимизаций это породит тысячи аллокаций на запрос.

  • Используется sync.Pool для буферов []byte и заголовков пакетов. Escape-анализ показывает, что при высокой нагрузке (10k+ RPS) буферы уходят в кучу, но пул снижает давление на GC на 60-70%.
  • Планировщик Go (runtime.scheduler) маппит goroutines, обрабатывающие streams, на OS threads (M-P-G). netpoller (epoll на Linux, kqueue на macOS/BSD) асинхронно опрашивает UDP-сокеты. Нет блокирующих syscalls read/write, только recvfrom/sendto с флагом MSG_DONTWAIT или epoll_wait.

Ключевые механизмы и оптимизации

1. Быстрое установление соединения (0-RTT / 1-RTT)

В классическом стеке TCP + TLS 1.2 клиенту требовалось от 2 до 3 RTT (Round-Trip Time), прежде чем отправить первый байт полезных данных HTTP:

  1. SYN ↔ SYN-ACK (1 RTT TCP).
  2. ClientHello ↔ ServerHello (1 RTT TLS).
  3. Обмен ключами и сертификатами (еще 1 RTT TLS 1.2).

TLS 1.3 уже сократил handshake до 1-RTT. QUIC объединил рукопожатие транспортного уровня и TLS 1.3 в один шаг: за 1-RTT устанавливается и криптографическая сессия, и транспортный канал.

Более того, QUIC добавляет возможность 0-RTT (early data). Клиент сохраняет session tickets и ключи. При повторном подключении он может отправить данные вместе с первым пакетом ClientHello. Сервер может принять и обработать их ещё до завершения handshake — именно поэтому защита от replay остаётся на прикладном уровне (см. ниже).

sequenceDiagram
    autonumber
    participant Client as Клиент
    participant Server as QUIC Сервер

    Note over Client,Server: Первое подключение: 1-RTT Handshake
    Client->>Server: Initial: QUIC Handshake + TLS 1.3 ClientHello
    Server-->>Client: Handshake: TLS 1.3 ServerHello + Сертификаты + Finish
    Client->>Server: 1-RTT Data: GET /index.html (Криптоканал готов!)
    Server-->>Client: 1-RTT Data: 200 OK

    Note over Client,Server: Повторное подключение: 0-RTT Handshake (Early Data)
    Client->>Server: Initial + Early Data: TLS 1.3 Ticket + GET /fast-data! (0-RTT!)
    Server-->>Client: 1-RTT Data: 200 OK
  

2. Мультиплексирование на транспортном уровне

Каждый stream имеет независимый StreamID. Потеря пакета в stream 0 не влияет на stream 1. Это решает проблему HoL-блокировки, характерную для 10. TCP. Устройство, гарантии и жизненный цикл соединения. Если в потоке передачи аудиофайла потерян UDP-пакет, ядро и рантайм продолжают бесперебойно передавать байты критического потока управления.

3. Устойчивость к NAT и мобильности

QUIC использует Connection ID вместо связки IP:Port. Это позволяет клиентам менять сеть без разрыва соединения. Для миграции соединения используются PATH_CHALLENGE/PATH_RESPONSE. Механизма UDP hole punching (ICE/STUN) в quic-go нет; при необходимости обхода NAT его реализуют отдельно.


Практика в Go: Поддержка HTTP/3

Для включения HTTP/3 в Go-сервере или клиенте используется пакет github.com/quic-go/quic-go/http3 (пакет golang.org/x/net/http3 существует, но сейчас это заготовка без публичного API, непригодная для production).

Go
package main

import (
    "crypto/tls"
    "log"
    "net/http"

    "github.com/quic-go/quic-go/http3"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/v1/data", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"status": "ok"}`))
    })

    // Сервер требует TLS 1.3 и UDP-порт (обычно 443)
    server := &http3.Server{
        Addr:      ":443",
        Handler:   mux,
        TLSConfig: &tls.Config{
            NextProtos: []string{"h3"}, // ALPN negotiation
        },
    }

    log.Println("Starting HTTP/3 server on :443")
    // Запуск требует UDP Listener. http3.Server сам создаст его при ListenAndServe.
    if err := server.ListenAndServeTLS("cert.pem", "key.pem"); err != nil {
        log.Fatal(err)
    }
}

Ловушки и вопросы на собеседованиях

  1. Когда HTTP/3 НЕ нужен?
    В локальных сетях (LAN) с гигабитными свитчами и низким RTT (<1ms) оверхед на UDP-парсинг и user-space congestion control может сделать HTTP/3 медленнее TCP. TCP-стек ядра Linux (tcp_fastopen, tcp_bbr) уже оптимизирован для LAN.

  2. Профилирование UDP vs TCP
    Стандартные pprof профили показывают goroutines и память, но не видят потерь пакетов. Для отладки QUIC используйте quic-go internal metrics или net/http/httptrace с кастомным DialContext. В production мониторьте packets_sent, packets_lost, rtt через Prometheus (http3_server_round_trip_duration_seconds).

  3. Сравнение с PHP/Java/C#
    В традиционных стеках HTTP/3 часто требует сторонних бинарников (Caddy, Nginx + module) или тяжелых JNI-оберток. Go выигрывает за счет:

    • Нативного netpoller для UDP.
    • Zero-GC-подходов в quic-go (пулы буферов).
    • Возможности встраивать HTTP/3 прямо в binary без внешних прокси.
  4. Gotcha: MTU и Path MTU Discovery
    QUIC чувствителен к MTU. Если пакет превышает PMTU (Path Maximum Transmission Unit), он фрагментируется или отбрасывается. В Go это требует включения IP_PMTUDISC_DO на сокет. Без этого возможны silent drops и деградация производительности до уровня TCP.


Итог

HTTP/3 переносит мультиплексирование и контроль перегрузок из ядра ОС в user-space, решая фундаментальную проблему HoL-блокировки TCP. Для Go-разработчика это означает:

  • Использование github.com/quic-go/quic-go/http3 (в стандартной библиотеке и golang.org/x/net/http3 готового HTTP/3 пока нет).
  • Понимание работы netpoller с UDP и sync.Pool для пакетов.
  • Учет рисков 0-RTT replay-атак и необходимости PMTU discovery.
  • Преимущество перед традиционными бэкенд-стеками в контейнеризованных средах.

В следующей статье мы опустимся еще глубже в протокол и разберем внутреннее устройство пакетов QUIC, алгоритмы loss recovery и маппинг stream'ов: 24. Deep Dive в QUIC. Пакеты, Stream, Loss Recovery.