Разгоняем сервис до 1M RPS: с чего начинается производительность

Оглавление

Введение

Когда в статье написано, что система “держит 1M RPS”, а рядом нет ни размера ответа, ни latency, ни уровня ошибок, ни описания стенда, ни состояния кэша, ни правил подсчета успешных запросов, то инженерной ценности у такой цифры почти нет. Пропускная способность системы сам по себе не говорит, держит ли система реальный пользовательский трафик, соблюдает ли SLO и не ломается ли при откате, деградации Redis или замедлении базы. Авторитетные издания рекомендуют строить SLO вокруг пользовательского опыта, измерять долю “успешных событий” к “общему количеству событий” и использовать “error budget” как инструмент инженерных решений, а не KPI ради KPI. Важно, что закрытая модель нагрузки может сама “прятать” деградацию системы, если генератор ждет завершения предыдущих итераций. 

В этом цикле мы не говорим о том, что 1M RPS – это свойство языка, фреймворка или удачного флага JVM. В рамках серии мы последовательно построим высоконагруженную систему, заточенную на чтение, сначала на Java/Spring Boot, затем на Go, доведем ее до ПРОД стенда, проверим под нагрузкой, под отказами и под релизами, и только потом будем сравнивать стеки. То есть предметом исследования станет не “быстрый ендпоинт”, а вся система: API, кэш, база, сеть, наблюдаемость, тестовая методика, Kubernetes и эксплуатационные компромиссы. По моему мнению, это и есть правильный базис для разговора про миллион запросов в секунду. 

Почему 1M RPS без условий ничего не значит

Проблема большинства материалов про “миллион RPS” в том, что они начинают не с постановки эксперимента, а с цифры в заголовке (в целом, как и этот материал). Дальше выясняется, что речь шла про один горячий GET ендпоинт без TLS, без базы, без кэша второго уровня, без наблюдаемости, с “игрушечным” телами запроса, после полного прогрева и иногда даже без честного отделения успешных ответов от ошибок. На практике это не ответ на бизнес-нагрузку это микро-бенчмарк, который по дороге к ПРОДу способен развалиться на:

  • p99
  • CPU тротлинге
  • Hikari CP
  • балансировщике нагрузки
  • попытке сделать роллаут без потери трафика и т.д. 

Язык и фреймворк в этой ситуации всего лишь один слой из многих. 

Базово начинается разработка подобной системы довольно просто: 

  1. Сначала определяется SLI, то есть измеряемый признак качества сервиса.
  2. Затем SLO – целевой уровень этого качества
  3. Затем “error budget” – приемлемая доля “ошибочных событий”, которую система может “сгенерировать” за период. 

Всегда важно помнить, что 100% надежность – “странная” цель: она не соответствует настоящему пользовательскому опыту, дорожает с каждой дополнительной “девяткой” и практически парализует изменения, потому что главный источник инцидентов – это изменения. Это особенно важно для темы высокой нагрузки: нельзя одновременно требовать бесконечной скорости, абсолютной консистентности, нулевой ошибки и бесконечной скорости развертывания, не обсуждая цену такого контракта. 

Нагрузочное тестирование тоже легко обмануть. В закрытой модели нагрузки генератор не начинает следующую итерацию, пока не завершилась предыдущая. Если система начинает тормозить, интенсивность поступления данных падает вместе с ней, и график может выглядеть “красивее”, чем реальность. В открытой модели k6 специально разрывает связь между длительностью итерации и стартом следующей, а constant-arrival-rate запускает фиксированное число итераций в единицу времени независимо от времени ответа системы. Именно поэтому для RPS-ориентированных API открытая модель обычно полезнее закрытой модели: она лучше показывает, что случится, когда входящий поток не “пожалеет” вашу систему и не станет самодросселироваться (self-throttling). 

Есть и еще одна ловушка: координированный пропуск (coordinated omission). Традиционный генератор, который ждет ответа перед следующим измерением, занижает хвосты latency. Например, wrk2 исправляет это, измеряя latency относительно того момента, когда запрос должен был быть отправлен при заданной постоянной пропускной способности. Корректный 99-й процентиль оказывается примерно в 200 раз выше, чем в “некорректной” технике, чувствительной к координированному пропуску. Игнорирование этого момента, может сделать “красивый” p99 просто технической галлюцинацией генератора. 

Поэтому первая статья цикла не будет “разгонять код”. Ее задача – зафиксировать правила:

  • какую систему мы строим
  • какие чтения считаем в целевую пропускную способность
  • какие SLO берем как рабочий контракт
  • какую консистентность обещаем пользователю
  • как считаем успешный запрос
  • как выглядит нагрузочная модель
  • по каким правилам вообще можно будет потом сказать, что Java и Go (или любая другая технология) сравнивались честно.

Как устроен сам цикл

Ниже – тот самый маршрут, который я собираюсь пройти в серии. Он намеренно начинается не с “битвы фреймворков”, а с постановки инженерного эксперимента.

Логика здесь простая. Пока меняется сама архитектура, сравнивать языки рано. Пока не зафиксирована рабочая нагрузка, сравнивать пропускную способность бессмысленно. Пока не проверены отказы, говорить “готово к ПРОДу” нечестно. И пока не проведено измерение одного и того же контракта на одинаковом стенде, Java vs Go остается разговором в курилке, а не инженерным выводом. 

Что мы строим

Для цикла я сознательно беру не абстрактный /hello, а компактную, но правдоподобную систему с высокой нагрузкой на чтение: Offer Snapshot Service. Ее задача – вернуть актуальный snapshot торгового предложения по паре SKU + регион: 

  • цену
  • доступность
  • количество товара
  • срок доставки
  • версию данных
  • время последнего обновления. 

Это реалистичный сценарий для карточки товара, каталога, поисковой выдачи, корзины, мобильного приложения, витрин и партнерского АПИ. У такой системы действительно может быть экстремально высокое объединение нескольких входящих потоков, сигналов или задач в один (fan-in) по чтению и сравнительно небольшой трафик на запись. Это критично: 1M RPS для АПИ снапшотов данных с высокой интенсивностью чтения – тяжелая, но осмысленная цель. Нагрузка в 1 млн RPS на транзакционный путь записи с резервированием остатков – уже совсем другой класс системы.

Публичный API и JSON-контракт

Основной endpoint цикла:

GET /api/v1/offers/{sku}?region={region}

Пример:

GET /api/v1/offers/iphone-17-pro?region=moscow

Пример ответа:

{
  "sku": "iphone-17-pro",
  "region": "Moscow",
  "price": 12999000,
  "currency": "RUB",
  "available": true,
  "availableQuantity": 47,
  "deliveryDays": 1,
  "version": 18423,
  "updatedAt": "2026-07-12T18:42:31Z"
}

Я сознательно храню цену в минимальных денежных единицах, а не в double: это убирает типичные ошибки двоичной арифметики на границе сериализации, сортировки, сравнения и кэширования. Здесь важнее не “красивый JSON”, а детерминированный контракт. В системах с высокой нагрузкой это особенно полезно, потому что сериализация, равнозначность и кэш-ключи должны вести себя предсказуемо.

Что сервис делает и чего он не делает

Что входит в Offer Snapshot ServiceЧто не входит
Быстрое чтение цены и доступностиСоздание заказа
Возврат snapshot по sku + regionРезервирование товара
Возврат version и updatedAtОплата
Контролируемая eventual consistencyФинальная фиксация цены сделки
Кэширование и деградация на операциях чтенияПолноценный auth-сервер
Обновление snapshot из upstream-событийПоиск, рекомендации, промо-движок

Эта граница принципиальна. Мы формируем SLI вокруг того, что фактически видит пользователь. и отдельно приводит пример freshness SLI – доли запросов, получивших данные не старее заданного окна. Для Offer Snapshot Service это естественный критерий: пользователь хочет быстро увидеть правдоподобную цену и наличие товара, но финальная проверка перед оформлением заказа должна происходить уже в отдельном transactional контуре. То есть snapshot API можно проектировать под экстремальное чтение и контролируемую устарелость, а checkout – под более строгую корректность. 

Поля snapshot и их смысл

ПолеТипЗачем нужно
skustringИдентификатор товара
regionstringРегиональная гранулярность предложения
pricebigint в минимальных единицахДетерминированные деньги без double
currencystring(3)Валюта
availablebooleanБыстрый бизнес-флаг доступности
availableQuantityintegerПриблизительное количество для витрины
deliveryDaysintegerОценка срока доставки
versionbigintПорядок обновлений и защита от устаревших событий
updatedAttimestamptzДиагностика свежести ответа

Ключевые поля здесь – version и updatedAt. Они нужны для механики системы. version позволит упорядочивать обновления и не принимать старое событие поверх нового. updatedAt даст возможность измерять свежесть, строить отдельную SLI по свежести и объяснять, насколько “устаревшие” данные хотел или не хотел получить пользователь. Google SRE прямо пишет, что SLIs могут измерять не только “availability” и “latency”, но и “freshness”. Для нашего сервиса это не факультативная метрика, а часть будущего контракта. 

SQL-схема

Минимальная логическая модель выглядит так:

create table offers (
    sku varchar(128) not null,
    region varchar(64) not null,
    price bigint not null,
    currency varchar(3) not null,
    available boolean not null,
    available_quantity integer not null,
    delivery_days integer not null,
    version bigint not null,
    updated_at timestamptz not null,
    primary key (sku, region)
);

Ключ sku + region выбран намеренно. Один и тот же товар может стоить по-разному в разных регионах, иметь разные остатки и разные сроки доставки. Это обычная реальность распределенных торговых систем: источник хранения хранит регионально-зависимое состояние, а логика чтения возвращает уже рассчитанный snapshot, оптимизированный под чтение. PostgreSQL при этом остается источником истины, но не должен синхронно обслуживать каждый пользовательский запрос при миллионном запросов: число одновременно активных соединений и вообще сама пропускная способность БД в высоконагруженном АПИ очень быстро становятся ограничением, а PostgreSQL напрямую ограничивает одновременно активные подключения параметром max_connections. 

Целевая архитектура

Вот архитектура, к которой мы будем идти, но не с первой статьи и не за один шаг.

Сразу важно проговорить: полная архитектура появится не в первой реализации. Сначала будет Java реализация с PostgreSQL. Затем Redis. Затем локальный кэш. Затем асинхронные обновления. Затем стенд с несколькими нодами. Затем ранбуки и тестирование на отказ. Этот поэтапный путь нужен именно для корректного измерения: если я сразу навешу все слои, я не пойму, где выиграл, а где просто спрятал ботлнек за кэшем.

Чтение данных

Базовый путь чтения выглядит так:

Здесь важно не то, что схема знакомая, а то, что это три принципиально разные стоимости запроса.

  • Local cache hit – это память внутри процесса.
  • Redis hit – это уже сеть, сериализация, протокол, клиентская библиотека и отдельный сервер.
  • PostgreSQL fallback – это еще дальше: connection pool, SQL, план, дисковая и in-memory базы, плюс потенциальный коннект на самой БД.

Поэтому цифра “1M RPS” без структуры путей в нашем цикле недопустима: 100% local-hit бенчмарк и 100% PostgreSQL-read бенчмарк – это два разных эксперимента, даже если ендпоинт один и тот же.

Путь записи и асинхронные обновления

У сервиса будет и внутренний путь записи, но его роль другая. Условно:

PUT /internal/v1/offers/{sku}

Пример тела:

{
  "region": "moscow",
  "price": 12499000,
  "currency": "RUB",
  "available": true,
  "availableQuantity": 38,
  "deliveryDays": 1,
  "version": 18424
}

На раннем этапе цикла такой путь апдейта может быть синхронным. Но целевая архитектура будет асинхронной: upstream-системы цен и остатков публикуют изменения, потребитель применяет их к PostgreSQL, затем обновляет или инвалидирует Redis и распространяет инвалидацию для локального кэша. В этом и смысл разделения путей: чтение оптимизируется под экстремальную пропускную способность и низкий latency. Запись – под корректный порядок событий, версионирование, дедупликацию и предсказуемое распространение изменений.

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

Если очень упростить, у нас есть три базовые величины:

  • Throughput – сколько запросов в единицу времени система успешно завершает.
  • Latency – сколько времени пользователь ждет ответ.
  • Concurrency – сколько запросов одновременно находится в системе.

Закон Литтла в стационарной системе связывает эти величины формулой

L = ? ? W

среднее число запросов в системе равно средней эффективности интенсивност поступления, умноженному на среднее время пребывания в системе. Это операционный закон, который используют и в компьютерных системах. Даже в популярном изложении закон применяется к:

  • среднему времени отклика
  • средней пропускной способности

,а не к “любому красивому процентилю”. 

На практике для API это удобно читать как грубую формулу проверки “вменяемости” (sanity check):

concurrency --> throughput --> latency

Например, если система в устойчивом состоянии действительно обрабатывает 1 000 000 успешных запросов в секунду, а среднее время пребывания запроса в системе равно 20 ms, то в среднем внутри системы одновременно находится порядка 20 000 запросов. Это не точный план обеспечения производственных мощностей для всех внутренних очередей, но очень полезная инженерное допущение: она сразу показывает, что разговор про 1M RPS – это разговор не про “один быстрый контроллер”, а про большое число одновременных операций, сетевых буферов, соединений, очередей, дескрипторов, кэш-слоев и downstream-ограничений. 

Здесь есть важная оговорка. В законе Литтла входят средние величины. Формально подставлять туда p99 вместо среднего нельзя. Но как интуитивная оценка масштаба хвоста это все равно полезно: если p99 растет в разы, значит внутри системы растут очереди, контеншн, медленные операции и общее число “долгоживущих” запросов. Именно поэтому в производительности API меня интересует не только среднее время ответа, но и перцентили.

Почему p99 и p999 важнее средней latency

Prometheus в своей документации по гистограммам и сводкам напоминает базовую вещь: пересентиль – это не то же самое, что среднее время. Можно иметь приемлемое среднее и при этом “нехороший” хвост. Для реплицируемого сервиса это особенно важно, потому что опыт взаимодействия с системой складывается из поведения многих экземпляров, и высокие персентили должны агрегироваться корректно. Отсюда два практических следствия.

Во-первых, для latency SLO нужна явная работа с p95/p99/p999.

Во-вторых, для распределенной системы лучше строить персентили по гистограммам, а не усреднять заранее вычисленные сводные квантили, потому что такое усреднение Prometheus называет статистически бессмысленным. 

Покажу на простом примере. Представим 10 000 запросов. Из них 9 900 завершаются за 8 мс, а 100 – за 600 мс. Средняя latency получится около 13,9 мс. Если смотреть только на average, можно сказать: “Все отлично, мы уложились в 14 мс”. Но пользовательский опыт другой: 1% запросов ждали сотни миллисекунд, а в абсолютных числах это уже 100 очень заметных задержек на каждые 10 000 обращений. В системе с разветвлением такой хвост приносит еще больше вреда: пользовательский ответ часто зависит от самого медленного внутреннего шага, а значит p99 отдельных компонентов начинает напрямую влиять на p99 цепочки целиком. И это как раз тот класс проблем, для описания которого существует понятие tail latency

SLI, SLO, SLA и error budget

SLI считается как отношения числа “хороших” событий к общему числу событий. Это удобно не только для доступности, но и для latency и свежести данных. Например, “число успешно завершенных HTTP-запросов / общее число HTTP-запросов”, “число запросов быстрее 100 ms / общее число запросов” и даже freshness-метрику вида “число запросов проверки наличия товара, использовавших данные свежее заданного окна / общее число запросов проверки наличия товара”. Для нашего сервиса это почти готовый шаблон. 

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

ПоказательРабочая цель
System throughput1 000 000 RPS
p50< 10 мс
p95< 20 мс
p99< 50 мс
p999< 150 мс
5xx + timeout< 0,1%
Допустимая устарелость snapshotдо 5 секунд
Availability99,99%

Такой набор не взят “с потолка”, а отражает типичную форму request-driven SLO:

  • latency
  • success ratio
  • freshness.

Доступность и latency – частые SLO, но свежесть, корректность и покрытие тоже имеют право на существование, если они действительно связаны с пользовательским опытом. Для Offer Snapshot Service свежесть – обязательная часть договора с клиентом, потому что цена и наличие не вечно консистентны и потому что устаревшие данные должны быть осознанной частью дизайна, а не случайным побочным эффектом TTL. 

Что означает допустимая “устарелость” до пяти секунд

Рабочий контракт цикла такой: пользовательский snapshot может отставать от основного хранилища (источника истины, Source of Thruth, SoT) до пяти секунд. Для карточки товара, каталога или корзины это реалистичный компромисс между скоростью и консистентностью. Для финального оформления заказа – уже нет, и именно поэтому логика оплаты должна перепроверять условия отдельным транзакционным-сервисом.

Из этого допущения естественно следуют механизмы, которые позже появятся в цикле:

  • TTL
  • TTL jitter
  • локальный кэш,
  • Redis,
  • события инвалдиации
  • stale-while-revalidate,
  • single-flight
  • контроллируемый фолбек для устаревших данных

Первая статья их не реализует, но обязана объяснить, зачем они вообще нужны. Если я заранее делаю ставку на 1M RPS для АПИ ориентированного на нагрузку для чтения, значит я уже принимаю решение не ходить в хранилище на каждый запрос. Иначе сам контракт нереалистичен.

Почему наблюдаемость – не факультативная опция

Когда Prometheus описывает гистограммы, он делает еще одно важное замечание: _count и _sum аддитивны, а гистограммы позволяют строить агрегируемые персентили вроде histogram_quantile() по сумме bucket rates. Это значит, что для ПРОД измерений мы должны собирать не только сырой RPS, но и гистограммы latency, чтобы видеть p95/p99 по сервису целиком, а не по отдельным подам. Кроме того, Kubernetes отдельно предупреждает: пробы готовности используются не только на старте, но и позже в жизненном цикле, например при временных ошибках и перегрузках. При ошибки проверки готовности под удаляется из EndpointSlices соответствующих сервисов. То есть наблюдаеомсть и сигналы жизненного цикла – это часть управления нагрузкой, а не необязательная декоративная телеметрия. 

На этапе профилирования мы будем использовать JDK Flight Recorder и async-profiler для Java, а для Go – pprof. JFR – это фреймворк с низким оверхедом для сбора диагностических событий для Java-приложений и самого HotSpot, с целевой overhead-метрикой “не более 1%” на SPECjbb2015. Async-profiler официально описывает себя как профилировщик с низким оверхедом для Java, умеющий анализировать CPU, аллокации heap, локи и счетчики для перфоманса. Go, в свою очередь, документирует go tool pprof и показывает как CPU-профиль, так и профилировщик heap. Все это появится в следующих статьях, потому что высокая нагрузка без профилирования быстро превращается в подбор “‘мифических” флагов. 

Нагрузочная модель и сетевой бюджет

Слишком многие бенчмарк-цифры ломаются о то, что трафик в них был “равномерным и случайным”. В реальной системе так обычно не бывает. В e-commerce, контентных сервисах и вообще во многих системах с высокой нагрузкой на чтение популярность ключей сильно неравномерна: маленькая доля объектов дает непропорционально большую долю обращений. Redis прямо рекомендует политику allkeys-lru, когда ожидается, что некоторый поднабор элементов будет запрашиваться значительно чаще остальных. Документация называет это очень распространенным случаем. Это хорошо ложится на наш сценарий: небольшой набор SKU будет горячим, длинный хвост – холодным, а поведение локального кэша и Redis будет зависеть именно от этой неравномерности. 

Поэтому рабочая гипотеза нагрузки для цикла такая:

ХарактеристикаРабочая гипотеза
Распределение ключейZipf-like / hot-tail
1% товаров~50% запросов
10% товаров~80% запросов
Read traffic~99,5%
Write traffic~0,5%
Основной пользовательский путьGET /api/v1/offers/{sku}?region={region}

Это не “истина”, а стартовая модель. Ее смысл в том, что она сразу порождает ряд инженерных вопросов:

  • горячие ключи
  • эффективность локального кэша
  • Redis хоспот и miss storm после истечения TTL
  • неравномерная нагрузка на шарды
  • деградация при холодном старте.

Доли путей чтения

Для смешанного ПРОД сценария я фиксирую такой стартовый состав:

ПутьОриентировочная доля
Local cache hit85%
Redis hit14%
PostgreSQL fallback1%

А теперь – ключевая таблица, без которой любые RPS дальше будут мусором.

СценарийЧто реально делает системаЧто измеряем
Local cache hitЧтение из памяти процессаВерхний предел hot path
Redis hitСетевой round trip + сериализация + кэшСтоимость распределенного кэша
PostgreSQL fallbackConnection pool + SQL + сеть + БДСамый дорогой путь чтения
Смешанная нагрузкаВсё вместе в заданных доляхПРОД агрегат

Эта таблица и есть ответ на вопрос, почему “1M RPS” без условий бессмысленно. Если кто-то опубликовал одну цифру без распределения этих путей, читатель не знает, что именно измерялось: память, Redis, базу или распределение между ними.

Почему нужны отдельные бенчмарк-сценарии

Пороговые значения как критерии успеха/провала для теста: можно формализовать, например, “менее 1% ошибок”, “95% запросов быстрее 200 ms”, “99% быстрее 400 ms” и проваливать тест, если условия не выполняются. Это ровно тот подход, который нужен и нам: у каждого сценария должен быть свой пакет пороговых значений, а не один усредненный сценарий на все случаи жизни. Иными словами, я буду отдельно тестировать:

  • 100% локальный cache hit
  • 100% Redis hit
  • 100% чтение из PostgreSQL
  • смешанная нагрузка
  • нагрузка на “горячие ключи”
  • холодный старт кэша
  • деградация Redis
  • замедление PostgreSQL
  • резкий всплеск траффика.

И только после этого можно будет честно говорить о пропускной способности системы в терминах SLO, а не только о нагрузке со стороны генератора. 

Сетевой бюджет

Теперь простая арифметика, которая моментально возвращает разговор к фундаментальным ограничениям. Пусть средний JSON-ответ Offer Snapshot Service занимает около 1 КБ полезной нагрузки. Тогда:

1 000 000 ответов в секунду -> 1 КБ -> 1 ГБ/с полезных данных
+ накладные расходы HTTP, TCP, TLS, ретрансмиции и служебного трафика

Это еще без учета запросов, заголовков, TCP/IP оверхеда, TLS записи, ACK, ретрансмитов, нагрузки на обратного прокси и внутренних общений между сервисом, Redis и PostgreSQL. Уже отсюда следует, что задача “1M RPS” находится не внутри языка программирования. Она упирается в пропускную способность NIC, количество пакетов в секунду, переиспользование соединений, сетевую топологию и поведение балансировщиков.

HTTP/1.1 по умолчанию использует персистентные соединения и позволяет нести несколько запросов/ответов по одному соединению. RFC 9113 описывает HTTP/2 как более эффективное использование сетевых ресурсов с уменьшением latency за счет компрессии полей и нескольких конкурентных обменов по одному соединению. TLS 1.3 поддерживает 0-RTT, но одновременно имеет риски реплея и необходимость анти-реплей мер. Это означает, что выбор версии HTTP, стратегии keep-alive и терминации TLS – не второстепенные настройки, а часть итоговой кратины пропускной способности. 

На уровне Linux к этому добавляются системные лимиты:

  • ip_local_port_range по умолчанию задает диапазон локальных TCP/UDP-портов 32768–60999
  • somaxconn определяет верхний предел listen backlog и по умолчанию равен 4096
  • tcp_max_syn_backlog ограничивает число незавершенных входящих соединений в состоянии SYN_RECV.

Это именно те вещи, которые всплывают, когда кто-то пытается “просто добавить трафика” к предположительно быстрому АПИ. 

Отдельно важно помнить про Kubernetes resource management. Лимиты CPU на Linux енфорсятся через CPU троттлинг, а лимиты ОЗУ – реактивно через OOM kills при нехватке памяти. Для высоконагруженных систем это не теоретическая деталь: агрессивные лимиты CPU легко ломают p99, а слишком оптимистичные лимиты памяти могут убить под при пиковой нагрузке. Поэтому любой разговор о бенчмарках в контейнерах обязан фиксировать запросы/лимиты и фактическое поведение троттлинга. 

Бенчмарк, окружения и развитие цикла

Бенчмарк начинается с того, что мы заранее фиксируем, что именно будет считаться объективным. Минимальный чеклист, без которого я не буду доверять даже собственным цифрам.

Что фиксируемЗачем это нужно
Версия кода, рантайма и фреймворкаИначе результат не воспроизводим
CPU, RAM, архитектура, kernelАппаратная среда меняет пропускную способность и latency
Запросы/лимиты контейнераCPU троттлинг и OOM меняют поведение
Топология стендаОдин под и десять под – разные системы
Размер тела запроса200 B и 1 KB – разный сетевой бюджет
Датасет и распределение ключейРавномерный и hot-tail трафик несравнимы
Состояние кэшаХолодный кэш и теплый кэш – разные фазы
Arrival modelOpen vs closed радикально меняют выводы
Duration и warm-upКороткий тест не показывает стабильное состояние
Лимиты клиента и число генераторовГенератор может стать ботлнеком
Семантика ошибокНужно отделять успех, таймаут, 4xx, 5xx
p50/p95/p99/p999 и уровень ошибокСредней latency недостаточно
Упавшие итерацииИначе пропускная способность может быть ложной
Утилизация сетиМожно упереться в сеть, а не в сервис
Стоимость инфраструктуры1M RPS без стоимости – половина картины

Этот чеклист не академическая перестраховка. Персентили в распределенным сервисом нужно агрегировать корректно. Пороговые значения должны кодировать ожидания по персентилям и уровню ошибок.

Признаки недостоверного результата

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

ПризнакПочему это плохо
Генератор уперся в CPU или сетьМы измеряем генератор, а не сервис
Используется только average latencyХвосты скрыты
Ошибки включены в “общий RPS”Throughput завышен
Тест слишком короткийНет стабильного состояния, нет прогрева
Закрытая модель выдана за фиксированный-RPS бенчмаркСервис замедляется, а генератор “жалеет” его
Состояние кэша не зафиксированоЧисла между прогонами несравнимы
В одном варианте кэш есть, в другом нетСравнение некорректно
Разные лимиты ресурсов у Java и GoСравнение некорректно
Отключены метрики или логи только у одного стекаProduction parity отсутствует
Не видно упавшие итерацииЦелевая нагрузка могла не выполняться

Отдельно стоит сказать про локальный стенд. Docker Compose на одной машине не доказывает 1M RPS. Он нужен для того, чтобы воспроизводимо поднять сервис, базу, Redis, Prometheus, Grafana, собрать метрики, убедиться в корректности контракта, показать отличия между путями записи/чтения и провести небольшие базовые тесты. Для настоящего 1M RPS нам нужно настоящее ПРОД окружение, несколько генераторов, TLS, фиксированный бюджет ресурсов и измерение не только приложения, но и периметра.

Локальный стенд и ПРОД окружение

ХарактеристикаЛокальный стендProduction-like стенд
ОркестрацияDocker ComposeKubernetes
Число узловОбычно одна машинаНесколько приложений и несколько нод
НазначениеРазработка, фундамент, наблюдаемостьМасштабирование, сеть, отказы
Путь TLSОбычно упрощённыйНормальный ПРОД
Генерация нагрузкиОграниченная локальной машинойРаспределённая
Достоверность 1M RPSНедостаточнаяПотенциально достаточная
Удобство воспроизведенияВысокоеНизкое

Readiness probes могут использоваться не только на старте, но и позже в жизненном цикле, например при временной перегрузке, и тогда под будет убран из EndpointSlices сервисов.

Удаление по умолчанию “graceful” в пределах 30 секунд, а preStop и graceful терминация непосредственно влияют на то, потеряем ли мы активные запросы во время раскатки. Это и есть пример того, почему ПРОД перфоманс нельзя свести к локальному ab -n 1000000 -c 1000. 

Этапы развития системы

План, по которому будет идти весь цикл.

И в виде краткой таблицы:

СтатьяЧто делаемГлавный результат
ПерваяФиксируем контракт экспериментаПонимание системы, SLO и нагрузки
ВтораяСтроим Java/Spring Boot фундаментИзмеримый сервис
ТретьяДелаем нагрузочное тестированиеКарта “узких мест”
ЧетвёртаяОптимизируем JavaЛучший фундамент для одного инстанса
ПятаяМасштабируем системуПропускная способность уже на уровне системы
ШестаяПроверяем раскатку и отказыГотовность к ПРОДу
СедьмаяРеализуем Go и сравниваемJava vs Go вывод

Как именно мы будем сравнивать Java и Go

Главное правило сравнения простое: никаких разных условий. Если Java тестируется с горячим локальным кэшем и прогретым Redis, а Go – напрямую в PostgreSQL, это не сравнение языков. Если у Java пять CPU, а у Go десять – это не сравнение языков. Если у одного стека включены метрики и логи, а у другого выключены – это не сравнение, а подмена условий.

Рабочая таблица сравнения:

МетрикаЗачем нужна
RPS на pod и на системуПропускная способность
p50 / p95 / p99 / p999Форма распределения latency
CPU per requestСтоимость обработки
Memory footprintПлотность размещения подов
Allocation rateДавление на allocator/GC
GC contributionЦена runtime
Error rateСтабильность под нагрузкой
Startup timeRollout characteristics
Pod densityCapacity economics
Cost per 1M successful requestsЭкономика эксплуатации
Сложность observabilityРеальная цена сопровождения
Трудозатраты разработкиЦена переписывания

Для сравнения будут зафиксированы одинаковые API, одинаковый dataset, одинаковая допустимая устарелость, одинаковый Redis и PostgreSQL, одинаковые SLO и thresholds, одинаковая нагрузочная модель и одинаковые лимиты ресурсов – либо два явно разделенных режима сравнения, если лимиты преднамеренно различаются. Только при такой дисциплине можно будет обсуждать, что именно дал Go: runtime-эффект, меньше аллокаций, ниже футпринт памяти или просто иной набор инфраструктурных компромисов.

Отказы и защитные механизмы

Перфоманс без семантики отказов в ПРОДе почти бесполезна, поэтому уже в первой статье я фиксирую будущие сценарии отказов.

СценарийЧто система должна делать
Отказ RedisНе превращать весь трафик в шторм по PostgreSQL
Замедление PostgreSQLСохранять быстрый cache-hit путь и ограничивать промахи
Терминация подаВыходить из readiness и дожимать активные запросы
Всплески траффикаМасштабироваться, распределенная нагрузка и защищать downstream

Часть механизмов здесь уже заранее понятна по документации и по здравому смыслу архитектуры. Readiness должна уметь снимать pod с трафика при временной перегрузке, а терминация – давать системе время на остановку без мгновенного обрыва запросов. Redis имеет смысл держать в режиме, благоприятном для горячего поднабора данных, а PostgreSQL не должен становиться ендпоинтом-заменой распределенного кэша хотя бы потому, что число активных соединений ограничено. Отсюда в следующих статьях неизбежно появятся:

  • ограниченный параллелизм
  • signle-flight режим
  • бюджет тайм-аута,
  • сброс нагрузки,
  • дренаж на основе готовности
  • подход к диагностике p99 с использованием ранбуков.

Заключение

Главный вывод первой статьи очень простой: 1M RPS – это не характеристика языка и не число из консоли генератора, а системный контракт. В этот контракт входят API, тело запроса, latency, семантика ошибок, свежесть данных, кэш-модель, сеть, наблюдаемость, лимит ресурсов, правила бенчмарков и поведение под отказами. Пока все это не зафиксировано, разговор про пропускную способность – это разговор о чем угодно, только не о ПРОД-системе. 

Второй вывод: выбор именно Offer Snapshot Service для цикла – не случайность. Это достаточно реалистичная read-heavy система, чтобы миллион RPS имел бизнес-смысл, и достаточно компактная, чтобы на ней можно было последовательно показать цену кэширования, хвосты latency, сетевые ограничения, отличие local hit от Redis hit и опасность превращения PostgreSQL в прямой serving layer.

Третий вывод: кэширование делает цель 1M RPS правдоподобной, но не бесплатной. Вместе с local cache и Redis в систему приходят stale data, invalidation, stampede, hot keys, деградация при отказе Redis и необходимость явно формализовать freshness как часть пользовательского контракта. То есть быстродействие покупается не магией, а архитектурными компромиссами.

И, наконец, главный организационный вывод для всего цикла: Java и Go будут сравниваться только после того, как сама архитектура, методика измерений и требования перестанут плавать. Иначе мы будем сравнивать не рантайм, а две разные системы.

Следующая статья цикла – “Java/Spring Boot под нагрузкой: создаем базис, который можно измерять”. В ней уже появится первая рабочая реализация Offer Snapshot Service на Java, локальный стенд с PostgreSQL, Redis и observability, а также тот самый фундамент, с которого вообще имеет смысл начинать разгон.

Источники

Первоисточники и официальная документация, на которые я опирался при постановке контракта эксперимента, обсуждении SLO, percentiles, k6-моделей нагрузки, Kubernetes lifecycle, Redis/PostgreSQL ограничений и профилирования.

Loading