Оглавление
- Введение
- Почему 1M RPS без условий ничего не значит
- Что мы строим
- Как мы будем измерять производительность
- Нагрузочная модель и сетевой бюджет
- Честный benchmark, окружения и развитие цикла
- Заключение
- Источники
Введение
Когда в статье написано, что система “держит 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
- балансировщике нагрузки
- попытке сделать роллаут без потери трафика и т.д.
Язык и фреймворк в этой ситуации всего лишь один слой из многих.
Базово начинается разработка подобной системы довольно просто:
- Сначала определяется SLI, то есть измеряемый признак качества сервиса.
- Затем SLO – целевой уровень этого качества
- Затем “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 и их смысл
| Поле | Тип | Зачем нужно |
|---|---|---|
| sku | string | Идентификатор товара |
| region | string | Региональная гранулярность предложения |
| price | bigint в минимальных единицах | Детерминированные деньги без double |
| currency | string(3) | Валюта |
| available | boolean | Быстрый бизнес-флаг доступности |
| availableQuantity | integer | Приблизительное количество для витрины |
| deliveryDays | integer | Оценка срока доставки |
| version | bigint | Порядок обновлений и защита от устаревших событий |
| updatedAt | timestamptz | Диагностика свежести ответа |
Ключевые поля здесь – 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 throughput | 1 000 000 RPS |
| p50 | < 10 мс |
| p95 | < 20 мс |
| p99 | < 50 мс |
| p999 | < 150 мс |
| 5xx + timeout | < 0,1% |
| Допустимая устарелость snapshot | до 5 секунд |
| Availability | 99,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 hit | 85% |
| Redis hit | 14% |
| PostgreSQL fallback | 1% |
А теперь – ключевая таблица, без которой любые RPS дальше будут мусором.
| Сценарий | Что реально делает система | Что измеряем |
|---|---|---|
| Local cache hit | Чтение из памяти процесса | Верхний предел hot path |
| Redis hit | Сетевой round trip + сериализация + кэш | Стоимость распределенного кэша |
| PostgreSQL fallback | Connection 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 model | Open 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 Compose | Kubernetes |
| Число узлов | Обычно одна машина | Несколько приложений и несколько нод |
| Назначение | Разработка, фундамент, наблюдаемость | Масштабирование, сеть, отказы |
| Путь 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 time | Rollout characteristics |
| Pod density | Capacity 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 ограничений и профилирования.
- Google SRE – Continuous Improvement To Get Reliability
- Open and closed models | Grafana k6 documentation
- GitHub – giltene/wrk2: A constant throughput, correct latency recording variant of wrk · GitHub
- PostgreSQL: Documentation: 18: 19.3. Connections and Authentication
- Little’s law – Wikipedia
- Histograms and summaries | Prometheus
- JEP 328: Flight Recorder
- Key eviction | Docs
- Thresholds | Grafana k6 documentation
- RFC 9112: HTTP/1.1
- IP Sysctl – The Linux Kernel documentation
- Resource Management for Pods and Containers | Kubernetes
- Liveness, Readiness, and Startup Probes | Kubernetes
- Google SRE – Prometheus Alerting: Turn SLOs into Alerts
- Constant arrival rate | Grafana k6 documentation
- Pod Lifecycle | Kubernetes
- RFC 9113: HTTP/2
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 | RFC Editor
- GitHub – async-profiler/async-profiler: Sampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events · GitHub
- Profiling Go Programs – The Go Programming Language
- Deconstructing the Tail at Scale Effect Across Network Protocols
![]()
You must be logged in to post a comment.