Где HTTP/2 и HTTP/3 реально быстрее, а где не дают ничего

Про HTTP/2 и HTTP/3 написано столько, что вопрос кажется закрытым. Включаешь — и всё летает. Так, во всяком случае, рассказывают. Мультиплексирование убирает очередь из запросов, QUIC держится, когда пакеты теряются, рукопожатие укладывается в один круг вместо двух. Звучит складно. И, наверное, где-то так и есть.
Меня эта складность не устраивала. Все графики, на которые я натыкался, были нарисованы на чужих стендах. И почти нигде не говорилось простое: где именно выигрыш исчезает, а он исчезает. Причём в самых обыденных ситуациях — отдаёте один большой файл или сервер с клиентом стоят в одном дата-центре, и всё, новой магии нет.
Поэтому я собрал стенд и померил сам. Caddy раздаёт одни и те же данные по всем трём протоколам сразу, tc netem изображает шесть сетей — от дата-центра до края соты. Дальше — что из этого вышло. Включая те случаи, где новые протоколы проигрывают старому. И три ловушки в методике, каждая из которых едва не увела меня в публикацию красивой неправды.
Поэтому я собрал стенд и померил сам. Caddy раздаёт одни и те же данные по всем трём протоколам сразу, tc netem изображает шесть сетей — от дата-центра до края соты. Дальше — что из этого вышло. Включая те случаи, где новые протоколы проигрывают старому. И три ловушки в методике, каждая из которых едва не увела меня в публикацию красивой неправды.
❯ Что именно мы проверяем
HTTP/1.1 — это один запрос за раз в одном соединении. RFC 9113 прямо называет это причиной, по которой клиенты открывают к серверу сразу несколько соединений. Сколько именно — спецификация не фиксирует, браузеры сошлись на шести на хост. Не больше, не меньше.
HTTP/2 делает иначе: много запросов едут одновременно в одном TCP-соединении. Очередь на уровне HTTP исчезает. Но остаётся уровнем ниже — и это важно. TCP обязан отдать приложению байты строго по порядку. Поэтому, как только теряется сегмент, стек придерживает в приёмном буфере всё, что пришло после, и ждёт, пока потерянное не восстановят. Это и называют head-of-line blocking: встают все потоки соединения разом, даже те, чьи сегменты дошли целыми.
HTTP/3 переносит всё на QUIC поверх UDP. Порядок доставки гарантируется внутри каждого потока по отдельности — потерянный пакет блокирует только свой поток, соседей не трогает. Заодно QUIC складывает транспортное рукопожатие и TLS 1.3 в одну процедуру. Там, где TCP тратит круг на SYN и SYN-ACK, а TLS следом ещё один, QUIC укладывается в один.
Три обещания. Все три можно проверить измерением, а не верить слайдам.
❯ Стенд
Компонент | Что именно |
Сервер | Caddy 2.11.4, один порт, протоколы |
Клиент | curl 8.2.1-DEV: BoringSSL, nghttp2 1.52.0, quiche 0.18.0 |
ОС | Ubuntu 24.04, ядро 6.8.0 |
Сеть | loopback с MTU 1500, эмуляция |
Один и тот же бинарник curl обслуживает все три протокола — меняется только флаг. Это не мелочь. Сравнивать HTTP/2 из одной сборки с HTTP/3 из другой бессмысленно: разница между сборками перекроет разницу между протоколами, и вы будете измерять чужой код, а не транспорт.
Второй вопрос снимаю сразу — управление перегрузкой одинаково по обе стороны. В ядре net.ipv4.tcp_congestion_control = cubic, у quiche алгоритм по умолчанию тоже CUBIC. Значит, всё, что я увижу ниже, — это различия транспорта, а не выбора алгоритма.
И одна настройка, без которой замер вообще бессмыслен. QUIC работает поверх UDP, а стандартный приёмный буфер сокета его душит — Caddy при старте прямо жалуется в лог. Документация quic-go рекомендует поднять буфер до 7 МБ:
sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000Если вам попадётся бенчмарк, где HTTP/3 проигрывает, первым делом спросите — поднимали ли там буфер. Обычно нет.
Профили сети. netem на loopback применяется к обоим направлениям, поэтому RTT равен удвоенной задержке — проверено замером времени TCP-рукопожатия. По той же причине удваиваются потери: в колонке «потери 1%» читайте «1% на каждое направление».
Сценарий | Настройка netem | Измеренный RTT |
Дата-центр | Нет | 0,15 мс |
Проводной |
| 40,16 мс |
Другой континент |
| 150,24 мс |
Мобильный |
| 60,22 мс |
Плохой мобильный |
| 60,25 мс |
Край соты |
| 100,42 мс |
Четыре теста. Десять файлов по 8 КБ. Пятьдесят таких же. Один файл 3 МБ. И холодное соединение. HTTP/1.1 получает шесть параллельных соединений, HTTP/2 и HTTP/3 мультиплексируют потоки в одном — проверено счётчиком num_connects. Медиана из пяти прогонов, для крупного файла — из трёх, таймаут две минуты.
❯ Десять файлов: шесть окон перегрузки против одного
Сценарий | HTTP/1.1 | HTTP/2 | HTTP/3 | Лучший | HTTP/3 к HTTP/2 |
Дата-центр, RTT 0 | 7 мс | 9 мс | 6 | HTTP/3 | -33% |
Проводной, RTT 40 мс | 170 мс | 210 мс | 146 | HTTP/3 | -30% |
Другой континент, RTT 150 мс | 615 мс | 767 мс | 519 | HTTP/3 | -32% |
Мобильный, RTT 60 мс, потери 1% | 255 мс | 348 мс | 214 | HTTP/3 | -39% |
Плохой мобильный, RTT 60 мс, потери 3% | 569 мс | 368 мс | 270 | HTTP/3 | -27% |
Край соты, RTT 100 мс, потери 5% | 846 мс | 708 мс | 466 | HTTP/3 | -34% |
Первое, что бросается в глаза: на каналах без потерь HTTP/2 проигрывает HTTP/1.1. На проводном — 210 мс против 170, на межконтинентальном — 767 против 615. Ровно противоположно тому, что обещают слайды.
Объяснение простое, даже обидно простое. Браузер открывает шесть соединений, и десять файлов укладываются в два RTT. У каждого соединения своё окно перегрузки, все шесть проходят slow start параллельно — за тот же круг в сеть уходит вшестеро больше сегментов. А HTTP/2 кладёт всё в одно соединение и разгоняет одно-единственное окно. Мультиплексирование тут не отыгрывается: очереди из запросов и так нет.
HTTP/3 выигрывает у HTTP/2 стабильно — от 27 до 39 процентов. Я сначала списал это на рукопожатие, потом посчитал. На проводном канале отрыв 64 мс при экономии на рукопожатии 38, на межконтинентальном — 248 против 149. Рукопожатие объясняет от 41 до 60 процентов, остальное без объяснения. Скорее всего, начальное окно перегрузки у QUIC своё и разгоняется иначе — но чтобы утверждать это, нужна трассировка, а у меня её нет.
❯ Пятьдесят файлов: где мультиплексирование окупается
Сценарий | HTTP/1.1 | HTTP/2 | HTTP/3 | Лушчий | HTTP/3 к HTTP/2 |
Дата-центр, RTT 0 | 14 мс | 12 мс | 15 мс | HTTP/2 | +25% |
Проводной, RTT 40 мс | 461 мс | 306 мс | 227 мс | HTTP/3 | -26% |
Другой континент, RTT 150 мс | 1,67 с | 1,07 с | 827 мс | HTTP/3 | -23% |
Мобильный, RTT 60 мс, потери 1% | 805 мс | 492 мс | 355 мс | HTTP/3 | -28% |
Плохой мобильный, RTT 60 мс, потери 3% | 1,12 с | 1,16 мс | 520 мс | HTTP/3 | -55% |
Край соты, RTT 100 мс, потери 5% | 2,07 с | 1,92 мс | 1,12 с ⚠ 4/5 | HTTP/3 | -41% |

Картина переворачивается. Пятьдесят файлов на шести соединениях — это девять кругов, и HTTP/1.1 платит за каждый. На межконтинентальном канале 1,67 секунды против 827 миллисекунд у HTTP/3. Разница вдвое, и она видна невооружённым глазом.
И тут же главная оговорка. Посмотрите на первую строку: в дата-центре, где RTT практически ноль, все три протокола укладываются в 12–15 миллисекунд. Разница лежит в пределах шума. Мультиплексирование окупается только тогда, когда круговая задержка заметна. Нет задержки — нет выигрыша. Вот, собственно, и всё.
❯ Один поток на 3 МБ: упираемся в пропускную способность
Сценарий | HTTP/1.1 | HTTP/2 | HTTP/3 | Лучший | HTTP/3 к HTTP/2 |
Дата-центр, RTT 0 | 5 мс | 8 мс | 22 мс | HTTP/1.1 | +186% |
Проводной, RTT 40 мс | 631 мс | 632 см | 654 мс | HTTP/1.1 | +4% |
Другой континент, RTT 150 мс | 1,62 с | 1,62 с | 2,10 с | HTTP/2 | +30% |
Мобильный, RTT 60 мс, потери 1% | 4,59 с | 1,22 с | 6,66 с | HTTP/2 | +446% |
Плохой мобильный, RTT 60 мс, потери 3% | 16,90 с | 18,19 с | 10,92 с | HTTP/3 | -40% |
Край соты, RTT 100 мс, потери 5% | 32,10 с | 40,31 с | 19,42 с ⚠ 1/3 | HTTP/3 | -52% |

Строка «Проводной» — это квинтэссенция всей статьи. 631, 632 и 654 миллисекунды. Три протокола, разных на десять лет разработки, показывают одно и то же время. Потому что все трое упираются в пропускную способность. Узкое место лежит вне протокола.
Прикиньте порядок величин. При 100 Мбит/с и RTT 40 мс произведение полосы на задержку — около 500 КБ. Файл 3 МБ — это шесть таких окон, и заметная часть передачи уходит на разгон.
Строка «Дата-центр» интереснее. На идеальном локальном канале HTTP/1.1 отдаёт файл за 5 мс, HTTP/2 — за 8, HTTP/3 — за 22. Новые протоколы проигрывают старому вчетверо. Когда сеть перестаёт быть узким местом, им становится процессор: HTTP/2 тратит такты на фрейминг и сжатие заголовков HPACK, а у QUIC весь транспорт живёт в пространстве пользователя — каждая датаграмма проходит через системный вызов и шифрование, тогда как TCP-стек работает в ядре.
❯ Рукопожатие: экономия ровно в один RTT
Сценарий | HTTP/1.1 | HTTP/2 | HTTP/3 | Лучший | HTTP/3 к HTTP/2 |
Дата-центр, RTT 0 | 1 мс | 2 мс | 2 мс | HTTP/1.1 | -2% |
Проводной, RTT 40 мс | 122 мс | 124 мс | 86 мс | HTTP/3 | -30% |
Другой континент, RTT 150 мс | 452 мс | 452 мс | 303 мс | HTTP/3 | -33% |
Мобильный, RTT 60 мс, потери 1% | 182 мс | 182 мс | 123 мс | HTTP/3 | -33% |
Плохой мобильный, RTT 60 мс, потери 3% | 182 мс | 182 мс | 125 мс ⚠ 3/5 | HTTP/3 | -32% |
Край соты, RTT 100 мс, потери 5% | 349 мс | 303 мс | 203 мс | HTTP/3 | -33% |

Здесь HTTP/3 выигрывает предсказуемо: примерно на треть во всех сценариях с задержкой. TCP тратит круг на SYN и SYN-ACK, и только по установленному соединению идёт TLS 1.3, забирая ещё один. А у QUIC криптография едет прямо в первых пакетах транспорта — отдельного TLS-рукопожатия после установки соединения просто нет.
Арифметика сходится до миллисекунд. На межконтинентальном канале с RTT 150 мс: 452 мс у TCP против 303 у QUIC. Разница — 149 мс, ровно один круговой обмен.
Одна оговорка. В начале рукопожатия сервер не имеет права отправить на неподтверждённый адрес больше чем втрое от полученного объёма — RFC 9000 называет это anti-amplification limit. В этот лимит входит цепочка сертификатов. Если она объёмная, ответ не помещается, и рукопожатие съедает лишний круг. У меня сертификат локального центра сертификации маленький, поэтому экономия и легла точно в один RTT.
❯ Потери пакетов: откуда берётся разброс в разы
Девять прогонов файла 3 МБ подряд в одних и тех же условиях.

При потерях 1% худший прогон HTTP/1.1 хуже лучшего в 13,5 раза. У HTTP/2 — в 11,3. У HTTP/3 — только в 2,4. На чистом канале у всех троих разброс — единица.
Причина — в том, как TCP обнаруживает потерю. Если следом за потерянным сегментом пришли другие, отправитель видит дублирующие подтверждения и уходит в быструю повторную передачу: cubic срежет окно примерно на треть и поедет дальше. Именно на треть, а не вдвое — уполовинивание это поведение Reno, и разницу видно прямо в ядре:
$ cat /sys/module/tcp_cubic/parameters/beta 717 QUIC устроен иначе. Номера пакетов у него монотонно растут и не повторяются — повторная передача едет под новым номером. Исчезает та самая неоднозначность, из-за которой TCP не может понять, какому из двух одинаковых сегментов пришло подтверждение, и портит себе оценку RTT. Вместо таймаута повторной передачи работает probe timeout по RFC 9002.
Для продакшена это важнее средних. Пользователь ведь видит не медиану — он видит конкретную загрузку. Свою. Сейчас.
❯ Пять процентов потерь: HTTP/3 встаёт намертво
Последний сценарий — RTT 100 мс и 5% потерь. Обычная жизнь на границе покрытия.
По медианам выходила красивая картинка: 32,1 секунды у HTTP/1.1, 40,3 у HTTP/2 и всего 19,4 у HTTP/3. Отличный финал для статьи. Потом я посмотрел, сколько прогонов вообще дошло до конца. У HTTP/3 — один из трёх.

За весь эксперимент таких набралось семь. И все семь — у HTTP/3. Отдельная серия с проверкой кода возврата показала CURLE_OPERATION_TIMEDOUT: не разрыв соединения и не ошибка QUIC, а обычный таймаут.
Дальше я снял трафик tshark'ом. И картина оказалась неожиданной. Зависание — это не медленная передача, а полная остановка:
run1 19,8 с успех пауз >300 мс: 0
run2 120,3 с таймаут одна пауза 108,4 с (встал на 11,6 с, отдав 1,77 МБ)
run4 120,2 с таймаут одна пауза 119,3 с (встал на 0,69 с, отдав 0,14 МБ)
run6 24,1 с успех пауз >300 мс: 0Три зависания из шести устроены одинаково. Последний пакет перед тишиной — всегда от клиента, короткое подтверждение на 77 байт. После него сервер не присылает ничего, а клиент не отправляет ни одного зондирующего пакета, хотя RFC 9002 их предписывает. Обе стороны просто ждут друг друга — пока curl не закроет соединение по своему таймауту.
Точка срыва произвольная. Один раз — после 1,77 МБ. Два раза — после 0,15 МБ, на первой секунде. Версия про исчерпание окна на фиксированной границе отпала.
Назвать причину я не берусь. QUIC шифрует типы фреймов, и чтобы увидеть последний отправленный, нужен захват с ключами. Фиксирую то, что воспроизводится: связка curl 8.2.1-DEV с quiche 0.18.0 и Caddy на quic-go под потерями от 3% примерно в половине случаев встаёт намертво. Сборка клиента старая — на свежей стоит перепроверить.
❯ Три ловушки, в которые я попался
Все три раза ошибка выглядела как свойство протокола. И все три раза оказывалась свойством стенда.
MTU у loopback. Первый вариант стенда давал разброс в восемь раз между двумя запусками одной команды. Очередь netem была ни при чём — tc -s qdisc показывал dropped 0. Тогда я померил канал голым сокетом на Python, без всякого HTTP: чистый TCP выдал 34 МБ/с там, где curl еле выжимал 1 МБ/с. Тридцатикратный разрыв указывал на стенд. Причина — MTU интерфейса lo равен 65536, в сорок раз больше обычного Ethernet, и окно перегрузки, которое считается в сегментах, ведёт себя рвано. Лечится строчкой ip link set lo mtu 1500.
Медиана по уцелевшим прогонам. Те самые «19,4 секунды», посчитанные по одному успешному измерению из трёх. Формально не ложь: медиана успешных загрузок действительно такая. Просто читатель никогда бы не узнал, что каждая вторая попытка не заканчивается. Теперь генератор таблиц помечает такие клетки: 19,42 с ⚠ 1/3. Шесть символов — а смысл строки меняют.
Распределение потерь. Самую неприятную я нашёл, уже когда садился писать выводы. В захвате оказалось, что кадры QUIC на loopback доходят до 14 452 байт при MTU 1500: quic-go отдаёт ядру несколько пакетов одним системным вызовом. У TCP максимальный кадр — 2962 байта.
netem стоит до сегментации и роняет кадр целиком. Я решил, что это занижает HTTP/3, посчитал — оказалось, нет: доля потерянных пакетов совпадает, 124 против 125 из примерно 2500. Отличается распределение. Со склейкой потери приходят пачками — по два-одиннадцать подряд. Без склейки — поодиночке.
Я отключил склейку переменной QUIC_GO_DISABLE_GSO=true и перемерил всё заново.

50 файлов | HTTP/1.1 | HTTP/2 | HTTP/3 |
Мобильный, потери 1% | 805 → 756 мс | 492 → 645 мс | 355 → 715 мс |
Плохой мобильный, 3% | 1,1 → 1,1 с | 1,2 → 1,0 с | 520 → 854 мс |
Край соты, 5% | 2,1 → 1,7 с | 1,9 → 3,9 с | 1,1 → 2,5 с |
При одной и той же доле потерь HTTP/3 вдвое медленнее, когда потери рассеяны. У HTTP/1.1 время почти не меняется. У HTTP/2 скачет без системы. Распределение бьёт в основном по QUIC — и это, в общем, логично: у него пятьдесят потоков восстанавливаются независимо, поэтому рассеянные потери задевают много потоков сразу, а пачка — один.
Возражение про процессор снимается контролем. Если бы дело было в лишних системных вызовах без склейки, это проявилось бы и на чистых каналах, а там ничего не изменилось — 461 против 455 мс, 306 против 308, 227 против 229.
Зависания HTTP/3, кстати, никуда не делись и во второй конфигурации. Сместились в другой сценарий, но остались. И по-прежнему только у HTTP/3.
❯ Что из этого следует
Ситуация | HTTP/2 против HTTP/1.1 | HTTP/3 против HTTP/2 |
Сервер и клиент в одном дата-центре | ничего, иногда хуже | хуже |
Один крупный файл, любой канал | ничего | ничего или хуже |
Десяток мелких файлов | часто хуже | лучше на треть |
Полсотни мелких файлов, заметный RTT | вдвое лучше | лучше на четверть |
Мелкие файлы плюс потери | по-разному | зависит от распределения потерь |
Установка соединения, заметный RTT | ничего | лучше на треть |
Включать HTTP/2 и HTTP/3 стоит. Ни в одном реалистичном сценарии они не сделали заметно хуже — проигрыши вылезли только на loopback, где сеть вырождена. Но чуда не ждите там, где узкое место в другом. Если у вас один большой файл, медленная база или тяжёлый бэкенд — смена протокола не изменит ничего.
Больше всего выигрывают те, у кого много мелких ресурсов и далёкие пользователи. Если весь трафик приходит из соседнего дата-центра, эффект будет околонулевым.
HTTP/3 нужен как раз там, где есть потери: мобильный интернет, публичный Wi-Fi, дальние маршруты. На стабильном проводном канале его преимущество сводится к одному сэкономленному кругу на рукопожатии. И проверьте UDP-буфер: полстроки в sysctl отделяют работающий HTTP/3 от протокола, который у вас «почему-то медленный».
❯ Границы применимости
Замеры сделаны на эмулированной сети, и характер потерь у netem зависит от сегментации — совпадение с реальной сетью случайное. Клиент curl, а не браузер: нет разбора HTML, приоритизации, кеша и предзагрузки. Сервер один, реализации QUIC заметно отличаются. 0-RTT не проверял.
Всё это воспроизводится: конфиг Caddy на десять строк, шелл-скрипт и tc netem.
❯ Итог
Я шёл в эксперимент с ожиданием, что новые протоколы дают равномерный выигрыш — просто разного размера. Получилось не так.
Выигрыш оказался узким и адресным. HTTP/2 окупается ровно в одном сценарии — много мелких файлов при заметной задержке — и там действительно даёт вдвое. Во всех остальных он либо не меняет ничего, либо слегка проигрывает старому доброму HTTP/1.1 с его шестью соединениями. HTTP/3 добавляет сэкономленный круг на рукопожатии и более предсказуемое поведение под потерями, но под равномерными потерями сам оказывается вдвое медленнее, а при 5% в половине прогонов вставал намертво.
Самое неожиданное для меня не цифры, а то, как легко получить неправильные: восьмикратный разброс из-за MTU; медиана по одному уцелевшему прогону, которая просилась в заголовок и распределение потерь, о котором я не думал вообще.
Отсюда единственный вывод, который я готов защищать: правдоподобный результат опаснее странного. Странный вы пойдёте проверять сами, а «19,4 секунды вместо 40» выглядят совершенно нормально — и проходят в публикацию, если не пересчитать прогоны из чистого занудства.
❯ Ссылки
RFC 9114 — HTTP/3
RFC 9000 — QUIC, в том числе anti-amplification limit
RFC 9002 — обнаружение потерь и управление перегрузкой в QUIC
RFC 9113 — HTTP/2 и причина, по которой HTTP/1.1 открывает несколько соединений
Документация quic-go по оптимизациям — размер UDP-буфера и переменная QUIC_GO_DISABLE_GSO
RIPE Labs: Navigating Network Measurements — почему сетевым измерениям нельзя верить по умолчанию
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.