MAATRIX / Блог / Мультиплексирование в HTTP/2 и почему один медленный ответ тормозит остальные

Мультиплексирование в HTTP/2 и почему один медленный ответ тормозит остальные

MAATRIX

Если открыть вкладку разработчика в браузере и посмотреть на водопад запросов до перехода на HTTP/2, картина обычно неприятная: десятки CSS, JS и картинок грузятся волнами по шесть штук — ровно столько параллельных соединений браузер разрешает на один домен. HTTP/2 обещал это исправить, и формально исправил: все запросы к одному хосту теперь идут через одно TCP-соединение одновременно. Но у этого решения есть изнанка, о которой почти никто не предупреждает: если на уровне TCP теряется один сегмент, замирают вообще все запросы в этом соединении, даже те, чьи данные уже физически лежат на сервере или у клиента.

Проблема HTTP/1.1, которую должен был решить HTTP/2

В HTTP/1.1 одно TCP-соединение обслуживает запросы строго по очереди (pipelining на практике почти никто не включал из-за проблем совместимости с прокси). Чтобы браузер не ждал ответа на каждый ресурс по очереди, он открывает несколько параллельных соединений к одному домену — обычно 6, у некоторых браузеров было до 8. Это работает, но платит за это сервер и сеть:

  • каждое новое TCP-соединение — это отдельное рукопожатие, отдельный TLS handshake, отдельный медленный старт congestion control;
  • лимит в 6 соединений на домен упирается в потолок, когда на странице 80+ ресурсов — отсюда трюки вроде спрайтов, конкатенации JS и доменного шардинга (static1.example.com, static2.example.com), которые индустрия использовала годами именно чтобы обойти это ограничение;
  • каждое соединение независимо разгоняет своё TCP-окно, и в сумме это не быстрее, а часто медленнее, чем один хорошо разогнанный канал.

HTTP/2 зашёл с другой стороны: вместо того чтобы открывать больше соединений, он научился отправлять много логических запросов через одно.

Как работает мультиплексирование: потоки и кадры

Ключевая идея HTTP/2 — разделить понятия «соединение» и «запрос». Одно TCP-соединение теперь несёт множество независимых потоков (streams), у каждого свой числовой идентификатор. Каждый поток разбивается на кадры (frames) — HEADERS, DATA, и так далее, — и эти кадры от разных потоков перемежаются внутри одного и того же байтового потока TCP.

Упрощённо это выглядит так: браузер запрашивает style.css, app.js и logo.png почти одновременно. Сервер не обязан отвечать по порядку — он может начать отправлять кадры logo.png, вставить туда пару кадров app.js, продолжить style.css, и так далее, пока все три ответа не будут собраны. На приёмной стороне TCP-стек передаёт эти байты в HTTP/2-слой в правильном порядке (TCP гарантирует порядок доставки), а тот уже — по идентификатору потока в кадре — раскладывает данные обратно по нужным логическим запросам.

Практический эффект: браузеру больше не нужно 6 соединений на домен, достаточно одного (иногда двух — например, из-за coalescing разных поддоменов на общем сертификате). Ушли издержки на лишние TLS-рукопожатия и на параллельный разгон нескольких congestion-control окон. Проверить, что сервер действительно отвечает по HTTP/2, можно так:

curl -I --http2 -v https://example.com/ 2>&1 | grep -i "using http" 

или посмотреть в devtools браузера столбец Protocol в вкладке Network — там будет h2.

Приоритизация потоков (какому потоку отдавать пропускную способность первым) в оригинальной спецификации HTTP/2 была устроена через дерево зависимостей, но на практике большинство серверов и клиентов её либо игнорировали, либо реализовывали по-разному — это отдельная больная тема, не связанная напрямую с тем, о чём эта статья.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Оставшаяся проблема: TCP не знает, что такое «поток»

Вот здесь начинается самое интересное — и самое неочевидное для тех, кто читал только маркетинговые слайды про HTTP/2.

TCP — протокол с гарантией строгого порядка доставки байт. Это его фундаментальное свойство: приложение на приёмной стороне получает байты именно в том порядке, в котором они были отправлены, ни байтом раньше. Если сегмент с байтами 1000–1500 потерялся, а сегменты 1500–3000 уже дошли, TCP-стек не отдаст байты 1500–3000 приложению, пока не дождётся повторной передачи потерянного сегмента 1000–1500 и не восстановит порядок. Это называется TCP receive buffer reordering — все данные, идущие после дыры, складываются в буфер и ждут.

Теперь наложите это на HTTP/2. Все потоки живут в одном TCP-соединении, то есть в одном непрерывном байтовом потоке. Если сегмент, в котором были байты потока №5 (условно, картинка), потерялся где-то на маршруте, то TCP-стек получателя придержит вообще все байты, которые физически пришли после этой точки в общем потоке — включая кадры потоков №7, №9 и №11, чьи данные к этому моменту могли полностью и без единой потери долететь до сетевой карты сервера или клиента. HTTP/2-слой просто не увидит эти кадры, пока TCP не восстановит порядок целиком.

Получается парадокс: мультиплексирование избавило нас от head-of-line blocking на уровне очереди HTTP-запросов (как в HTTP/1.1 без pipelining), но head-of-line blocking никуда не делся — он просто переехал на уровень ниже, в TCP. И там он даже неприятнее: теперь одна потеря блокирует не один запрос, а все запросы, мультиплексированные в этом соединении, независимо от того, к какому логическому потоку относились потерянные данные.

Важный нюанс: это проблема именно TCP, а не HTTP/2 как такового. HTTP/2 не сломал ничего нового — он просто увеличил площадь поражения от одной и той же старой особенности TCP, потому что раньше на одно потерянное соединение из шести приходился один заблокированный поток запросов, а теперь на одно соединение — все.

Как это выглядит на практике

Симптом обычно один: страница почти полностью загрузилась, но подвисает буквально на пару элементов, и весь остальной контент — включая то, что визуально не связано с зависшими элементами — тоже как будто замирает на короткое время, хотя сами ресурсы небольшие. Особенно заметно на нестабильных сетях: мобильный интернет с переключением между вышками, спутниковые каналы, каналы с высоким BDP (bandwidth-delay product), где даже небольшой процент потерь превращается в заметные паузы (подробнее о том, почему единичная потеря обходится так дорого — в статье про ретрансмиты и потерю пакета).

На сервере эту блокировку напрямую не видно в логах приложения — она происходит ниже уровня приложения, в TCP-стеке ядра. Косвенные признаки можно поймать так:

# Число ретрансмитов по соединению
ss -ti dst <ip-клиента>

# Общая статистика ретрансмитов на интерфейсе
nstat -az | grep -i retrans

# Живой захват трафика конкретного HTTP/2-соединения
tcpdump -i eth0 host <ip-клиента> and port 443 -w h2-session.pcap

В ss -ti строка вида retrans:3/27 означает 3 ретрансмита из 27 переданных сегментов в текущем окне — если это соединение с активным HTTP/2-мультиплексированием, эти ретрансмиты придерживают все потоки внутри него, а не только «свой».

Отдельно стоит сказать про механизм congestion control — после потери TCP не просто ждёт повторной доставки, он ещё и снижает окно (по-разному, в зависимости от алгоритма — подробнее в статье про BBR против CUBIC), что дополнительно замедляет уже восстановленный поток данных, а значит и все HTTP/2-потоки в нём.

Что с этим можно сделать сегодня

Полностью убрать TCP-уровневый HOL blocking, оставаясь на TCP, нельзя — это встроенное свойство протокола, а не баг конкретной реализации. Но снизить его влияние можно несколькими способами:

1. Уменьшить вероятность потерь на самом маршруте. Это банально, но именно это чаще всего реально помогает: адекватный MTU без фрагментации, отсутствие переполненных буферов на промежуточных узлах (bufferbloat), разумные настройки congestion control под характер канала. Если сервер физически далеко от аудитории и путь идёт через перегруженные транзитные сети, потери будут регулярными вне зависимости от настроек HTTP.

2. Не смешивать в одном соединении разнородные по важности ресурсы. Например, если критичный для рендеринга CSS и тяжёлое фоновое видео идут в одном HTTP/2-соединении, потеря сегмента с видео придержит и CSS. Некоторые проекты сознательно разносят такие ресурсы на разные хосты (и, соответственно, разные TCP-соединения) — это отчасти возвращает старую проблему HTTP/1.1, поэтому решение точечное, не универсальное.

3. Перейти на HTTP/3 там, где это оправдано. HTTP/3 работает поверх QUIC, а QUIC использует UDP и реализует мультиплексирование потоков так, что потеря пакета, относящегося к одному потоку, не блокирует доставку данных других потоков получателю — каждый поток восстанавливается независимо. Это не значит, что HTTP/3 «быстрее» в общем случае — выигрыш заметен именно в сценариях с потерями, а на стабильном канале с низким RTT разница может быть незаметна. Настройка на своём VPS обычно сводится к включению QUIC в веб-сервере:

server {
    listen 443 quic reuseport;
    listen 443 ssl;
    http2 on;
    http3 on;

    add_header Alt-Svc 'h3=":443"; ma=86400';
    ssl_certificate     /etc/ssl/example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/example.com/privkey.pem;
}

Это требует свежей версии nginx, собранной с поддержкой QUIC (штатная сборка в дистрибутивах не всегда её включает), и открытого UDP/443 в файрволе — про частые ошибки этого перехода стоит почитать отдельно, здесь только суть механизма.

4. Настроить TCP под свой трафик, если остаётесь на HTTP/2. Управление буферами и таймаутами ретрансмиссии не устраняет проблему, но может сократить время, которое соединение проводит в заблокированном состоянии:

# Более быстрое обнаружение потери через SACK (обычно уже включено)
sysctl net.ipv4.tcp_sack

# Начальный congestion window — влияет на то, сколько данных
# можно отправить до первого подтверждения
sysctl net.ipv4.tcp_slow_start_after_idle

Тут нет универсально «правильных» значений — они зависят от характера канала и трафика, и любые изменения стоит проверять на реальной нагрузке, а не копировать вслепую из чужого гайда.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

HTTP/2 вообще не нужен, раз проблема осталась?

Нужен. Он убрал реальные издержки HTTP/1.1 — лишние TLS-рукопожатия, доменный шардинг, ограничение в 6 соединений. TCP-уровневый HOL blocking проявляется только при потерях пакетов; на стабильном канале с низкими потерями мультиплексирование почти всегда чистый выигрыш.

Можно ли увидеть эту блокировку в логах nginx или Apache?

Напрямую нет — она происходит в TCP-стеке ядра, ниже уровня, который видит веб-сервер. Ориентироваться нужно на счётчики ретрансмитов (ss -ti, nstat) и на захват трафика через tcpdump, а не на access-логи.

Если у меня один пользователь на статичном IP без мобильной сети, стоит ли вообще думать об этой проблеме?

Скорее нет — TCP-уровневый HOL blocking заметен там, где регулярны потери пакетов: мобильные сети, дальние маршруты с высоким BDP, перегруженные транзитные каналы. На стабильном проводном соединении с низким процентом потерь эффект обычно не критичен.

HTTP/3 полностью решает проблему?

QUIC решает именно эту конкретную проблему — независимость потоков друг от друга при потере пакета. Но у HTTP/3 свои особенности: часть корпоративных файрволов блокирует или режет UDP, а разгон QUIC-соединения на некоторых сетях ведёт себя иначе, чем TCP. Это не серебряная пуля, а другой набор компромиссов.

Достаточно ли включить http2 on в nginx, чтобы получить мультиплексирование?

Да, само мультиплексирование потоков внутри HTTP/2-соединения работает «из коробки» после включения HTTP/2 и не требует отдельной настройки — а вот приоритизация потоков и переход на HTTP/3 (если он нужен) требуют дополнительных шагов, разобранных выше.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →