HTTP/2 — бинарные фреймы, мультиплексирование и HPACK
В 2026 году более половины интернет-запросов идёт по HTTP/2. Когда вы открываете GitHub, YouTube, Twitter — между вами и сервером работает именно HTTP/2 (или уже HTTP/3 — о нём в следующем уроке). И это происходит абсолютно прозрачно: вы не меняете код, не пишете ничего нового. Семантика осталась прежней: те же методы, статус-коды, заголовки. Изменилось только то, как байты передаются по проводам.
Зачем вообще понадобился HTTP/2? И почему в HTTP/2 формат сообщений теперь бинарный, а не текстовый? Давайте разбираться.
Проблема HTTP/1.1: latency
К середине 2010-х средняя веб-страница выросла с пары страничек HTML до нескольких мегабайт ресурсов: 50-100 запросов на одну загрузку (CSS, JS, картинки, шрифты, API). На HTTP/1.1 это превратилось в боль.
Также каждый запрос HTTP/1.1 нёс килобайты заголовков (cookies + user-agent + custom headers) — и эти заголовки повторялись на каждом запросе без сжатия. Для API, где запросов сотни, это бессмысленный overhead.
HTTP/2 решает эти проблемы радикально, через четыре ключевых механизма:
- Бинарный формат фреймов — эффективный парсинг, чёткие границы.
- Мультиплексирование streams — много запросов одновременно в одном TCP.
- HPACK header compression — сжатие заголовков.
- Server push — сервер может отправить ресурс до запроса (но в практике почти не используется).
Бинарный формат: фреймы вместо текста
В HTTP/1.1 сообщение — это текст: разделители из CRLF, заголовки в формате Имя: Значение. Парсер сервера читает символ за символом, ищет \r\n, парсит до пустой строки. Это просто и понятно человеку, но неэффективно для машины: переменная длина, нечёткие границы, сложно вычислить размер.
В HTTP/2 всё передаётся как набор фреймов. Фрейм — это блок бинарных данных с заголовком фиксированной длины.
Самые важные типы фреймов:
- HEADERS — заголовки запроса/ответа (включая статус-код, метод и т.д. как pseudo-headers
:method,:path,:status). - DATA — тело сообщения. Может быть несколько DATA-фреймов на один stream.
- SETTINGS — параметры соединения (max concurrent streams, initial window size).
- WINDOW_UPDATE — flow control на уровне stream и соединения.
- RST_STREAM — немедленно прерывает stream (как cancel).
- GOAWAY — сервер сообщает клиенту, что закрывает соединение и не примет новые streams.
- PING — проверка живости соединения.
Преимущества бинарного формата:
- Парсинг быстрее. Сервер читает фиксированные 9 байт заголовка фрейма, узнаёт длину payload, читает ровно столько байт.
- Чёткие границы. Не нужно сканировать на CRLF — длина известна сразу.
- Удобно для битмаскинга и параллельной обработки.
Минус: вы больше не можете прочитать сырой HTTP/2-трафик через telnet. Но curl, Wireshark и DevTools браузера декодируют его обратно в человекочитаемое представление.
Stream и мультиплексирование
Ключевая идея HTTP/2: на одном TCP-соединении одновременно живёт много streams. Stream — это логический «запрос/ответ» (или для server-push — сервер -> клиент). Каждый stream имеет свой ID и независим.
На проводе фреймы разных streams перемежаются. Например, последовательность может быть:
HEADERS(stream=1) HEADERS(stream=3) DATA(stream=1) DATA(stream=3) DATA(stream=1, END_STREAM)
То есть сервер отвечает на запрос 1 и 3 параллельно, перемежая chunks данных. Каждый stream независим, и приоритеты можно регулировать (стимы можно перевешивать — но в HTTP/2 этот механизм был сложным и поставщики часто его игнорировали, в HTTP/3 он упрощён).
Pseudo-headers
В HTTP/2 первая строка запроса (request-line) разбита на отдельные «pseudo-headers», начинающиеся с двоеточия:
:method: GET
:path: /api/users
:scheme: https
:authority: api.example.com
accept: application/json
user-agent: curl/8.4.0
В ответе:
:status: 200
content-type: application/json
Заметьте: имена в lowercase. Это обязательно в HTTP/2. И :authority заменяет Host из HTTP/1.1.
HPACK: header compression
В HTTP/1.1 каждый запрос несёт полный набор заголовков, включая повторяющиеся (cookies, user-agent). На странице с 100 ресурсами это значит 100x повторений тех же байт. HPACK решает это умным сжатием.
HPACK использует две техники:
Пример: первый запрос содержит cookie длиной 200 байт. Он передаётся как Huffman-сжатая строка (~150 байт) и добавляется в dynamic table с индексом 62. На втором запросе тот же cookie передаётся как один байт с индексом «62» — сжатие в 200 раз.
Реальный эффект: типичный API-запрос HTTP/2 шлёт заголовки в 5-10 байт, тогда как HTTP/1.1 та же информация занимает 500-2000 байт. На страницах с десятками запросов это экономит сотни КБ traffic’а.
HPACK имел известную уязвимость — HPACK Bomb / CRIME-style атаки. Если атакующий может вставлять кусочек строки в ваш header и видеть размер сжатого ответа — он может угадать остальные значения. Workaround: серверы ограничивают размер dynamic table и могут не сжимать чувствительные headers. В HTTP/3 алгоритм называется QPACK — модификация, более устойчивая к out-of-order доставке.
Flow control в HTTP/2
В HTTP/1.1 flow control делает только TCP. В HTTP/2 добавляется flow control на уровне stream и соединения — через WINDOW_UPDATE фреймы.
Зачем второй уровень? Представьте: один stream шлёт огромный файл, второй — маленький JSON. Без stream-level flow control один тяжёлый stream забил бы буфер сервера и заблокировал других. С stream-level каждый stream имеет свой window, и сервер регулирует pace отдельно.
Клиент: WINDOW_UPDATE(stream=1, increment=65536) -- 'я готов принять ещё 64КБ для stream 1'
Сервер: DATA(stream=1, 65536 bytes) -- шлёт ровно столько
Клиент: WINDOW_UPDATE(stream=1, increment=65536) -- готов ещё
Это даёт точное управление: можно паузить, ускорять, отменять отдельные streams не влияя на других.
Server push (умер, но был интересной идеей)
HTTP/2 включал механизм, позволяющий серверу отправить ресурс ДО того, как клиент его запросил. Идея: клиент шлёт GET /index.html. Сервер видит, что html ссылается на /style.css и /app.js. Зачем ждать, пока клиент попросит CSS и JS — сервер сразу пушит их.
Stream 1: GET /index.html (клиент -> сервер)
Stream 1: PUSH_PROMISE for /style.css (сервер -> клиент: 'я пушу этот ресурс')
Stream 1: HEADERS + DATA (HTML)
Stream 2: HEADERS + DATA (CSS, pushed)
На бумаге звучит круто. На практике:
- Сложно решать, что пушить. Сервер не знает, что у клиента уже в кэше.
- Push часто бесполезен — клиент уже имел в кэше. Тратит bandwidth впустую.
- Реализации браузеров глючные.
В 2022 году Chrome отказался от поддержки server push для HTTP/2. Большинство серверов её не используют. В HTTP/3 server push формально есть, но не считается best practice. Вместо неё рекомендуется 103 Early Hints — сервер шлёт «вот ссылки на ресурсы, которые понадобятся» до тела основного ответа, и клиент сам решает запросить их.
Один большой недостаток HTTP/2: HoL blocking на уровне TCP
Мультиплексирование решило HoL blocking на уровне HTTP. Но TCP сам по себе сериализует байты. Если один пакет в TCP потерян, TCP не отдаёт следующие пакеты приложению, пока не получит потерянный (потому что TCP гарантирует in-order delivery).
На стабильной wired-сети это редко проблема. Но на мобильных, на WiFi с потерями, особенно при больших задержках — HoL blocking ощутимо снижает performance HTTP/2 по сравнению с теоретически возможным. Именно эту проблему решает HTTP/3, переходя на QUIC поверх UDP — следующий урок.
ALPN: как клиент и сервер договариваются о версии
Браузер не знает заранее, поддерживает ли сервер HTTP/2. Узнаёт через ALPN (Application-Layer Protocol Negotiation) — расширение TLS, в котором клиент в TLS handshake перечисляет поддерживаемые протоколы:
ClientHello: ALPN [h2, http/1.1]
ServerHello: ALPN h2
Сервер выбирает один из предложенных. Если сервер поддерживает h2 — использует HTTP/2 поверх TLS. Если нет — падают обратно на http/1.1. Это причина, по которой HTTP/2 в реальности всегда идёт через HTTPS — chrome и firefox не поддерживают HTTP/2 без TLS.
Без TLS HTTP/2 теоретически возможен (h2c — HTTP/2 cleartext), на практике почти не используется.
Попробуй сам
# 1. Посмотреть, использует ли сервер HTTP/2
curl -I --http2 -w 'HTTP version: %{http_version}\n' https://github.com 2>&1 | grep -i 'http\|http_version'
# 2. Заставить curl использовать только HTTP/1.1
curl -I --http1.1 -w 'HTTP version: %{http_version}\n' https://github.com 2>&1
# 3. Посмотреть на ALPN-negotiation в TLS handshake
curl -v --http2 https://github.com 2>&1 | grep -i alpn
# 4. Сравнить размер заголовков HTTP/1.1 vs HTTP/2 (визуально)
# В Chrome DevTools (F12) -> Network tab -> любой запрос:
# В Headers сверху увидите Protocol (h2, h3, http/1.1)
# Размер запроса/ответа в Size column
# 5. Запустить простой HTTP/2 сервер на Python (через aiohttp поддержки HTTP/2 нет,
# нужно httpx-aiohttp или hyper-h2 -- эксперимент для интересующихся)
# Простой вариант: использовать nghttpd из nghttp2-tools
# Установка: brew install nghttp2 / apt install nghttp2
# 6. nghttp -- HTTP/2 клиент с детальным выводом
# nghttp -nv https://github.com # покажет фреймы
# Для каждого фрейма видим: тип (HEADERS, DATA), stream_id, flags
# 7. Wireshark с фильтром http2 -- видно все фреймы (если расшифровать TLS через SSLKEYLOGFILE)
# (SSLKEYLOGFILE export в .env для chrome/firefox, потом Wireshark подцепит ключи)
Если у вас Chrome и любой современный сайт — DevTools -> Network показывает Protocol колонку. Большинство современных сайтов отдают h2 или h3.
Что вы должны вынести
- HTTP/2 = HTTP/1.1 по семантике (методы, статус-коды, заголовки одни и те же).
- Изменился формат на проводе: бинарные фреймы вместо текста.
- Мультиплексирование — много streams в одном TCP. Решает HoL blocking уровня HTTP.
- HPACK сжимает заголовки — особенно эффективно на повторяющихся (cookies, user-agent).
- Server push в HTTP/2 — неудачная фича, де-факто заменена 103 Early Hints.
- TCP HoL blocking остаётся проблемой — его решает HTTP/3 через QUIC.
- Версия согласуется через ALPN в TLS handshake. HTTP/2 в реальности всегда через HTTPS.
HTTP/2 multiplexing с точки зрения API: connection pooling и gRPC Kafka producer и HTTP/2: параллелизм на уровне протокола