HTTP — это язык, на котором браузер разговаривает с сервером, а сервисы между собой. Сам смысл запросов почти не менялся десятилетиями: GET /orders/42, заголовки, тело, код ответа. А вот то, как эти запросы упаковываются и бегут по сети, за это время переписали трижды. Так появились HTTP/1.1, HTTP/2 и HTTP/3 — три версии одного протокола, каждая быстрее предыдущей.

Хорошая новость: ваш код почти всегда остаётся прежним. Разбираться в версиях нужно не чтобы переписывать логику, а чтобы понимать, почему одна страница грузится рывками, а другая плавно, и что за галочку «HTTP/2» вы включаете на балансировщике. Пойдём по порядку — от самого старого к самому новому.

Проще всего разница видна на одной шкале времени: пока буксует один ответ, важно, что в это время происходит с остальными.

большой ответ A и маленький B по одной сети; на 50-й мс теряется пакет A 0 50 100 150 200 250 300 мс потерян один пакет ответа A HTTP/1.1 очередь в соединении HTTP/2 мультиплекс поверх TCP HTTP/3 мультиплекс поверх QUIC A B A B A B A: 200 мс B: 250 мс A: 200 мс B: 150 мс A: 200 мс B: 100 мс B ждёт, пока A не доедет весь: очередь в соединении кадры B доехали к 100 мс — TCP держит их до переотправки потеря держит только поток A — B отдали вовремя

Числа условные, важно, кто кого ждёт. Один потерянный пакет большого ответа A задерживает маленький B по-разному: в HTTP/1.1 B ещё даже не начинался — очередь в соединении; в HTTP/2 кадры B уже доехали, но TCP отдаёт байты строго по порядку и держит их до переотправки; в HTTP/3 поток B не ждёт ничего — QUIC знает про потоки и тормозит только пострадавший.

Обязательно

HTTP/1.1: просто, но по очереди

HTTP/1.1 — версия, на которой интернет прожил бо́льшую часть своей истории. Она текстовая: запрос — это буквально строки, которые можно прочитать глазами. Открыли соединение, послали GET /page, получили ответ. Удобно для отладки, легко для понимания.

Раннее неудобство HTTP/1.0 — на каждый запрос новое соединение — здесь уже решено механизмом keep-alive: соединение остаётся открытым, и по нему можно послать несколько запросов подряд, не переустанавливая связь каждый раз. Это экономит время: установка соединения сама по себе не бесплатна (подробнее — в статье про соединения).

Но есть фундаментальное ограничение: ответы по одному соединению приходят строго по очереди. Если первый ответ большой или сервер над ним задумался, все остальные ждут за ним, даже если давно готовы. Это называется head-of-line blocking — «блокировка головой очереди»: один медленный элемент впереди держит всех, кто за ним, как одна застрявшая машина держит целую полосу.

Строго говоря, слать запросы не дожидаясь ответа HTTP/1.1 умеет — это называется конвейеризацией (pipelining). Легче от неё, правда, не становится: отвечать сервер всё равно обязан в том же порядке, в каком его спросили, так что застрявший первый ответ по-прежнему держит остальные. Вдобавок конвейер регулярно ломали кривые посредники на пути, и браузеры в итоге выключили его у себя совсем. Так что на практике всё и выглядит как «послали — дождались — послали следующий».

Как с этим жили? Браузеры открывали к одному сайту несколько параллельных соединений — обычно шесть. Пока по одному качается картинка, по другому летит стиль, по третьему скрипт. Костыль работает, но дорогой: каждое соединение — это отдельная установка связи, отдельная память на сервере, отдельные накладные расходы. Шесть — потолок, а на тяжёлой странице ресурсов сотни.

HTTP/2: много потоков в одной трубе

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

Чтобы это стало возможным, протокол сделали бинарным. Вместо текстовых строк данные режутся на маленькие пронумерованные кусочки — кадры (frames). Каждый кадр помечен, к какому потоку он относится. Сервер и клиент отправляют кадры вперемешку, а на другой стороне собирают их обратно по номерам. Аналогия — не колонна машин на однополосной дороге, а посылки на общей ленте конвейера: едут вперемешку, но у каждой свой адрес, и на выходе их раскладывают по получателям.

[1|заголовки] [2|заголовки] [1|тело] [3|заголовки] [2|тело] [1|конец] поток 1 поток 2 поток 3 конец потока 1

В одном соединении кадры разных потоков идут вперемешку, а номер в начале кадра говорит, к какому потоку его отнести на приёме.

Что это даёт на практике:

  • Одно соединение вместо шести. Меньше установок связи, меньше нагрузки на сервер — а параллелизм при этом выше, чем был на шести соединениях.
  • Сжатие заголовков. У каждого запроса ворох повторяющихся заголовков (одни и те же cookie, user-agent, host). HTTP/2 их сжимает и не гоняет одно и то же по сто раз — заметная экономия там, где запросов много.
  • Server push — сервер мог сам, не дожидаясь запроса, дослать клиенту то, что тому наверняка понадобится (например, стиль к странице). На практике идея не прижилась: сервер не знал, что у клиента уже лежит в кэше, и слал лишнее; Chrome убрал поддержку push в 2022 году, в версии 106, а nginx в 2023-м. В описаниях HTTP/2 она всё ещё встречается.

Важно: сам смысл запросов не изменился. Те же методы, заголовки, коды ответов — поменялась только «упаковка». Поэтому переход на HTTP/2 обычно не требует трогать код сервиса.

Что осталось нерешённым в HTTP/2

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

И вот тут прячется ловушка. TCP отдаёт данные наверх строго по порядку. Если в пути потерялся один пакет, TCP останавливает выдачу всего, что пришло после него, и ждёт, пока потерянный кусок доедет заново. А в этом «всём, что после» лежат кадры разных потоков. Получается: логически потоки независимы, но один потерянный пакет тормозит их все — потому что все они делят одно TCP-соединение.

Это снова head-of-line blocking, только спустившийся на уровень TCP. HTTP/2 честно убрал блокировку у себя, но не мог достать до транспорта под собой. На хорошей сети разница незаметна, а вот на мобильной связи с потерями пакетов HTTP/2 иногда работал не лучше, чем несколько отдельных соединений HTTP/1.1.

Три ограничения, из-за которых «одно соединение» не всегда благо.

Потолок одновременных потоков. Сервер объявляет предел (обычно сто), и запросы сверх него ждут в очереди на стороне клиента. Для браузера сто — много, для сервиса, который шлёт тысячу параллельных запросов в один адрес, — уже предел: дальше начинается ожидание, которое выглядит как медленный сервис при незагруженной сети.

Управление потоком. У каждого потока и у соединения есть окно — сколько данных можно отправить без подтверждения. Окно по умолчанию невелико (64 КБ), и на больших ответах поток упирается в него, пока получатель не подтвердит прочитанное. На быстрых каналах с большой задержкой это ограничивает скорость сильнее, чем полоса; настраивается на сервере и в клиенте.

Одно соединение прибивает к одному узлу. Это главное и самое неожиданное. Балансировщик распределяет соединения, а не запросы; при HTTP/1.1 клиент с пулом из двадцати соединений раскладывается по всем узлам, а при HTTP/2 у него одно соединение — и все запросы идут на один узел, выбранный при подключении. Отсюда картина: узлов десять, нагрузка на одном, задержка выросла, пул «выродился» до одного соединения.

Лечится это тремя способами. Балансировщик уровня приложения, который умеет распределять запросы внутри соединения (так делает прокси, понимающий HTTP/2). Или клиент, которому явно велено держать несколько соединений к одному адресу. Или обнаружение сервисов на стороне клиента: клиент сам знает список узлов и открывает соединение к каждому — так работает балансировка в gRPC.

Когда новая версия не нужна

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

HTTP/2 между своими сервисами тоже часто бесполезен: запросы там идут по постоянным соединениям из пула, размер ответов небольшой, потерь нет — то есть ни мультиплексирование, ни сжатие заголовков не спасают от того, чего нет. Зато появляется описанная выше привязка к одному узлу. Практическое правило: внутри — HTTP/1.1 с пулом или gRPC (который берёт HTTP/2 осознанно, вместе со своей балансировкой), снаружи — HTTP/2 и HTTP/3 на прокси.

HTTP/3: сменить фундамент под протоколом

Чтобы убрать блокировку окончательно, нужно было заменить сам транспорт. Так появился HTTP/3 — тот же бинарный мультиплексируемый HTTP, но поверх QUIC вместо TCP.

QUIC — это транспорт, построенный на UDP (быстром протоколе «отправил и забыл», без гарантии порядка) с досыпанной поверх надёжностью. Ключевое отличие: QUIC знает про потоки. Если теряется пакет одного потока, страдает только этот поток — остальные едут дальше, их никто не держит. Тот самый TCP-level head-of-line blocking исчезает, потому что TCP под протоколом больше нет.

Бонусом QUIC быстрее устанавливает соединение. В TCP сначала идёт рукопожатие транспорта, потом отдельно рукопожатие шифрования — два круга задержки. QUIC складывает их в одно: шифрование встроено в него изначально, и связь поднимается за меньшее число обменов. На мобильной сети, где каждый круг до сервера ощутим, это заметно ускоряет первый ответ.

Цена — QUIC ходит по UDP, а некоторые старые сети и межсетевые экраны относятся к UDP настороженно. Поэтому HTTP/3 обычно работает как ускорение поверх: клиент и сервер умеют HTTP/2, а при возможности переключаются на HTTP/3, откатываясь обратно, если QUIC не проходит.

Переключение это не магия, у него есть конкретное место. Самый ходовой способ — заголовок Alt-Svc в ответе: сервер отвечает как обычно, по HTTP/2, и заодно приписывает «меня же можно найти по HTTP/3 вот на этом порту»; клиент это запоминает и следующее соединение пробует уже через QUIC. Второй способ — запись HTTPS в DNS: там то же самое написано заранее, и клиент узнаёт про HTTP/3 ещё до первого соединения, не тратя лишний круг. Так что когда HTTP/3 «почему-то не включается», искать надо именно их: отдаёт ли балансировщик Alt-Svc и заведена ли запись в DNS.

Где это применяется

Для backend-сервиса версия HTTP — это не абстракция, а вполне конкретные точки, где она всплывает.

Первое и главное — gRPC работает поверх HTTP/2. Мультиплексирование и бинарные кадры для gRPC не бонус, а требование: множество параллельных вызовов и потоковая передача держатся именно на этом. Если вы поднимаете gRPC-сервис, HTTP/2 у вас уже включён, хотите вы того или нет.

Второе — где включается версия. Обычно не в коде приложения. Наружу смотрит прокси или балансировщик (nginx, Envoy, облачный load balancer), и HTTP/2 или HTTP/3 терминируется на нём: браузер общается с балансировщиком по HTTP/2, а тот дальше до вашего сервиса может идти хоть по обычному HTTP/1.1 — на коротком быстром участке внутри дата-центра разница уже не так важна. Где именно рвётся и пересобирается соединение — тема статьи про балансировщики. Ещё одна деталь: браузеры включают HTTP/2 только поверх шифрования, так что на практике он идёт в паре с HTTPS и TLS. Согласуют версию прямо внутри рукопожатия TLS — для этого там есть отдельное поле, ALPN (Application-Layer Protocol Negotiation): клиент в первом же сообщении перечисляет, что понимает (h2, http/1.1), сервер выбирает одно из списка. Отдельного обмена на это не тратится вовсе. Тот самый «тумблер HTTP/2» на балансировщике — это, как правило, ровно список ALPN в его настройках.

Где спотыкаются начинающие:

  • Думают, что HTTP/2 меняет смысл запросов. Нет. Методы, заголовки, коды ответов — те же, что в HTTP. Меняется только упаковка на проводе, а ваш обработчик запроса остаётся прежним.
  • Считают, что HTTP/2 полностью убил head-of-line blocking. Он убрал его на уровне HTTP, но на уровне TCP при потере пакета блокировка остаётся — окончательно её снимает только HTTP/3 поверх QUIC.
  • Пытаются «включить HTTP/3 в приложении». Обычно версия живёт на прокси/балансировщике, а не в коде сервиса. Искать переключатель нужно там.
  • Путают версию протокола с шифрованием. HTTP/2 и TLS — разные вещи, просто браузеры требуют их вместе. Версия отвечает за то, как гоняются данные, TLS — за то, что их не прочитать по дороге.
Дополнительно: при первом чтении можно пропустить

Глубже: gRPC: потоки, коды ошибок и дедлайнырасширенное

Дважды выше сказано, что gRPC работает поверх HTTP/2, и это объясняет, почему он быстрый, но не объясняет, чем он отличается для того, кто его вызывает. Три вещи.

Контракт и потоки. Метод и типы описаны в файле .proto, из него генерируется клиент и сервер на любом языке, и тело идёт бинарным Protobuf, который меньше и быстрее JSON. Каждый вызов это отдельный поток HTTP/2, поэтому gRPC умеет четыре формы: обычный вызов «запрос и ответ», серверный поток (клиент спросил, сервер шлёт ответы по мере готовности), клиентский поток (загрузка частями) и двусторонний поток, где обе стороны шлют сообщения независимо. Именно последняя форма отличает gRPC от REST по существу: чат, подписка на изменения, обмен телеметрией без WebSocket и опроса.

Коды ошибок. HTTP-статус у ответа gRPC почти всегда 200, а результат лежит в конце ответа, в заголовке-трейлере grpc-status. Кодов шестнадцать, и они про смысл, а не про транспорт: NOT_FOUND, INVALID_ARGUMENT, ALREADY_EXISTS, PERMISSION_DENIED, UNAVAILABLE (повторять можно), DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED (аналог 429). Из этого следуют две грабли. Балансировщик и прокси, которые смотрят на HTTP-статус, считают все ответы успешными и не видят ошибок, поэтому им нужно понимать gRPC отдельно. И код UNAVAILABLE это единственный, который повторяют по умолчанию, а повтор UNKNOWN или INTERNAL может продублировать операцию.

Дедлайны. В gRPC срок задаёт клиент при каждом вызове, и он передаётся по цепочке: сервис A вызвал B с дедлайном две секунды, B вызывает C и передаёт остаток. Это тот самый бюджет таймаутов из статьи про надёжность, только встроенный в протокол: заголовок grpc-timeout идёт с запросом, а сервер по истечении получает отменённый контекст и обязан прекратить работу. Клиент без дедлайна ждёт вечно, и это первое, что проверяют в новом клиенте.

Что остаётся снаружи. Браузер напрямую gRPC не говорит (нужен gRPC-Web с прокси), из curl его не потрогать без grpcurl, отладка по логам сложнее из-за бинарного тела, а HTTP/2 требует, чтобы балансировщик умел балансировать по запросам, а не по соединениям, иначе все вызовы одного клиента уйдут на один экземпляр по одному долгоживущему соединению. Поэтому gRPC берут между сервисами внутри системы, где обе стороны свои, а наружу отдают REST.

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

Потоковые вызовы в обе стороны. Кроме привычного «запрос — ответ» есть поток от клиента (отправляем множество сообщений, получаем один ответ), поток от сервера (один запрос, поток ответов — так делают подписки на изменения) и двусторонний поток (обе стороны пишут независимо, как в WebSocket, но с типизированными сообщениями и внутри одного соединения).

Отмена вызова. Клиент, которому ответ больше не нужен, закрывает свой поток — и сервер об этом узнаёт, то есть может прекратить работу. В обычном HTTP/1.1 отмена выглядит как обрыв соединения, и сервер часто продолжает считать.

Дедлайны вместо таймаутов. Клиент передаёт не «сколько я жду», а «до какого момента это имеет смысл», и дедлайн передаётся по цепочке вызовов дальше. Нижний сервис знает, что осталось 40 миллисекунд, и может не начинать работу, которая столько не уложится.

Отсюда и практический вывод: gRPC не «HTTP/2 для сервисов», а другая модель общения. Поэтому он приносит с собой свою балансировку (по запросам, а не по соединениям), свои коды ошибок и свой способ описывать контракт.

Коротко

  • HTTP/1.1 обрабатывает запросы по очереди, HTTP/2 гоняет много потоков в одном соединении, HTTP/3 переносит это на QUIC поверх UDP.
  • Версия включается на балансировщике, а не в коде; выигрыш HTTP/2 внутри системы съедает одно соединение на всех, если балансировать по соединениям.
  • gRPC это Protobuf и HTTP/2 с четырьмя формами вызова; ошибки в трейлере grpc-status, а не в HTTP-статусе, повторять можно только UNAVAILABLE; дедлайн задаёт клиент и он передаётся по цепочке.
  • Наружу отдают REST, gRPC оставляют между своими сервисами: браузер и curl его напрямую не говорят, а прокси должны понимать grpc-status.
  • У HTTP/2 три ограничения: потолок одновременных потоков, небольшое окно управления потоком и главное — одно соединение прибивает клиента к одному узлу балансировщика.
  • Лечится это прокси, распределяющим запросы, несколькими соединениями или балансировкой на стороне клиента, как в gRPC.
  • HTTP/3 дороже по процессору и режется сетями без UDP, а HTTP/2 внутри дата-центра обычно не даёт ничего; для gRPC потоки — не ускорение, а способ работы, вместе с отменой вызова и дедлайнами.

Что почитать дальше

Версии HTTP стоят на транспорте под ними, поэтому логично разобрать сам транспорт: TCP и UDP — там же живёт QUIC, на котором держится HTTP/3. Полезно понять и то, что именно версии оптимизируют — установку и переиспользование соединений: keep-alive, рукопожатия, цена нового соединения. Дальше — сам прикладной слой HTTP (методы, коды, заголовки) и шифрование поверх него, HTTPS и TLS, в паре с которым HTTP/2 и HTTP/3 обычно и работают.