Бюджет задержки и выбор транспорта: highload под SLA < 300 мс

Из чего складывается бюджет 300 мс, почему Kafka не место в синхронном пути, HTTP/1.1 против HTTP/2 и когда WebSocket не нужен — на сквозном кейсе 2000–5000 rps

Возьмём типичную задачу highload-сервиса: на вход прилетает поток запросов — JSON примерно 8 КБ, 2000–5000 запросов в секунду. На каждый запрос нужно выполнить синхронную проверку, которая сама по себе занимает 100–200 мс. При этом весь путь запроса — от клиента до ответа и обратно — обязан уложиться в SLA < 300 мс end-to-end.

Цифры выглядят безобидно по отдельности, но вместе они образуют жёсткие рамки: бюджет на всё, что не является самой проверкой, — считаные десятки миллисекунд. Любое случайное архитектурное решение — лишний синхронный hop, неудачно выбранный транспорт, самодельный протокол поверх WebSocket — либо не влезает в бюджет, либо съедает запас, который был нужен на скачки нагрузки.

Эта статья открывает серию perf-lowlatency и задаёт для неё общий словарь: как раскладывать бюджет задержки на составляющие, почему число одновременных запросов (concurrency) важнее полосы пропускания, где в этой картине место Kafka и какой транспорт выбрать между клиентом и обработчиком. Дальше по тексту разбор идёт по одной и той же триаде: ✅ правильно / ❌ неправильно / ⚠️ неоптимально — чтобы сразу было видно, какое решение куда попадает.

Бюджет времени ответа: сегментированная шкала 300 мс с индикатором у порога и выносом части потока в асинхронный буфер

Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример performance/highload-lowlatency: HAProxy L7 + пул Go/Java-бэкендов + клиент-нагрузчик, docker compose up. По ходу статей будем ссылаться на его конфиги и код.

В статье

Бюджет 300 мс на салфетке

Первый шаг в любом расчёте latency-бюджета — разложить общий SLA на составляющие, прежде чем оптимизировать хоть что-то. Для нашего кейса слагаемые примерно такие: сетевой RTT между клиентом и сервисом, TLS-handshake, парсинг входящего JSON на ~8 КБ, сама синхронная проверка (100–200 мс), сериализация ответа и обратный сетевой путь.

TLS-handshake — не разовая константа, а решение, которое либо принято правильно, либо нет. Если каждое соединение поднимает TLS заново, это добавляет заметные миллисекунды на каждый запрос при 2000–5000 rps. Правильный путь — переиспользование соединений (keep-alive, connection pooling), тогда handshake амортизируется на множество запросов и почти не участвует в бюджете отдельного вызова. Это первая архитектурная развилка, и её легко упустить, если проектировать «запрос за запросом», а не поток.

Дальше — парсинг 8 КБ JSON и сериализация ответа. Сами по себе это операции на доли миллисекунды при разумной реализации, но при 2000–5000 rps суммарная нагрузка на CPU от парсинга/сериализации становится заметной статьёй бюджета, особенно если сериализация сделана наивно (рефлексия, лишние аллокации, не переиспользуемые буферы).

Если вычесть из 300 мс синхронную проверку в 200 мс (худший случай из диапазона 100–200 мс), на всё остальное — сеть, TLS, парсинг, сериализацию, обратный путь — остаётся около 100 мс. Это очень небольшой запас: любой лишний hop в синхронном пути (ещё один сервис, очередь, dns-резолв без кеша) грозит выходом за SLA. Отсюда прямое следствие для всей серии: бюджет нужно защищать архитектурно, а не надеяться, что «в среднем уложимся».

flowchart LR A["RTT + TLS
(амортизируется keep-alive)"] --> B["парсинг JSON 8 КБ"] B --> C["проверка 100–200 мс
(основная часть)"] C --> D["сериализация ответа"] D --> E["обратный путь"]

flowchart LR
  A["RTT + TLS
(амортизируется keep-alive)"] --> B["парсинг JSON 8 КБ"] B --> C["проверка 100–200 мс
(основная часть)"] C --> D["сериализация ответа"] D --> E["обратный путь"]
Бюджет времени ответа при SLA < 300 мс

Узкое место — concurrency, а не полоса

Первый инстинкт при виде 2000–5000 rps — посчитать нагрузку на сеть. Прикидка простая: 8 КБ × 5000 rps ≈ 40 МБ/с входящего трафика (без учёта TLS-оверхеда, заголовков и трафика ответов). Это заметная, но совершенно посильная для современной сети и NIC цифра — сама по себе полоса пропускания не является узким местом этого кейса.

Настоящий риск в другом месте — в количестве одновременно обрабатываемых запросов. Пока проверка одного запроса занимает 100–200 мс, а новые запросы прилетают с частотой 2000–5000 rps, в системе одновременно находится не один запрос, а тысяча с лишним. Это прямое следствие закона Литтла (Little’s law): среднее число объектов в системе равно произведению скорости их поступления на среднее время пребывания в системе.

Для верхней границы кейса: 5000 rps × 0.2 s = 1000 одновременных проверок. Это значит, что сервис должен уметь стабильно держать порядка 1000 параллельно исполняющихся обработчиков (потоков, горутин, файберов — в зависимости от рантайма) плюс разумный запас на пики и на то, что латентность проверки не всегда строго 200 мс. Если пул обработчиков рассчитан на десятки или сотни, а не на тысячу с запасом, система начнёт либо копить очередь (и терять SLA), либо отклонять запросы, хотя формально «канал не перегружен».

Отсюда вывод, важный для всей серии: проектировать нужно вместимость (capacity) обработчиков под конкретное число in-flight запросов, а не полосу канала. Пул соединений к бэкендам, размер worker-pool, лимиты на конкурентные горутины/потоки — всё это должно быть явно посчитано от rps × latency, а не подобрано на глаз.

in-flight ≈ rps × latency
5000 rps × 0.2 s = 1000 одновременных проверок
→ пул обработчиков на 1000 in-flight + запас

Kafka: не в синхронном пути

Kafka — отличный инструмент, но не универсальный молоток для любого «нужно передать сообщение». В синхронном пути с бюджетом 300 мс, где на всё, кроме проверки, остаётся около 100 мс, Kafka как обязательный hop перед ответом — прямая дорога к нарушению SLA.

  • неправильно — встраивать Kafka как обязательный шаг перед ответом клиенту: producer → broker → consumer → обработка → обратный путь. Каждое из этих звеньев добавляет задержку и, что хуже, делает её непредсказуемой — брокер может подтормозить под собственной нагрузкой, consumer может быть занят другим партиционированием. Тот скромный запас в ~100 мс, который у нас есть, здесь съедается почти гарантированно, а сама задержка становится не худшим случаем, а лотереей.
  • правильно — Kafka остаётся для того, для чего она хороша: audit-логи, асинхронные события для других систем, повторная обработка, аналитика, dead-letter queue (DLQ) для проверок, которые не удалось выполнить с первого раза, фоновые проверки, не блокирующие ответ. Синхронный путь при этом выглядит просто: клиент → обработчик → проверка → ответ, а событие в Kafka отправляется параллельно — fire-and-forget или уже после того, как ответ ушёл клиенту.
  • ⚠️ неоптимально / компромисс — если бизнес-логика в принципе допускает отложенный ответ (не наш случай с жёстким SLA < 300 мс, но частый смежный сценарий), вариант — вернуть 202 Accepted с request_id сразу, а результат проверки отдавать поллингом статуса или через callback/push. Это честный асинхронный контракт, а не Kafka «под капотом» синхронного вызова с иллюзией мгновенного ответа.
flowchart LR C["Клиент"] --> H["Обработчик"] H --> V["Проверка 100–200 мс"] V --> R["Ответ клиенту"] H -.->|"async: audit / events / DLQ"| K["Kafka"]

flowchart LR
  C["Клиент"] --> H["Обработчик"]
  H --> V["Проверка 100–200 мс"]
  V --> R["Ответ клиенту"]
  H -.->|"async: audit / events / DLQ"| K["Kafka"]
Kafka вне синхронного пути ответа

HTTP/1.1 против HTTP/2

Выбор транспорта между клиентом и обработчиком напрямую завязан на то, сколько соединений и как они переиспользуются под 1000+ одновременных запросов.

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

HTTP/2 решает это иначе: множество независимых streams мультиплексируются в одном TCP-соединении. Клиенту не нужно открывать сотни сокетов, чтобы держать сотни параллельных запросов — они едут в одном соединении, TLS-handshake амортизируется на всё время жизни соединения, keep-alive работает естественным образом. Для нашего кейса с ~1000 одновременных проверок это ровно то, что нужно: REST или gRPC поверх HTTP/2 позволяют держать нужный уровень конкурентности без взрывного роста числа соединений.

Критерий HTTP/1.1 HTTP/2
Соединения на клиента много параллельных TCP-соединений одно соединение, много streams
Head-of-line blocking да, на уровне соединения нет на уровне HTTP (возможен на уровне TCP)
Keep-alive / TLS-амортизация работает, но соединений больше естественна, соединений меньше
Пригодность под ~1000 in-flight дорого (дескрипторы, handshake) подходит по умолчанию

Стоит сразу оговориться: то, как HTTP/2-streams реально распределяются по бэкендам на уровне балансировщика (L4 против L7, поведение HAProxy/Angie с h2), — отдельная и важная тема, которая заслуживает собственного разбора. Ей посвящена следующая статья серии: балансировка L4/L7 и HTTP/2.

WebSocket: когда не нужен

WebSocket — это постоянное соединение с двусторонним обменом сообщениями, но сам по себе он не ускоряет обработку запроса. Проверка не станет быстрее оттого, что транспорт — WebSocket, а не HTTP/2: 100–200 мс работы остаются 100–200 мс работы независимо от протокола доставки.

  • неправильно — городить самодельный multiplexing поверх одного WebSocket-соединения (свои message-id, свою очередь ответов, свою корреляцию запрос/ответ), чтобы «параллелить» запросы вручную. Это фактически повторное изобретение того, что HTTP/2 уже даёт из коробки, только без готовых библиотек, инструментов отладки и стандартной поддержки на стороне инфраструктуры (балансировщики, прокси, observability).
  • правильно — WebSocket оправдан там, где нужен именно его профиль: постоянное соединение с обеих сторон, много мелких сообщений, и, что особенно важно, серверу нужно самому инициировать push результата клиенту без отдельного опроса. Для классической схемы request → validate → response, где инициатор всегда клиент, этот профиль не нужен.

Для нашего сквозного кейса модель request → validate → response уже полностью закрывается HTTP/2: мультиплексирование даёт нужную конкурентность, keep-alive убирает накладные расходы на установление соединений. Вводить WebSocket означает добавлять инфраструктурную сложность (управление состоянием соединений, reconnect-логика, балансировка stateful-соединений) без выигрыша в задержке или пропускной способности.

Короткий checklist

  1. Посчитан ли бюджет 300 мс по слагаемым — и известно, где сидит основная доля (в нашем случае — сама проверка, 100–200 мс)?
  2. Посчитано ли ожидаемое число одновременных запросов по формуле rps × latency, и рассчитан ли пул обработчиков под эту цифру с запасом?
  3. Нет ли в синхронном пути ответа лишних hop’ов (Kafka, дополнительные синхронные вызовы), которые не обязаны там быть?
  4. Переиспользуются ли соединения (keep-alive) и амортизирован ли TLS-handshake, а не выполняется на каждый запрос заново?
  5. Транспорт между клиентом и обработчиком — HTTP/2 (или gRPC поверх HTTP/2), а не набор параллельных HTTP/1.1-соединений или самодельный multiplexing поверх WebSocket?
  6. Всё, что не влияет напрямую на ответ клиенту (audit, аналитика, DLQ), вынесено в асинхронный путь?

Что дальше

Мы разложили бюджет, посчитали concurrency и выбрали транспорт на уровне протокола. Следующий шаг — что происходит с этими HTTP/2-соединениями и их streams на уровне балансировщика: чем отличается L4-балансировка от L7, как HAProxy и подобные прокси распределяют нагрузку между бэкендами при мультиплексировании. Это разбирает вторая статья серии: балансировка L4/L7 и HTTP/2.

См. также

Обсуждение в Telegram

Присоединиться →

Комментарии