HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3
HTTP — самый успешный протокол в истории и одновременно самый недопонятый. Его учат по шпаргалке «GET читает, POST пишет, 404 не найдено» и на этом останавливаются, а потом в проде выясняется, что CDN отдаёт всем пользователям персональную страницу первого зашедшего, что ретрай POST списал деньги дважды, что «мы включили HTTP/2» ничего не ускорило, а прокси и бэкенд по-разному поняли, где заканчивается тело запроса, и это оказалось дырой в безопасности.
Ключевая идея, из которой выводится всё остальное: HTTP — это протокол передачи представлений ресурсов, и он спроектирован вокруг посредников. Между вашим браузером и вашим приложением почти никогда нет прямой линии: там браузерный кэш, корпоративный прокси, CDN, балансировщик, service mesh sidecar. Каждый из них имеет право читать и интерпретировать сообщение. Именно поэтому HTTP такой болтливый и текстовый, поэтому у него настолько подробная модель кэширования, и поэтому семантика методов («этот запрос безопасно повторить») важнее любой из его бинарных версий.
Актуальная спецификация — не «RFC 2616», как до сих пор пишут в половине статей, а серия 2022 года: RFC 9110 «HTTP Semantics» (методы, коды, заголовки — общее для всех версий), RFC 9111 «Caching», RFC 9112 «HTTP/1.1», RFC 9113 «HTTP/2», RFC 9114 «HTTP/3». Такое разделение само по себе поучительно: семантику вынесли отдельно, потому что она за 25 лет почти не изменилась, а транспорт переписали трижды.
Предполагается, что вы знакомы с TCP, UDP и QUIC и DNS; карта уровней — в обзоре трека.
Анатомия сообщения
Проще всего это увидеть, отправив запрос руками, без библиотек:
# Открытым текстом на 80-й порт: видно ровно то, что уходит в TCP
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
| nc example.com 80 | head -12
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
ETag: "3147526947"
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Cache-Control: max-age=604800
Date: Wed, 16 Jul 2026 09:04:11 GMT
Expires: Wed, 23 Jul 2026 09:04:11 GMT
Content-Length: 1256
Connection: close
Три вещи, которые здесь важнее, чем кажется. \r\n, а не \n — строгие серверы (и все прокси) считают одиночный LF нарушением; ошибка «мой самописный клиент работает с nginx, но ловит 400 от Envoy» почти всегда про это. Заголовок Host обязателен в HTTP/1.1: один IP обслуживает тысячи сайтов, и без Host сервер не знает, чью страницу отдавать; его отсутствие — гарантированный 400 Bad Request. Пустая строка — единственная граница заголовков и тела; всё, что после, интерпретируется по Content-Length или Transfer-Encoding.
Для TLS вместо nc берём openssl s_client — тот же уровень «сырых байтов», но внутри шифрования (подробности рукопожатия — в TLS):
printf 'HEAD / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
| openssl s_client -quiet -servername example.com -connect example.com:443 2>/dev/null
-servername здесь не косметика: это SNI, без него сервер с сотней сайтов отдаст дефолтный сертификат, и вы будете отлаживать несуществующую проблему.
Методы: три свойства, из которых следует всё
Метод — это не «глагол для красоты URL», а обещание о трёх независимых свойствах. Именно на них опираются браузеры, прокси, CDN и библиотеки ретраев.
| Метод | Безопасный | Идемпотентный | Кэшируемый | Тело запроса |
|---|---|---|---|---|
GET |
да | да | да | нет (по семантике игнорируется) |
HEAD |
да | да | да | нет |
OPTIONS |
да | да | нет | нет |
TRACE |
да | да | нет | нет |
PUT |
нет | да | нет | да |
DELETE |
нет | да | нет | обычно нет |
POST |
нет | нет | да, но только с явным Cache-Control |
да |
PATCH |
нет | нет (зависит от формата патча) | нет | да |
- Безопасный (safe) — «не должен менять состояние». Отсюда: браузер имеет право спекулятивно предзагружать
GET, поисковый робот пройдёт по всем ссылкам. Классическая катастрофа нулевых — админка со ссылками/delete?id=17: приходил краулер и вычищал базу. Это не баг краулера. - Идемпотентный — «повторить N раз = сделать один раз». Отсюда: клиентские библиотеки и прокси автоматически ретраят
GET,PUT,DELETEпри обрыве соединения и не ретраятPOST. Если вашPOST /paymentsдолжен переживать ретрай — вводите ключ идемпотентности в заголовке; см. идемпотентность и доставку. - Кэшируемый — «ответ можно сохранить и переиспользовать». По умолчанию кэшируются ответы
GETиHEADс определёнными кодами.
Практические различия, о которых спорят на ревью:
PUT vs POST. PUT /orders/8812 — «сделай так, чтобы по этому URI лежало вот это». Клиент задаёт идентификатор, повтор безопасен. POST /orders — «обработай это как считаешь нужным», сервер сам создаёт ресурс и возвращает 201 Created с Location. Правило: если клиент знает URI заранее — PUT, если нет — POST.
PATCH. Не идемпотентен в общем случае: {"op":"increment","field":"qty"} при повторе даст другой результат. Идемпотентным его делает формат данных, а не метод. Используйте JSON Merge Patch (RFC 7386) для «заменить поля» или JSON Patch (RFC 6902) для операций, и явно указывайте Content-Type: application/merge-patch+json.
OPTIONS и CORS-preflight. Браузер сам, до вашего запроса, отправляет OPTIONS, если запрос «непростой» (метод не GET/HEAD/POST, или заголовок вроде Authorization, или Content-Type: application/json):
curl -i -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PATCH, DELETE
access-control-allow-headers: content-type, authorization
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin
Здесь важны две строки, которые забывают. access-control-max-age: 600 кэширует preflight — без неё каждый запрос стоит два RTT вместо одного. vary: Origin обязателен, если allow-origin вычисляется по входящему Origin: без него CDN закэширует ответ для одного фронтенда и отдаст его другому, сломав CORS для всех. И помните: CORS — это защита браузера, а не сервера. curl игнорирует её полностью, поэтому авторизация должна быть настоящей (API security).
TRACE отключайте: в связке с некоторыми клиентами он давал Cross-Site Tracing и утечку Cookie и Authorization.
Коды состояния: как выбрать правильный
Первая цифра — единственное, что клиент обязан понимать: 1xx информационные, 2xx успех, 3xx перенаправление, 4xx ошибка клиента, 5xx ошибка сервера. Незнакомый 418 клиент обязан трактовать как 400.
«ты кто?»"] B -->|"да"| D{"Права есть?"} D -->|"нет"| E["403
«я знаю кто ты, нельзя»
404 если факт существования секретен"] D -->|"да"| F{"Ресурс существует?"} F -->|"нет"| G["404 / 410 если удалён навсегда"] F -->|"да"| H{"Тело запроса валидно?"} H -->|"синтаксис битый"| I["400"] H -->|"синтаксис ок, смысл нет"| J["422 Unprocessable Content"] H -->|"ок"| K{"Условные заголовки?"} K -->|"If-None-Match совпал"| L["304 Not Modified
без тела"] K -->|"If-Match не совпал"| M["412 Precondition Failed
оптимистическая блокировка"] K -->|"нет условий"| N{"Конфликт состояния?"} N -->|"да"| O["409 Conflict"] N -->|"нет"| P{"Лимиты?"} P -->|"превышен"| Q["429 + Retry-After"] P -->|"ок"| R{"Обработали?"} R -->|"синхронно"| S["200 / 201 + Location / 204"] R -->|"в очередь"| T["202 Accepted + ссылка на статус"] R -->|"упали"| U["500 / 502 / 503 + Retry-After / 504"]
Разбор мест, где ошибаются чаще всего.
301 vs 302 vs 307 vs 308. Историческая ловушка: браузеры при 301 и 302 меняли POST на GET, хотя спецификация этого не разрешала. Чтобы вылечить, ввели 307 (временный, метод и тело сохраняются) и 308 (постоянный, сохраняются). Правило: редирект HTTP→HTTPS и смена домена — 301; временный техперерыв — 302; редирект внутри API, где важно не потерять POST, — 307. И 303 See Other — «сделай GET вон туда», классический post/redirect/get после формы. 301 кэшируется браузером агрессивно и практически навсегда — ошибочный 301 на продакшене живёт в браузерах пользователей месяцами; на время эксперимента всегда 302.
401 vs 403. 401 Unauthorized на самом деле означает «не аутентифицирован» и обязан нести WWW-Authenticate. 403 Forbidden — «аутентифицирован, но не положено». Возврат 403 там, где нужен 401, ломает автоматическое обновление токена в клиентах.
400 vs 422. 400 — сервер не смог разобрать сообщение (сломанный JSON, битые заголовки). 422 — разобрал, но данные не проходят по бизнес-правилам (qty: -5). Разница практическая: 400 не имеет смысла показывать пользователю, 422 имеет.
429 и 503 оба должны нести Retry-After (секунды или HTTP-дату), иначе клиенты будут долбить в цикле и превратят частичную деградацию в полный отказ. Ретраи всегда с экспоненциальной задержкой и джиттером.
Формат тела ошибки — не изобретайте: есть RFC 9457 «Problem Details for HTTP APIs»:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Недостаточно средств",
"status": 409,
"detail": "На счёте 120.50 USD, требуется 340.00 USD",
"instance": "/accounts/12/withdrawals/8812",
"balance": "120.50"
}
Отдаётся с Content-Type: application/problem+json. type — стабильный идентификатор для кода клиента, detail — для человека.
Заголовки, которые действительно решают судьбу запроса
Заголовков сотни, но регулярно нужны десятка три. Сгруппируем по тому, на что они влияют.
Идентификация ресурса и представления. Host (в HTTP/2 и 3 — псевдозаголовок :authority) выбирает виртуальный хост. Content-Type описывает формат тела вместе с параметрами: application/json не имеет параметра charset (всегда UTF-8), а text/html; charset=utf-8 — имеет, и его отсутствие отдаёт браузеру право гадать. Content-Language, Content-Encoding (gzip, br, zstd) описывают то же представление в другой упаковке.
Согласование содержимого. Клиент шлёт Accept, Accept-Encoding, Accept-Language с весами (q=0.8), сервер выбирает и обязан объявить, по чему он выбирал, в Vary. Пропуск Vary — источник самых неприятных инцидентов с кэшами: русскоязычный ответ уезжает англоязычному пользователю, а br-сжатое тело — клиенту, который brotli не понимает.
Границы тела. Ровно один из двух механизмов: Content-Length (точная длина) или Transfer-Encoding: chunked (длина каждого куска в hex, нулевой кусок — конец). Если в сообщении есть оба, спецификация требует игнорировать Content-Length, но не все посредники это делают — отсюда HTTP request smuggling: фронт видит границу запроса в одном месте, бэкенд в другом, и «хвост» первого запроса становится началом второго, позволяя атакующему подставить свои заголовки в чужой запрос. Защита: одинаковый строгий парсер на всём пути, отклонение сообщений с обоими заголовками, HTTP/2 до бэкенда. Подробности — в OWASP Top 10.
Hop-by-hop против end-to-end. Connection, Keep-Alive, Transfer-Encoding, Upgrade, Proxy-Authenticate, TE относятся к одному участку и обязаны удаляться каждым прокси. Всё остальное едет насквозь. Именно поэтому в HTTP/2, где соединение мультиплексировано, Connection вообще запрещён — его наличие обязано порождать ошибку протокола. Ошибка «мы просто проксируем все заголовки как есть» ломает keep-alive и Upgrade для WebSocket (реальное время).
Информация о пути. Прокси добавляют Via, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host или стандартизованный Forwarded (RFC 7239). Правило безопасности: доверять X-Forwarded-For можно только от своих прокси и только последнему добавленному значению — иначе любой клиент подделает свой IP и обойдёт rate limiting. В nginx это set_real_ip_from + real_ip_header, детали — в прокси и балансировке.
Диапазоны. Range: bytes=1048576- + ответ 206 Partial Content с Content-Range — на этом держатся докачка, перемотка видео и параллельная загрузка кусками. Сервер объявляет поддержку через Accept-Ranges: bytes. Условный вариант If-Range защищает от докачки изменившегося файла.
Expect: 100-continue. Клиент шлёт заголовки без тела и ждёт 100 Continue, прежде чем заливать 2 ГБ. Позволяет серверу отбить запрос по авторизации или размеру, не приняв ни байта тела. curl включает это сам для больших POST, что иногда даёт загадочную секундную паузу с серверами, которые не отвечают 100.
Кэширование: самая недооценённая часть HTTP
Самый быстрый запрос — тот, которого не было. Модель кэширования HTTP (RFC 9111) работает одинаково в браузере, в CDN, в nginx и в вашем requests-клиенте с кэш-адаптером, потому что она описана в самом протоколе, а не в конфиге.
Жизненный цикл записи в кэше
Арифметика свежести, которую стоит держать в голове:
freshness_lifetime = s-maxage # только для общих кэшей
иначе -> max-age
иначе -> Expires - Date
иначе -> эвристика: (Date - Last-Modified) / 10 # если явного нет ничего
current_age = Age (сколько ответ пролежал в вышестоящих кэшах)
+ (now - Date)
свежий, пока current_age < freshness_lifetime
Эвристика — причина «мы ничего не настраивали, а страница залипла»: если ответ несёт Last-Modified годовой давности и не несёт Cache-Control, кэш имеет полное право держать его больше месяца. Отсутствие заголовков кэширования — это не «не кэшировать», это «решай сам».
Директивы, которые нужно знать наизусть
| Директива | Кто слушает | Что делает |
|---|---|---|
max-age=N |
все | свежесть N секунд |
s-maxage=N |
только общие (CDN, прокси) | переопределяет max-age для них |
no-cache |
все | можно хранить, но перед каждым использованием ревалидировать |
no-store |
все | не сохранять вообще нигде — для персональных данных |
private |
общие | «только браузер»; CDN обязан не сохранять |
public |
общие | можно кэшировать даже при Authorization |
must-revalidate |
все | протухло — запрещено отдавать stale, лучше 504 |
immutable |
браузер | не ревалидировать даже по F5 |
stale-while-revalidate=N |
CDN, браузер | отдать протухшее мгновенно и обновить в фоне |
stale-if-error=N |
CDN | ориджин упал — отдавать протухшее вместо ошибки |
Самая частая путаница: no-cache не запрещает кэширование, он требует ревалидации. Запрещает — no-store. Приватную страницу с no-cache CDN сохранит на диск совершенно законно.
Две рабочие стратегии
Иммутабельные ассеты + версия в имени файла. app.7f3a91c.js меняет имя при каждой сборке, поэтому его можно кэшировать вечно:
Cache-Control: public, max-age=31536000, immutable
Динамический HTML/API — короткая свежесть плюс ревалидация:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
ETag: W/"8812-v3"
Vary: Accept-Encoding, Accept-Language
Здесь браузер каждый раз спрашивает, CDN держит минуту, а следующие пять минут отдаёт слегка протухшее мгновенно и обновляет в фоне. Для трафика с пиками это разница между 5% и 95% попаданий в кэш.
Ревалидация в реальном выводе
# 1. Получаем валидатор
curl -sI https://example.com/ | grep -i -E 'etag|last-modified|cache-control|age'
Cache-Control: max-age=604800
ETag: "3147526947+gzip"
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Age: 219
# 2. Условный запрос — тело не приедет, если ничего не изменилось
curl -s -o /dev/null -w '%{http_code} %{size_download} байт\n' \
-H 'If-None-Match: "3147526947+gzip"' https://example.com/
304 0 байт
Ноль байт тела вместо 1256 — и это на статической странице; на списке из тысячи заказов экономия на порядки. W/ в начале ETag означает слабый валидатор: «семантически то же самое», допускается различие в байтах (например, разный порядок полей или timestamp генерации). Сильный ETag обязателен для Range и If-Match.
If-Match — оптимистическая блокировка бесплатно. Клиент читает GET /orders/8812 → ETag: "v7", затем шлёт PUT с If-Match: "v7". Если кто-то успел изменить — 412 Precondition Failed вместо потерянного обновления. Это lost update problem, решённая на уровне протокола, без единой строчки в бизнес-коде.
Ключ кэша и Vary — где всё ломается
Ключ кэша по умолчанию: метод + полный URI (включая порядок query-параметров как есть) + значения заголовков из Vary. Отсюда три реальных инцидента:
?utm_source=...создаёт отдельную запись для каждой рекламной метки — попадания падают до нуля. Лечится нормализацией ключа на CDN.Vary: User-Agentфрагментирует кэш на десятки тысяч записей (User-Agent почти уникален). Никогда так не делайте; для мобильной версии — отдельный URI илиVary: Sec-CH-UA-Mobile.Vary: Cookieзабыли, а ответ содержит имя пользователя. Первый вошедший пользователь «прогревает» CDN своей персональной страницей, остальные получают её же. Правильно: персональные ответы —Cache-Control: private, no-store.
Соединения и эволюция версий
HTTP/1.1 и цена соединения
До 1.1 каждый запрос = новое TCP-соединение = рукопожатие + slow start. Страница из 40 картинок стоила 40 рукопожатий. Connection: keep-alive (в 1.1 — поведение по умолчанию) позволил переиспользовать соединение, и это до сих пор самая окупаемая оптимизация в любом HTTP-клиенте.
Попытка номер два — pipelining: слать несколько запросов не дожидаясь ответов. Провалилась: ответы обязаны возвращаться строго в порядке запросов, поэтому один медленный ответ блокирует все следующие (head-of-line blocking на уровне приложения), а половина прокси реализовала это с ошибками. Браузеры выключили pipelining и вместо этого стали открывать до 6 соединений на хост — отсюда родились хаки вроде доменного шардинга (img1.example.com, img2.example.com), которые в эпоху HTTP/2 стали вредными.
Проверить, переиспользуется ли соединение, можно так:
curl -sv -o /dev/null https://example.com/ https://example.com/robots.txt 2>&1 \
| grep -E 'Connected to|Re-using|Connection #'
* Connected to example.com (93.184.216.34) port 443
* Connection #0 to host example.com left intact
* Re-using existing connection with host example.com
Re-using existing connection — второй запрос сэкономил TCP- и TLS-рукопожатие целиком, то есть 2 RTT. Если этой строки нет в вашем сервисе — ищите Connection: close от сервера или клиент, создающий новый пул на каждый вызов (классика в Go: http.Client без переиспользования, в Python: requests.get вместо requests.Session).
HTTP/2: мультиплексирование и его границы
HTTP/2 берёт те же сообщения и режет их на фреймы: заголовок фрейма 9 байт (длина 24 бита, тип 8, флаги 8, идентификатор потока 31), затем полезная нагрузка. Типы, которые видно в дампе: HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, GOAWAY, PING. Каждый запрос-ответ живёт в своём потоке с собственным идентификатором (клиентские нечётные, серверные чётные), и фреймы разных потоков свободно чередуются в одном TCP-соединении.
curl -sv --http2 https://example.com/ -o /dev/null 2>&1 | grep -E 'ALPN|HTTP/2|^[<>]'
* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: example.com]
* [HTTP/2] [1] [:path: /]
> GET / HTTP/2
> Host: example.com
>
< HTTP/2 200
< content-type: text/html; charset=UTF-8
< cache-control: max-age=604800
Что здесь читается. Версию выбирает ALPN внутри TLS-рукопожатия, а не отдельный запрос — переход на h2 стоит ноль дополнительных RTT. Псевдозаголовки :method, :scheme, :authority, :path заменили стартовую строку и Host; они всегда идут первыми и всегда в нижнем регистре — имена заголовков в HTTP/2 обязаны быть строчными, заглавная буква = ошибка протокола. Строка > GET / HTTP/2 — выдумка curl для читаемости, в проводе такого текста нет.
HPACK (RFC 7541) сжимает заголовки статической таблицей из 61 записи (:method: GET — это один байт), динамической таблицей повторяющихся полей и кодированием Хаффмана. На типичном запросе с куками и длинным User-Agent это 800 байт → 30-50 байт на повторных запросах. Побочный эффект: динамическая таблица — общее состояние соединения, из-за чего атака CRIME/BREACH на сжатие потребовала запрета сжимать секреты вместе с пользовательским вводом.
Чем платим:
- HOL blocking переехал в TCP. Потерянный сегмент останавливает доставку данных всех потоков, потому что ядро не отдаёт байты с дырой. На канале с 2% потерь HTTP/2 может быть медленнее, чем шесть отдельных соединений HTTP/1.1. Это и есть главная причина появления HTTP/3.
- Поточное окно 64 КБ по умолчанию.
SETTINGS_INITIAL_WINDOW_SIZE= 65535 байт на поток. На канале 100 Мбит/с с RTT 100 мс это ограничивает один поток примерно 5 Мбит/с независимо от TCP. Реальный симптом: «скачивание большого файла по h2 медленнее, чем по h1.1». Лечится увеличением окна на сервере (http2_body_preread_sizeи настройки h2 в nginx,InitialWindowSizeв Go). - Server Push мёртв. Идея «отдать CSS до того, как браузер о нём спросил» на практике дублировала уже закэшированные ресурсы. Chrome выключил её в 2022 году; замена —
103 Early HintsсLink: </app.css>; rel=preload, где решение остаётся за браузером. - Приоритеты из RFC 7540 провалились — дерево зависимостей никто не реализовал одинаково. Заменены простой схемой RFC 9218 (Extensible Prioritization) с заголовком
Priority: u=3, i.
Отдельный практический нюанс: шардинг доменов и склейка спрайтов при h2 вредны. Разные домены = разные соединения = потерянное мультиплексирование и повторные рукопожатия. Оптимизации 2012 года нужно откатывать, а не накапливать.
HTTP/3: тот же HTTP поверх QUIC
HTTP/3 — это HTTP/2 без TCP. Потоки, мультиплексирование и сжатие заголовков переехали в QUIC, который знает границы потоков и доставляет каждый независимо, поэтому потеря пакета тормозит только пострадавший поток. HPACK заменён на QPACK (RFC 9204): в HPACK ссылка на динамическую таблицу требовала строгого порядка, что при независимой доставке невозможно, поэтому QPACK разнёс инструкции таблицы по отдельным однонаправленным потокам.
Что получаем сверх этого: 1 RTT на установку соединения вместе с шифрованием (TLS 1.3 вшит в QUIC) и 0 RTT при возобновлении — но 0-RTT данные уязвимы к replay, поэтому туда разрешено класть только безопасные идемпотентные запросы. Миграция соединения по Connection ID означает, что переход Wi-Fi → LTE не рвёт загрузку.
Обнаружение — через заголовок в ответе по HTTP/1.1 или /2:
curl -sI https://cloudflare.com/ | grep -i alt-svc
alt-svc: h3=":443"; ma=86400
«Тот же хост доступен по h3 на 443/UDP, помни об этом сутки». Первый запрос всегда идёт по TCP; на h3 браузер переключается со второго. Проверить принудительно:
curl -sI --http3-only https://cloudflare.com/ -o /dev/null \
-w 'версия=%{http_version} код=%{response_code} total=%{time_total}\n'
версия=3 код=200 total=0.121
Чем платим: UDP на 443 порту режут некоторые корпоративные фаерволы (нужен рабочий fallback на TCP, и он есть), CPU на пакет выше, чем у TCP с аппаратной разгрузкой на сетевой карте, а инструменты отладки моложе и грубее.
Сводка компромиссов
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Формат | текст | бинарные фреймы | бинарные фреймы |
| Параллелизм | ~6 соединений на хост | потоки в одном TCP | потоки в одном QUIC |
| Сжатие заголовков | нет | HPACK | QPACK |
| HOL blocking | на уровне запросов | на уровне TCP | практически нет |
| RTT до первого байта | 1 (TCP) + 1-2 (TLS) + 1 | то же | 1 (QUIC+TLS) + 1, или 0-RTT |
| Шифрование | опционально | де-факто обязательно | обязательно |
| Смена сети | разрыв | разрыв | миграция без разрыва |
| Отладка | тривиальная | нужен keylog | нужен keylog и свежие инструменты |
Что видно в Wireshark и tcpdump
Открытый HTTP/1.1. Фильтр отображения http показывает разобранные запросы; правый клик → Follow → HTTP Stream склеивает обе стороны в читаемый диалог. Полезные фильтры: http.response.code >= 400, http.time > 1 (время от запроса до ответа, Wireshark считает его сам), http.request.uri contains "/api/".
Без GUI тот же результат даёт классический фильтр «только пакеты с полезной нагрузкой»:
# Отбрасываем чистые ACK: длина IP минус заголовки IP и TCP != 0
sudo tcpdump -ni any -A -s 0 \
'tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)'
09:04:11.101233 IP 10.0.0.12.51234 > 93.184.216.34.80: Flags [P.], seq 1:78, length 77
E..u..@.@..............P....
GET / HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0
Потеря пакета глазами Wireshark. TCP-анализ подсвечивает её сам: [TCP Dup ACK] (получатель повторяет подтверждение, видя дыру), затем [TCP Retransmission] или [TCP Fast Retransmission]. Признак «HTTP тормозит из-за сети, а не из-за бэкенда»: между последним фреймом HEADERS и первым DATA — секунды, заполненные Dup ACK. Полезные фильтры: tcp.analysis.retransmission, tcp.analysis.zero_window (получатель захлебнулся — виновато приложение, а не сеть), tcp.analysis.ack_rtt > 0.2. График Statistics → TCP Stream Graphs → Time Sequence (tcptrace) показывает лесенку передачи с провалами на ретрансмиссиях нагляднее любых цифр.
HTTP/2 и /3 внутри TLS. Без ключей это просто зашифрованные байты. Решение — переменная окружения, которую понимают браузеры и curl:
export SSLKEYLOGFILE=/tmp/tls-keys.log
curl -s --http2 https://example.com/ -o /dev/null
# Wireshark: Preferences → Protocols → TLS → (Pre)-Master-Secret log filename → /tmp/tls-keys.log
После этого фильтр http2 покажет фреймы, http2.type == 1 — только HEADERS, http2.streamid == 5 — конкретный поток, quic и http3 — то же для HTTP/3. Ключи логировать только на тестовых стендах: файл позволяет расшифровать весь трафик.
Быстрая альтернатива без дампов — тайминги curl, которые отвечают на вопрос «где именно ушло время»:
curl -sS -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} ver=%{http_version} size=%{size_download}\n' \
https://example.com/
dns=0.014 tcp=0.042 tls=0.089 ttfb=0.151 total=0.152 ver=2 size=1256
Читается по разностям: DNS 14 мс, TCP-рукопожатие 28 мс, TLS 47 мс, «сервер думал» 62 мс, скачивание 1 мс. Если ttfb - tls огромен — проблема в бэкенде, а не в сети. Если total - ttfb огромен — узкая полоса или медленный стриминг. Методика в целом — в диагностике сети.
Путь реального запроса
If-None-Match: W/"v7" Note over E: ключ кэша = URI + Vary(Accept-Encoding) alt Свежая запись на edge E-->>B: 200 (HIT), age: 42 else Протухла, но есть stale-while-revalidate E-->>B: 200 (STALE) мгновенно E->>L: фоновая ревалидация, HTTP/2 L->>A: GET /catalog HTTP/1.1
X-Forwarded-For, X-Request-Id A->>D: SELECT ... D-->>A: строки A-->>L: 200, ETag W/"v8", Cache-Control s-maxage=60 L-->>E: обновлённое представление else Промах E->>L: GET /catalog
If-None-Match: W/"v7" L->>A: то же A-->>L: 304 Not Modified L-->>E: 304 E-->>B: 200 из своей копии (REVALIDATED) end Note over B,D: один пользовательский запрос — до трёх разных кэшей и минимум три версии HTTP на пути
Из этой схемы следуют вещи, которые постоянно кусают в проде.
Версия HTTP разная на каждом участке. Браузер ↔ CDN может быть h3, CDN ↔ балансировщик — h2, балансировщик ↔ приложение — h1.1 по unix-сокету. Это нормально: семантика одна, транслировать легко. Но именно поэтому нельзя закладываться на «у нас HTTP/2 везде»: логика приложения обязана работать одинаково при любой версии.
X-Request-Id (или traceparent из W3C Trace Context) прокидывайте с самого края. Без сквозного идентификатора расследование инцидента превращается в сопоставление таймстемпов; см. наблюдаемость.
Заголовки диагностики кэша стоит логировать. X-Cache: HIT/MISS, Age, cf-cache-status, x-served-by — по ним видно долю попаданий по URL, а это самый дешёвый источник ускорения. Подробности раздачи — в CDN и edge.
Инвалидация — не часть HTTP. Протокол умеет только истечение и ревалидацию. PURGE — расширение конкретных реализаций (Varnish, nginx plus, API конкретного CDN). Поэтому иммутабельные URL с хешем в имени почти всегда лучше, чем любая схема активной инвалидации.
Мини-практика: клиент, который не выстрелит в ногу
import httpx # httpx умеет HTTP/2 и переиспользует соединения
# Один клиент на процесс: пул соединений — главная оптимизация HTTP-клиента.
# Создание клиента на каждый запрос убивает keep-alive и стоит 2 RTT сверху.
client = httpx.Client(
http2=True,
timeout=httpx.Timeout(connect=2.0, read=10.0, write=10.0, pool=1.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
headers={"user-agent": "orders-sync/1.4"},
)
def fetch_catalog(etag: str | None) -> tuple[int, bytes | None, str | None]:
"""Условный GET: при отсутствии изменений сервер вернёт 304 без тела."""
headers = {"if-none-match": etag} if etag else {}
r = client.get("https://api.example.com/v1/catalog", headers=headers)
if r.status_code == 304:
return 304, None, etag # тело не приехало — экономия трафика
r.raise_for_status()
return r.status_code, r.content, r.headers.get("etag")
def create_order(payload: dict, idem_key: str) -> httpx.Response:
"""POST не идемпотентен по протоколу — идемпотентность делаем ключом.
Сервер обязан вернуть тот же ответ на повтор с тем же ключом."""
return client.post(
"https://api.example.com/v1/orders",
json=payload,
headers={"idempotency-key": idem_key},
)
Четыре вещи, которые здесь сделаны намеренно: один клиент (пул), раздельные таймауты (connect короткий, read длинный — иначе медленный бэкенд выглядит как недоступная сеть), условный GET и ключ идемпотентности вместо надежды на ретраи. Аналог на Go — один http.Client с настроенным Transport и обязательным resp.Body.Close(), иначе соединение не вернётся в пул; на Node — undici с Agent.
Типичные ошибки
POSTдля чтения. Убивает кэширование, ломает ретраи и логи. Если данных для GET слишком много — это признак плохого API, а не повод менять метод.- Считать
no-cacheзапретом на кэширование. Запрещаетno-store;no-cacheлишь требует ревалидации. - Забыть
Vary. Самый дорогой баг в этой статье: персональный или локализованный ответ уезжает чужим пользователям через общий кэш. Vary: User-AgentилиVary: *. Фрагментация кэша до нуля попаданий.200 OKс телом{"error": ...}. Ломает всю экосистему: прокси кэшируют ошибку, метрики показывают здоровье, клиентские библиотеки не бросают исключение.- Ретрай
POSTвслепую. Без ключа идемпотентности это двойное списание при первом же таймауте. 301вместо302на эксперименте. Кэшируется практически навсегда, откатить у уже зашедших пользователей нельзя.- Заголовки без
Retry-Afterпри429и503. Клиенты уходят в плотный цикл ретраев и добивают сервис. - Доверять
X-Forwarded-Forцеликом. Любой клиент подставит туда что угодно и обойдёт лимиты и гео-логику. - Одновременно
Content-LengthиTransfer-Encoding. Request smuggling; строгие парсеры на всех участках обязательны. - Создавать HTTP-клиент на каждый запрос. Потеря keep-alive: 2 лишних RTT и исчерпание эфемерных портов под нагрузкой.
- Оставлять шардинг доменов и спрайты после перехода на h2/h3. Оптимизации HTTP/1.1 превращаются в антипаттерны.
- Считать HTTP/2 автоматическим ускорением. Без исправленных поточных окон и при потерях он бывает медленнее 1.1; измеряйте, а не верьте.
- Пропускать
Connection,Upgrade,TEчерез прокси. Hop-by-hop заголовки обязаны срезаться, иначе ломается WebSocket и keep-alive.
Мини-итог
- HTTP — протокол представлений ресурсов, спроектированный вокруг посредников; отсюда текстовость, богатые заголовки и подробная модель кэширования.
- Метод — это контракт из трёх свойств: safe, idempotent, cacheable. На них опираются браузеры, прокси и ретраи, и нарушение контракта ломает не ваш код, а всю цепочку.
- Код ответа — интерфейс для машин:
401vs403,400vs422,307/308вместо301/302там, где нужно сохранить метод,Retry-Afterпри429и503. - Границы тела задаёт ровно один механизм —
Content-Lengthилиchunked; расхождение парсеров даёт request smuggling. - Кэширование — самая недооценённая часть протокола:
s-maxage+stale-while-revalidate+ETag+ правильныйVaryдают больше, чем любая оптимизация кода.no-cacheне запрещает хранение, запрещаетno-store. - HTTP/2 убрал HOL blocking приложения, но не TCP; HTTP/3 убрал и его, переехав на QUIC, ценой UDP-фильтров и более дорогих пакетов.
- Семантика одинакова во всех версиях — переход на h2/h3 это вопрос конфигурации фронта, а не переписывания кода.
- Отладка:
curl -wс таймингами отвечает «где ушло время»,curl -v— «какие заголовки реально уехали»,tcpdumpи Wireshark сSSLKEYLOGFILE— «что именно было в проводе».
Источники
- RFC 9110 — HTTP Semantics, RFC 9111 — Caching, RFC 9112 — HTTP/1.1, RFC 9113 — HTTP/2, RFC 9114 — HTTP/3.
- RFC 7541 — HPACK, RFC 9204 — QPACK, RFC 9218 — Extensible Prioritization, RFC 9457 — Problem Details, RFC 5861 — stale-while-revalidate.
- Ilya Grigorik. High Performance Browser Networking — главы про HTTP/1.x и HTTP/2, бесплатно онлайн.
- Daniel Stenberg. HTTP/3 explained и everything curl — от автора curl.
- Roy Fielding. Architectural Styles and the Design of Network-based Software Architectures — диссертация, из которой выросли и HTTP/1.1, и REST.
- MDN: HTTP — лучший справочник по конкретным заголовкам и кодам.
- Mark Nottingham. Caching Tutorial и блог — практика кэширования от редактора RFC 9111.
- PortSwigger: HTTP request smuggling — лучший разбор классов CL.TE и TE.CL с лабораториями.
- Google Web Fundamentals. HTTP caching — практические рецепты для фронтенда; см. также веб-производительность.
Что дальше
Всё, что мы разбирали, ехало открытым текстом или «внутри TLS», который мы аккуратно обходили стороной: openssl s_client с -servername, ALPN, выбирающий h2, SSLKEYLOGFILE для расшифровки дампа. Пора открыть этот чёрный ящик — тем более что без него сегодня не работают ни HTTP/2, ни HTTP/3.
TLS и HTTPS: рукопожатие, сертификаты, цепочки доверия, отладка — про то, из чего состоит рукопожатие и сколько RTT стоит, как устроены цепочки доверия и почему «сертификат валиден в браузере, но не в curl», и как читать вывод openssl и Wireshark, когда соединение падает.