Разгоняем сервис до 1M RPS: Java/Spring Boot под нагрузкой. Создаем воспроизводимую базовую реализацию и наблюдаемость.

Коротко: во второй части цикла мы не разгоняем сервис и не пытаемся получить красивую цифру RPS. Мы собираем воспроизводимый базовый стенд, на котором любой следующий результат можно объяснить: фиксируем API и набор данных, делаем три явно переключаемых пути чтения, добавляем метрики, структурированные логи, трассировки, Grafana и k6, а затем проверяем, что для каждого запроса можем ответить, откуда пришли данные и где было потрачено время.

Результат этой статьи – не “Java держит N RPS”, а система, в которой следующую оптимизацию уже нельзя будет обосновать ощущениями.

Оглавление

  1. Введение
  2. Три режима чтения и явный cache-aside
  3. Наблюдаемость как часть базовой реализации
  4. Нагрузка, тесты и воспроизводимость
  5. Практический сценарий воспроизведения
  6. Заключение

Введение

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

Мы зафиксировали Offer Snapshot Service, основной контракт чтения, рабочие SLO и правило, которое будет действовать на протяжении всего цикла:

Меняем одну существенную переменную – измеряем – объясняем результат – только после этого двигаемся дальше.

Именно поэтому первая статья не начиналась с Redis, флагов JVM или сравнения Java с Go. Цель всего цикла – не показать быстрый /hello, а пройти путь от простого сервиса до системы, поведение которой можно объяснить под высокой нагрузкой, при отказах и во время изменений. 

Во второй части мы наконец пишем сервис. Но разгонять его пока не будем.

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

После этой статьи мы должны уметь ответить на вполне конкретные вопросы:

  • Сколько запросов реально пришло в систему?
  • Сколько завершилось успешно?
  • Каков p95, p99 и p999?
  • Ответ пришел из локального кэша, Redis или PostgreSQL?
  • Ждали ли запросы соединение из HikariCP?
  • Сколько потоков Tomcat было занято?
  • Что в этот момент происходило с CPU и GC?
  • Можно ли найти конкретный медленный запрос в логах, а затем открыть его трассировку?
  • Можно ли через неделю повторить тот же эксперимент?

Если на эти вопросы нельзя уверенно ответить при 100 RPS, обсуждать 1M RPS пока рано.

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

Во второй статье мы поэтому строим не “быстрый сервис”. Мы строим измеримый сервис.

Экспериментальный контракт и архитектура

Предметная система остается той же:

Offer Snapshot Service

Ее основной ключ:

SKU + region

Публичный запрос:

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

Например:

GET /api/v1/offers/sku-42?region=moscow

Ответ:

{
  "sku": "sku-42",
  "region": "moscow",
  "price": 104200,
  "currency": "RUB",
  "available": true,
  "availableQuantity": 42,
  "deliveryDays": 1,
  "version": 42,
  "updatedAt": "2026-08-01T12:00:00Z"
}
  • Цена хранится в минимальных денежных единицах.
  • Версия понадобится для контроля порядка обновлений.
  • updatedAt – для измерения свежести данных.

В первой статье мы уже зафиксировали, что для такой модели чтения свежесть – это часть пользовательского контракта, а не побочный эффект TTL. 

Offer Snapshot Service не оформляет заказ, не резервирует товар, не проводит оплату и не является системой окончательной фиксации цены.

Это модель чтения.

Источник истины один: PostgreSQL

Redis и Caffeine являются производными копиями:

  • PostgreSQL = подтвержденное состояние
  • Redis      = распределенный кэш
  • Caffeine   = локальный кэш процесса

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

В текущей реализации исходный стенд зафиксирован на Java 25, Spring Boot 4.1.0 и Gradle 9.7.0. Версии инфраструктурных компонентов также фиксируются в репозитории. Для эксперимента принципиально не то, является ли конкретная патч-версия самой новой, а то, что она не меняется между сравниваемыми измерениями.

Архитектура приложения выглядит так:

Здесь сразу видны три принципиально разные стоимости одного и того же HTTP-запроса.

  • LOCAL_CACHE – означает чтение из памяти JVM.
  • REDIS – добавляет сетевой вызов, сериализацию, клиент Redis и отдельный процесс.
  • POSTGRESQL – добавляет HikariCP, сетевой обмен с БД, выполнение SQL и стоимость самого PostgreSQL.

Поэтому один итоговый RPS для всех трех случаев ничего не объясняет. Это три разных эксперимента, даже если URL одинаковый.

В текущем стенде мы специально можем переключать их независимо.

Контракт данной статьи

Долгосрочные цели цикла остаются такими, как мы зафиксировали в первой статье:

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

Это не результаты второй статьи. Это ориентиры всего цикла. 

Критерий успеха текущего этапа другой:

Один и тот же запрос должен воспроизводимо проходить через известный read path, а мы должны видеть его стоимость на каждом существенном уровне системы.

Именно поэтому даже локальные пороговые значения k6 здесь являются только защитой от очевидно сломанного стенда:

  • http_req_failed < 1%
  • p95             < 300 ms
  • p99             < 500 ms
  • checks          > 99%

Это поверхностная проверка работоспособности, а не ПРОД SLO.

Набор данных

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

50 000 SKU ? 5 regions = 250 000 offers

Регионы:

  • moscow
  • spb
  • ekb
  • kazan
  • novosibirsk

Схема остается компактной:

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)
);

create index idx_offers_region_updated_at
    on offers(region, updated_at desc);

Набор данных генерируется детерминированно через generate_series. PostgreSQL предоставляет generate_series как штатную функцию генерации последовательностей, поэтому такое наполнение базы данных начальными данными (seed) можно сформировать одним INSERT … SELECT, не создавая сотни тысяч отдельных клиентских вставок. 

Главное здесь не скорость наполнения. Главное – повторяемость.

sku-42 / moscow

не должен сегодня иметь один набор полей, а завтра другой только потому, что при запуске seed использовался random().

Мы оставляем несколько заранее известных записей:

  • sku-1
  • sku-42
  • sku-100

За счет этого smoke-проверки не зависят от случайно выбранных данных.

Почему JDBC, а не ORM

Путь чтения намеренно максимально прозрачен.

Репозиторий фактически выполняет один SQL:

select
    sku,
    region,
    price,
    currency,
    available,
    available_quantity,
    delivery_days,
    version,
    updated_at
from offers
where sku = :sku
  and region = :region

Для доступа используется NamedParameterJdbcTemplate.

Это не утверждение, что JDBC “быстрее Hibernate”.

Мы этого еще не измеряли.

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

Начальные настройки HikariCP тоже не объявляются оптимальными:

spring:
  datasource:
    hikari:
      pool-name: offer-pool
      maximum-pool-size: 16
      minimum-idle: 4
      connection-timeout: 3000
      validation-timeout: 1000

maximum-pool-size: 16 – стартовая точка.

Если позже появится:

hikaricp_connections_pending > 0

это еще не означает:

увеличиваем pool до 64

Пул соединений ограничивает количество конкурентной работы, которую приложение может передать PostgreSQL. Увеличив его, можно убрать очередь из приложения и создать гораздо более дорогую очередь уже внутри базы.

Сначала измеряем – потом меняем.

Три режима чтения и явный cache-aside

Одна из самых полезных частей стенда – наличие трех режимов внутри одного приложения.

  • Не три репозитория.
  • Не три сервиса.
  • Не три независимые реализации.

Одна кодовая база, один API, один набор данных и один контур наблюдаемости.

РежимПоследовательностьПлюсыМинусыКогда используем
noneHTTP ? PostgreSQLПрозрачно показывает стоимость пути чтения из БД, простейшая семантикаМаксимальная нагрузка на БДНулевая точка сравнения
redisHTTP ? Redis ? PostgreSQL при промахеУбирает большую часть повторных чтений из БДСеть, сериализация, устаревшие данные, отдельный отказоустойчивый компонентИзмеряем цену распределённого кэша
local-redisHTTP ? Caffeine ? Redis ? PostgreSQLСамый дешёвый путь для горячих ключейСостояние теперь существует отдельно в каждой JVMИзмеряем верхний уровень многоуровневого кэша

Переключение выполняется настройкой:

app:
  cache:
    mode: none

Допустимые значения:

  • none
  • redis
  • local-redis

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

make mode-db
make mode-redis
make mode-local

Такое устройство понадобится в следующей части особенно сильно. Мы сможем перейти:

A: PostgreSQL
      ?
B: Redis + PostgreSQL
      ?
C: Caffeine + Redis + PostgreSQL

не меняя предметную модель, ендпоинты и набор данных.

Это уже намного ближе к нормальному эксперименту.

DB-only

В режиме:

app:
  cache:
    mode: none

путь запроса:

HTTP
?
Spring MVC
?
HikariCP
?
PostgreSQL

Кэш вообще не должен участвовать в результате и не должен случайно прогреваться.

Дополнительный механизм обхода кэша доступен прямо из API:

GET /api/v1/offers/sku-42?region=moscow&cache=false

Такой запрос:

  • не читает Caffeine
  • не читает Redis
  • не пишет Caffeine
  • не пишет Redis

и всегда идет в PostgreSQL.

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

Redis

В режиме redis используется классический cache-aside:

request
  ?
Redis GET
  ?
hit ???????????????? response
  ? miss
PostgreSQL
  ?
Redis SET
  ?
response

Главный поток написан явно, а не спрятан за @Cacheable.

В упрощенном виде:

var cached = redisCache.get(key);

if (cached.isPresent()) {
    return result(cached.get(), REDIS);
}

var value = repository.find(key);

redisCache.put(key, value);

return result(value, POSTGRESQL);

Для обычного прикладного сервиса Spring Cache вполне может быть хорошим решением. Но в нашем случае кэширование само является предметом эксперимента. Нам нужно видеть, где находится проверка Redis, когда выполняется запрос в PostgreSQL, когда значение записывается обратно и где позже появится координация загрузчиков.

Caffeine + Redis

Третий режим добавляет локальный уровень:

request
  ?
Caffeine
  ? miss
Redis
  ? miss
PostgreSQL

Упрощенная реализация:

var local = localCache.getIfPresent(key);

if (local != null) {
    return result(local, LOCAL_CACHE);
}

var redis = redisCache.get(key);

if (redis.isPresent()) {
    localCache.put(key, redis.get());
    return result(redis.get(), REDIS);
}

var loaded = repository.find(key);

redisCache.put(key, loaded);
localCache.put(key, loaded);

return result(loaded, POSTGRESQL);

Caffeine поддерживает ограничение размера, истечение срока действия по времени, статистику доступа и автоматическую загрузку значений. Для более позднего single-flight особенно полезна атомарная форма cache.get(key, mappingFunction), которая вычисляет отсутствующее значение как одну операцию на локальном кэше. 

Но базовая реализация пока намеренно оставляет загрузчик “прямым”.

Мы еще хотим увидеть лавину запросов до того, как начнем его лечить.

Как доказать, откуда пришел ответ

В локальном диагностическом режиме сервис добавляет:

  • X-Request-Id
  • X-Offer-Source
  • X-Offer-Version

Например:

curl -i \
  -H 'X-Request-Id: article2-demo-001' \
  'http://localhost:8080/api/v1/offers/sku-42?region=moscow'

В DB mode:

X-Request-Id: article2-demo-001
X-Offer-Source: POSTGRESQL
X-Offer-Version: 42

После очистки Redis первый запрос в режиме redis может вернуть:

X-Offer-Source: POSTGRESQL

а второй:

X-Offer-Source: REDIS

После прогрева local-redis:

X-Offer-Source: LOCAL_CACHE

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

Запускаем приложение для работы с PostgreSQL с помощью команды:

make mode-db

Здесь мы видим HEADER ответа:

X-Offer-Source = POSTGRESQL

Запускаем приложение для работы с Redis с помощью команды:

make mode-redis
make cache-clear

Здесь мы видим HEADER ответа:

X-Offer-Source = REDIS

Запускаем приложение для работы с Caffeine с помощью команды:

make mode-local
make cache-clear

Таким образом мы с вами увидел для одного и того же sku-42: ответы

  • POSTGRESQL
  • REDIS
  • LOCAL_CACHE.

На изображениях выше хорошо видна смена X-Offer-Source.

Internal API

Для управляемых экспериментов предусмотрен внутренний API.

Статистика:

curl \
  'http://localhost:8088/internal/v1/cache/stats'

Очистка локального уровня:

curl -X POST \
  'http://localhost:8088/internal/v1/cache/clear?layer=local'

Redis:

curl -X POST \
  'http://localhost:8088/internal/v1/cache/clear?layer=redis'

Обоих уровней:

curl -X POST \
  'http://localhost:8088/internal/v1/cache/clear?layer=all'

И принудительное чтение из конкретного источника:

curl \
  'http://localhost:8088/internal/v1/offers/sku-42?region=moscow&source=database'

Поддерживаются:

  • source=auto
  • source=local
  • source=redis
  • source=database

Публичный Nginx проксирует /api/*, но не открывает /internal/* и /actuator/*.

Для нагрузочного эксперимента это принципиально.

Фраза:

“Наверное, кэш был холодным” не является описанием предусловия.

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

Наблюдаемость как часть базовой реализации

Теперь самая важная часть второй статьи.

Под нагрузкой нам мало знать, что p99 вырос.

Нужно иметь возможность пройти цепочку:

p99 вырос
    ?
в какой момент?
    ?
какой ресурс насыщен?
    ?
какая ветка read path?
    ?
какие запросы пострадали?
    ?
что происходило внутри конкретного запроса?

Для этого в локальном стенде используется:

  • Grafana
  • Grafana Alloy
  • Grafana Mimir
  • Grafana Loki
  • Grafana Tempo

Grafana Alloy предназначен для построения конвейеров метрик, логов и трассировок и поддерживает как экосистему Prometheus, так и OpenTelemetry. Для уже существующих Prometheus-метрик документация Grafana рекомендует использовать prometheus.* pipeline и prometheus.remote_write. 

Схема выглядит так:

Mimir в лаборатории запускается монолитно и хранит данные на локальной файловой системе. Сама документация Grafana показывает такой режим именно как демонстрационный и прямо отделяет его от ПРОД-развертывания. Локальная файловая система допустима для single-node Mimir. 

То же относится ко всему Compose-стенду. Он нужен для воспроизводимых локальных экспериментов. Он не доказывает, что такая же топология пригодна для 1M RPS.

Метрики

Spring Boot Actuator и Micrometer автоматически дают базовые метрики:

  • JVM
  • процесса
  • HTTP
  • datasource
  • HikariCP

Tomcat-метрики доступны при включенном MBean Registry. Prometheus-экспозиция публикуется через /actuator/prometheus. 

На текущем стенде нас интересуют прежде всего:

Что измеряемPrometheus metric
HTTP request counthttp_server_requests_seconds_count
HTTP latency histogramhttp_server_requests_seconds_bucket
JVM memoryjvm_memory_used_bytes
GC pausesjvm_gc_pause_seconds_count
Live threadsjvm_threads_live_threads
Process CPUprocess_cpu_usage
System CPUsystem_cpu_usage
Tomcat busy threadstomcat_threads_busy_threads
Hikari activehikaricp_connections_active
Hikari idlehikaricp_connections_idle
Hikari pendinghikaricp_connections_pending
Чтения Offeroffer_snapshot_reads_total
Время чтенияoffer_snapshot_read_duration_seconds
Операции кэшаoffer_cache_operations_total
Ошибки Redisoffer_redis_errors_total
Ошибки PostgreSQLoffer_database_errors_total
Запросы в обработкеoffer_requests_in_flight
Возраст snapshotoffer_snapshot_age_seconds

Важно различать имена внутри Micrometer и имена в формате Prometheus: например, метрика http.server.requests экспортируется в Prometheus с нормализованным именем.

Перед написанием любого дашборда полезно проверять реальные метрики:

curl \
  'http://localhost:8081/actuator/prometheus'

а не угадывать их по памяти.

RPS

sum(
  rate(
    http_server_requests_seconds_count{
      uri="/api/v1/offers/{sku}"
    }[1m]
  )
)

Доля 5xx

sum(
  rate(
    http_server_requests_seconds_count{
      uri="/api/v1/offers/{sku}",
      status=~"5.."
    }[5m]
  )
)
/
sum(
  rate(
    http_server_requests_seconds_count{
      uri="/api/v1/offers/{sku}"
    }[5m]
  )
)

p99

histogram_quantile(
  0.99,
  sum by (le) (
    rate(
      http_server_requests_seconds_bucket{
        uri="/api/v1/offers/{sku}"
      }[5m]
    )
  )
)

p999

histogram_quantile(
  0.999,
  sum by (le) (
    rate(
      http_server_requests_seconds_bucket{
        uri="/api/v1/offers/{sku}"
      }[5m]
    )
  )
)

Перцентили здесь рассчитываются по histogram buckets, а не усредняются между экземплярами приложения. Micrometer рекомендует percentile histograms для систем вроде Prometheus именно потому, что buckets можно агрегировать по измерениям и уже после этого вычислять percentile через histogram_quantile. 

Для HTTP latency в стенде нужны buckets вокруг интересующего нас диапазона:

5 ms
10 ms
20 ms
50 ms
100 ms
200 ms
500 ms
1 s

Распределение источников данных

sum by (source) (
  rate(offer_snapshot_reads_total[1m])
)

Этот график будет одним из самых полезных во всем цикле.

При одинаковом общем RPS мы должны видеть принципиально разные системы:

100% postgres

или:

95% redis
5% postgres

или:

90% local
9% redis
1% postgres

Без этого сравнение latency теряет значительную часть смысла.

Hikari pending

max(
  hikaricp_connections_pending{
    pool="offer-pool"
  }
)

Если одновременно растут:

p99 + hikaricp_connections_pending

это уже полезный сигнал. Но он еще не доказывает, что виноват размер пула. Нужно посмотреть PostgreSQL, CPU приложения, длительность “похода в БД” и число активных соединений.

Ошибки Redis

sum(
  rate(offer_redis_errors_total[5m])
)

В базовой реализации политика отказа Redis намеренно проста:

Redis GET
    ?
ошибка
    ?
metric + structured log
    ?
PostgreSQL fallback

Автоматического ретрая в пути чтения сейчас нет.

Это полезно именно на исходном этапе: при отказе Redis мы увидим чистую цену деградации:

Redis unavailable
      ?
DB fallback grows
      ?
Hikari saturation?
      ?
PostgreSQL saturation?
      ?
p99?

Бесконтрольные повторы запросов во время перегрузки способны только усилить проблему. Повторные вызовы увеличивает нагрузку на даунстрим и при перегрузке могут задерживать восстановление. Если же повторные попытки все-таки нужны, их следует ограничивать и разносить во времени через backoff и jitter. 

Не закидываем идентификаторы запросов в labels

В метриках нет:

  • sku
  • requestId
  • traceId
  • raw URL
  • userId

И это сделано намеренно. Иначе один ендпоинт:

/api/v1/offers/{sku}

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

В одной из предыдущих статей??? мы уже разбирали эту проблему отдельно: высококардинальные лейблы – один из самых быстрых способов сделать стек наблюдаеомсти дорогим и тяжелым.  Идентификаторы конкретного запроса должны жить в логах и трассировках.

Не в каждой метрике.

Логи

Приложение пишет структурированные JSON-логи.

Минимально полезный набор полей:

  • timestamp
  • level
  • service
  • logger
  • thread
  • message
  • requestId
  • traceId
  • spanId
  • method
  • route
  • status
  • durationMs
  • offerSource
  • exceptionType

X-Request-Id обрабатывается фильтром в начале HTTP-запроса. Если клиент передал корректное значение – используем его. Если нет – создаем UUID.

Идентификатор попадает в:

MDC
response header
structured log

и удаляется из MDC в finally.

Последний шаг обязателен для пула потоков: воркер Tomcat переиспользуется следующими запросами.

В результате можно пройти цепочку:

Nginx log
    ? requestId
application log
    ? traceId
Tempo trace

Необходимо запустить приложение с помощью команды:

make mode-db

Далее необходимо выполнить запрос, cURL ниже:

curl --location --request GET 'http://127.0.0.1:8080/api/v1/offers/sku-42?region=moscow' \
  --header 'Accept: application/json' \
  --header 'X-Request-Id: article2-demo-001' \
  --header 'traceparent: 00-8f3a7c2e91b64d05a4c8e7f123456789-6a2d9c4e8b1f3075-01'

После этого перейдя в Grafana по ссылке и выбрав Tempo сделать поиск по traceID 8f3a7c2e91b64d05a4c8e7f123456789:
http://127.0.0.1:3000/explore


В логах мы увидим детали запроса:

Пример лога:

2026-08-25 14:02:12.725{“timestamp”:”2026-08-25T11:02:12.725Z”,”level”:”INFO”,”logger”:”http.access”,”thread”:”http-nio-8080-exec-5″,”message”:”http request completed”,”service”:”offer-snapshot-service”,”requestId”:”article2-demo-001″,”traceId”:”8f3a7c2e91b64d05a4c8e7f123456789“,”spanId”:”ba439c9af51ea05e”,”method”:”GET”,”route”:”/api/v1/offers/{sku}”,”status”:200,”durationMs”:48.55,”offerSource”:”POSTGRESQL”}

JSON-запись в которой одновременно видны requestId, traceId, status, durationMs и offerSource.

Трассировки

Для трассировки используется OpenTelemetry Java Agent.

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

В локальной лаборатории:

Spring Boot
    ?
OpenTelemetry Java Agent
    ? OTLP
Grafana Alloy
    ?
Grafana Tempo

В коде не нужно одновременно заставлять три разных механизма экспортировать одну и ту же телеметрию.

Разделение обязанностей такое:

  • Micrometer   ? metrics
  • logging      ? logs
  • OTel Agent   ? traces

Для локальной демонстрации можно позволить себе 100% семплинг. В ПРОДЕ, конечно же, это уже отдельное решение, потому что стоимость трассировок зависит от нагрузки и объема спанов.

Хороший результат здесь выглядит не как “у нас есть трейсы”.

Он выглядит так:

увидели всплеск p99
        ?
выбрали время
        ?
нашли slow request в логах
        ?
взяли traceId
        ?
открыли Tempo
        ?
увидели, где запрос провел время

Health checks

Management ендпонит вынесен на отдельный порт:

8081

Доступны:

  • /actuator/health
  • /actuator/health/liveness
  • /actuator/health/readiness
  • /actuator/prometheus

Redis не должен делать процесс unhealthy по liveness.

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

В нашем случае Redis может быть недоступен, а приложение продолжит читать PostgreSQL.

Значит: Redis down

не равно: JVM process must restart

Намного интереснее другой вопрос:

А выдержит ли PostgreSQL поток после исчезновения Redis?

И это уже задача нагрузочного тестирования.

Grafana дашборды

Дашюорды лежат в Git и provisioning выполняется автоматически. После make up не должно быть ручного “импортируйте JSON и выберите датасорс”.

Для статьи нужны девять представлений:

DashboardЧто на нём проверяемЧто показать в статье
PROSELYTE / Java Baseline / OverviewRPS, p50/p95/p99/p999, ошибки, CPU, Hikari, source distributionОбязательный скриншот
Java Baseline / HTTP & TomcatHTTP latency, статусы, busy threads, NginxСкриншот при нагрузке
Java Baseline / JVMheap, GC, threads, CPUСкриншот при smoke/baseline
Java Baseline / PostgreSQL & Hikaripool, pending, DB connections, transactionsОбязательный скриншот
Java Baseline / Cachelocal/Redis hits, misses, source distributionСкриншот трёх режимов
Java Baseline / Logsошибки, request ID, поискСкриншот одного запроса
Java Baseline / Tracesslow/failed traces, JDBC/Redis spansОбязательный скриншот
Observability / Stack HealthAlloy, Mimir, Loki, Tempo, exportersДостаточно контрольного кадра
Load Testing / k6offered/completed RPS, latency, VUs, dropped iterationsОбязательный скриншот

Ниже скриншоты дашбордов:

Grafana здесь выполняет еще одну функцию: сохраняет контекст эксперимента в коде.

Если через полгода скриншот невозможно восстановить и никто не знает, какой PromQL использовался, это слабый инженерный артефакт.

JSON дашборда, provisioning и валидатор должны жить рядом с приложением.

Нагрузка, тесты и воспроизводимость

Нагрузку генерирует k6.

Для основной части эксперимента используется constant-arrival-rate.

Причина связана с моделью нагрузки.

Представим сервис, который сначала отвечает за 10 мс, а затем под перегрузкой начинает отвечать за 500 мс.

В закрытой модели клиент ждет завершения предыдущей работы:

ответ стал медленнее
        ?
следующий запрос приходит позже
        ?
фактическая интенсивность нагрузки падает

То есть деградирующая система начинает сама получать меньше новой работы.

Для исследования предела производительности это часто слишком удобно.

Open model отделяет момент запуска новых итераций от длительности уже выполняющихся. k6 прямо относит constant-arrival-rate к open-model executors: заданное число итераций запускается в единицу времени независимо от времени ответа, пока генератор располагает достаточным количеством VU. sleep() для pacing в таком сценарии не нужен – executor сам задает скорость поступления работы. 

Базовая форма:

export const options = {
  scenarios: {
    baseline: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 20,
      maxVUs: 100,
    },
  },
};

При этом следует различать: offered RPS и completed RPS

Если генератор хотел создать 10 000 итераций в секунду, но сам не смог поддержать такой поток и начал показывать dropped_iterations, число “10 000” нельзя выдавать за пропускную способность приложения.

Набор сценариев

В стенде нужны отдельные сценарии:

СценарийЧто проверяем
smokeВообще работает ли стенд
baseline-dbЧистый PostgreSQL path
baseline-redisRedis + DB fallback
baseline-localCaffeine + Redis + PostgreSQL
mixedСмешанный профиль
cold-cacheПоведение после очистки кэша
hot-keyКонкурентные запросы одного горячего ключа

лавину запросов логично оставить отдельным экспериментом: сначала получить проблему на прямом загрузчике, затем добавить single-flight и повторить такую же нагрузку.

Без этого получится очередная демонстрация кода, а не доказательство эффекта.

Безопасный smoke

Первичная проверка должна быть дешевой:

  • 20 RPS
  • 30 seconds

То есть примерно: 600 итераций

при отсутствии ошибок и сброшенных итераций.

Базовая нагрузка уже может начинаться с:

  • 100 RPS
  • 2 minutes

а повышение интенсивности выполняется явно:

TARGET_RPS=500 DURATION=5m make load-db

Никакой команды вроде:

make smoke

не следует неожиданно превращать в стресс-тест машины разработчика.

Порядок первого запуска

cp .env.example .env

make doctor
make build
make test
make up

После старта:

make urls

Основные точки:

  • 127.0.0.1:8080  ? public Nginx
  • 127.0.0.1:8088  ? direct application
  • 127.0.0.1:8081  ? Actuator
  • 127.0.0.1:3000  ? Grafana

Проверка базового пути на БД

make mode-db

curl -i \
  -H 'X-Request-Id: article2-db-001' \
  'http://localhost:8080/api/v1/offers/sku-42?region=moscow'

Ожидаем:

HTTP/1.1 200 OK
X-Request-Id: article2-db-001
X-Offer-Source: POSTGRESQL
X-Offer-Version: 42

Проверка Redis

make mode-redis
make cache-clear

Первый запрос:

X-Offer-Source: POSTGRESQL

следующий запрос того же ключа:

X-Offer-Source: REDIS

Проверка local + Redis

make mode-local
make cache-clear

Первое чтение прогревает уровни, после чего повторный запрос должен приходить из:

X-Offer-Source: LOCAL_CACHE

Проверка наблюдаемости

make dashboards-validate
make observability-verify

Минимальная проверка должна доказать не наличие контейнеров, а прохождение данных.

Для Mimir:

  • есть метрики HTTP
  • есть метрики JVM
  • есть метрики Hikari
  • есть кастомные метрики приложения
  • есть PostgreSQL экспортер
  • есть Redis экспортер

Для Loki:

отправили запрос с известным X-Request-Id
            ?
нашли именно этот запрос

Для Tempo:

тот же запрос
    ?
HTTP спан
    ?
JDBC спан

Для Grafana:

  • источники данных
  • дашборды опубликованы
  • запросы возвращают данные

[ВСТАВИТЬ СКРИНШОТ №5]
Терминал с make smoke, рядом Grafana Load Testing dashboard с offered RPS, completed RPS, p99 и dropped_iterations.

Что фиксировать для настоящей проверки

Когда мы в следующей статье перейдем от smoke к измерениям предела, каждый запуск должен иметь собственную идентичность:

  • timestamp
  • testId
  • scenario
  • git SHA
  • git dirty / clean
  • Java version
  • Spring Boot version
  • cache mode
  • Redis TTL
  • local TTL
  • Hikari maximum pool size
  • TARGET_RPS
  • duration
  • характеристики машины

Тогда через год файл db-baseline-004 останется инженерным результатом.

Без этого он превратится просто в JSON с красивым p99 неизвестного происхождения.

Почему локальный Compose не доказывает 1M RPS

На одной машине одновременно могут конкурировать:

  • k6
  • Nginx
  • Java
  • PostgreSQL
  • Redis
  • Grafana
  • Mimir
  • Loki
  • Tempo
  • Alloy
  • Docker

Они разделяют CPU, память, сеть, файловую систему и Docker daemon.

Поэтому локальная лаборатория прекрасно подходит для:

  • проверки пути чтения
  • поиска ошибок
  • изучения метрик
  • сравнения контролируемых изменений
  • воспроизводимости сценариев

но не для утверждения:

“Эта архитектура выдерживает 1M RPS в ПРОДе”.

Такое утверждение потребует совсем другого стенда.

Где базовая реализация должна ломаться

Хорошая базовая реализация нужна не только для базового корректного пути.

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

Устаревшие данные

Самая простая проблема cache-aside выглядит так:

PostgreSQL
version = 42

Redis
version = 41

Публичный GET попадает в кэш и возвращает старый снепшот.

Сам TTL проблему не устраняет. Он лишь ограничивает максимальное время жизни старой копии. Redis действительно предоставляет expiration как штатную семантику ключа: после истечения TTL ключ больше не должен участвовать в обычном чтении. 

Но если контракт свежести равен пяти секундам, TTL должен быть частью этой модели, а не случайным числом:

  • freshness budget
  •       ?
  • cache TTL
  •       ?
  • invalidation lag
  •       ?
  • fallback behaviour

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

На простом уровне:

UPDATE PostgreSQL
      ?
COMMIT
      ?
evict cache

Spring позволяет привязать обработчик события к фазе успешного коммита через @TransactionalEventListener. По умолчанию такой listener связан с фазой коммита. 

Но это решает только порядок.

Остается другой сценарий:

PostgreSQL COMMIT OK
        ?
процесс упал
        ?
Redis eviction не выполнен

Именно здесь возникает проблема двойной записи.

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

Это классический Transactional Outbox. Это решение проблемы двойной записи, когда изменение базы и уведомление другой системы не могут быть объединены в одну распределенную транзакцию. События при такой схеме могут доставляться повторно, поэтому обработчик должен быть идемпотентным. 

Ранее этот паттерн уже разобран отдельно. 

Для Redis eviction идемпотентность естественна:

DEL existing key
? key absent

DEL same key again
? key still absent

Во второй статье outbox не нужен: текущий пользовательский контур ориентирован на чтение.

Но граница уже понятна.

Cache stampede

Теперь более интересный отказ.

Представим горячий sku-42.

TTL истек.

В течение 200 мс пришло 3 000 запросов:

3000 requests
      ?
3000 cache miss
      ?
3000 PostgreSQL SELECT

Это и есть проблема stampede.

Увеличить Hikari pool в такой момент очень хочется.

Но этим мы можем просто позволить большему числу одинаковых запросов одновременно ударить по PostgreSQL.

Правильнее уменьшать количество повторяющейся работы.

Первый механизм:

request coalescing / single-flight

Идея:

1000 miss одного ключа
          ?
    один loader
          ?
    PostgreSQL
          ?
  общий результат
          ?
1000 HTTP responses

Для одного JVM Caffeine уже предоставляет атомарное вычисление отсутствующего значения через:

cache.get(key, mappingFunction)

что дает естественную точку для локальной координации загрузки. 

Но локальный single-flight нельзя переоценивать.

При 20 pod:

pod-1  ? 1 loader
pod-2  ? 1 loader
...
pod-20 ? 1 loader

Это все равно до 20 одинаковых запросов в PostgreSQL.

Обычно это все равно намного лучше, чем тысячи.

Вторая проблема:

10 000 miss по 10 000 разным ключам

Single-flight здесь почти не помогает.

Поэтому настоящая защита должна складываться:

per-key single-flight
+
bounded loader concurrency
+
TTL jitter
+
load shedding
+
cold-cache capacity tests
+
stale-while-revalidate,
если бизнес допускает передачу устаревших данных

TTL jitter нужен, чтобы большое количество ключей с одинаковым временем жизни не истекало синхронно. Тот же принцип разнесения периодической работы используется и для retry/backoff: одинаковые таймеры создают коррелированные пики, а jitter распределяет их во времени. 

Негативное кэширование

Следующая ловушка – отсутствие объекта.

GET /offers/not-existing-sku
      ?
PostgreSQL
      ?
NOT_FOUND

Если клиент или бот перебирает огромное количество несуществующих SKU, каждый такой запрос может создавать реальную работу в БД.

Поэтому кэшировать NOT_FOUND иногда полезно.

Особенно против:

  • перебора идентификаторов
  • дорогих поисков
  • частых повторов одного отсутствующего ключа

Но отсутствие – тоже состояние, которое может устареть.

12:00:00 ? object absent
12:00:00 ? NOT_FOUND cached
12:00:01 ? object created
12:00:02 ? cache still says NOT_FOUND

Отсюда четыре правила:

  1. негативное кэширование должно включаться осознанно
  2. TTL отсутствия обычно короче позитивного TTL
  3. создание объекта должно инвалидировать NOT_FOUND
  4. максимальная жизнь “негативной записи” должна соответствовать контракту “свежести” данных

В базовой реализации негативное кэширование подготовлено, но по умолчанию выключено:

app:
  cache:
    negative:
      enabled: false
      ttl: 1s

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

Холодный кэш

Самый опасный кэшовый тест часто вообще не связан с latency Redis.

Он выглядит так:

Redis flush / restart
        ?
hit ratio = 0%
        ?
весь поток идет в PostgreSQL

Если при коэффициенте попадания в кэш 97% база видела только 3% трафика на чтение, а после очистки внезапно получила почти 100%, Redis из “необязательного ускорителя” мгновенно превращается в архитектурно критичный компонент.

Именно поэтому базовая реализация содержит:

make load-cold

и возможность явно очистить кэш.

Мы обязаны знать ответ на вопрос:

Что произойдет с системой, если кэш исчезнет прямо сейчас?

Не теоретически, а по графикам.

Пять проверочных вопросов

Ниже пять ситуаций, которые удобно проанализировать ка при чтении статьи.

Интерактив №1

Карточка товара деградирует: p99 вырос до 1,8 секунды, Hikari pending периодически больше нуля, CPU PostgreSQL около 40%, а 2% товаров создают 70% чтений. Что делаем первым?

A. Сразу кэшируем горячие товары в Redis.

B. Фиксируем read paths, требования к свежести, исходные метрики и проверяем cold-cache capacity PostgreSQL.

C. Увеличиваем Hikari pool.

D. Добавляем read replica.

Правильный ответ: B.

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

Интерактив №2

PATCH успешно изменил запись. В PostgreSQL version=42, но следующий GET возвращает из Redis version=41. Какое изменение лучше всего исправляет проблему?

A. Записывать новый DTO в Redis до commit.

B. Удалять cache key до выполнения UPDATE.

C. Инвалидировать точный ключ после успешного commit, а потерю инвалидации сделать наблюдаемой и повторяемой.

D. Увеличить TTL.

Правильный ответ: C.

Для простого случая достаточно корректной after-commit инвалидации. Если потеря eviction недопустима, следующий уровень – transactional outbox.

Интерактив №3 – выберите все подходящие варианты

@Cacheable(
    cacheNames = "account-view",
    key = "#accountId"
)
public AccountDto getAccount(
        UUID userId,
        UUID tenantId,
        UUID accountId
) {
    authorizationService.checkAccess(
        userId,
        tenantId,
        accountId
    );

    return accountRepository.findAccount(accountId);
}

Почему код опасен?

A. Redis в принципе нельзя использовать для multi-tenant данных.

B. При cache hit тело метода может не выполниться, а значит checkAccess() будет пропущен.

C. Результат зависит от security context, но ключ содержит только accountId.

D. Проблема исчезает при TTL менее секунды.

Правильные ответы: B + C.

Проблема одновременно в границе кэшируемой операции и в ключе. Короткий TTL только сокращает окно утечки, но не делает схему корректной.

Интерактив №4

У горячего ключа истек TTL. За 200 мс произошло 3 000 промахов, Hikari pending вырос до 500. Что не следует делать первым?

A. Увеличивать Hikari pool и max_connections, чтобы больше запросов одновременно пошли в PostgreSQL.

B. Добавить один loader на ключ.

C. Ограничить общее число конкурентных загрузчиков.

D. Для допустимо устаревших данных рассмотреть stale-while-revalidate.

Правильный ответ: A.

Stampede – проблема дублирования работы. Расширение канала в PostgreSQL не уменьшает число одинаковых SELECT.

Интерактив №5 – выберите все подходящие варианты

Redis был очищен во время релиза. Hit ratio упал практически до нуля, DB QPS вырос в десятки раз, Hikari saturate, p99 ушел в секунды. Какие проблемы существовали еще до аварии?

A. Capacity planning и нагрузочные тесты проверяли только прогретый кэш.

B. Не был выполнен полный синхронный warm-up всех ключей.

C. Каждый cache miss мог бесконтрольно обратиться в PostgreSQL.

D. Redis назывался необязательным ускорителем, хотя PostgreSQL не был рассчитан на полный read traffic.

Правильные ответы: A + C + D.

Полный warm-up может быть одной из техник, но он не является фундаментальным требованием корректной системы. Главные проблемы – неизвестная degraded-mode capacity, отсутствие ограничения blast radius и скрытая архитектурная зависимость от кэша.

Практический сценарий воспроизведения

Теперь соберем все в один сценарий, который должен повторить любой читатель репозитория.

Начинаем с чистого окружения:

cp .env.example .env

make doctor
make build
make test
make up

Проверяем контейнеры:

make ps

Проверяем URLs:

make urls

Проверяем дашборды:

make dashboards-validate

Далее – путь чтения из БД:

make mode-db

Запрос:

curl -i \
  -H 'X-Request-Id: article2-check-db' \
  'http://localhost:8080/api/v1/offers/sku-42?region=moscow'

Ожидаем:

X-Request-Id: article2-check-db
X-Offer-Source: POSTGRESQL
X-Offer-Version: 42

Проверяем обход cache:

curl -i \
  'http://localhost:8080/api/v1/offers/sku-42?region=moscow&cache=false'

Источник снова:

X-Offer-Source: POSTGRESQL

Теперь Redis:

make mode-redis
make cache-clear

Первый запрос:

X-Offer-Source: POSTGRESQL

Второй:

X-Offer-Source: REDIS

Теперь локальный кэш:

make mode-local
make cache-clear

После прогрева:

X-Offer-Source: LOCAL_CACHE

Проверяем внутреннюю диагностику:

make cache-stats

Проверяем базовую нагрузку:

make smoke

Проверяем телеметрию:

make observability-verify

После этого один заранее известный: X-Request-Id должен находиться в Loki.

Из соответствующего лога должен быть доступен:

traceId

А в Tempo для него должны быть видны как минимум:

HTTP
?
JDBC

или, в режиме Redis:

HTTP
?
Redis

В Mimir одновременно должны существовать:

  • метрики HTTP
  • метрики JVM
  • метрики Tomcat
  • метрики Hikari
  • кастомные метрики сервиса
  • метрики PostgreSQL
  • метрики Redis

Если этот сценарий повторяется с чистого окружения, мы получили то, чего не было в начале статьи:

известный код
+
известный dataset
+
известный read path
+
известная нагрузка
+
известное состояние cache
+
метрики
+
логи
+
trace

Это и есть наша базовая реализация. Не конкретное значение p99. Не конкретное число RPS.

А воспроизводимое исходное состояние системы.

Заключение

Мы еще не разогнали Offer Snapshot Service.

И это сознательное решение, после второй части у нас есть:

  • фиксированный АПИ контракт
  • детерминированный набор данных
  • PostgreSQL как источник истины
  • явный путь чтение JDBC
  • режим работы только с БД
  • режим работы с Redis
  • режим работы Caffeine + Redis
  • игнорирование (обход) кэша
  • диагностические заголовки
  • внутренняя диагностика
  • метрики Hikari
  • метрики JVM
  • гистрограммы HTTP
  • структурированные логик
  • корреляция requestId / traceId
  • трейсы OpenTelemetry
  • Grafana Alloy
  • Mimir
  • Loki
  • Tempo
  • Grafana дашборды
  • k6 модель нагрузки
  • сценарии холодного кэша и горячего кэша
  • входные точки для JFR и thread-dump
  • повторяемые make-команды

И гораздо важнее – теперь мы знаем, что означает улучшение.

Если после изменения p99 снизился, мы должны проверить:

  • не изменился ли RPS?
  • не изменился ли cache hit ratio?
  • не исчезли ли ошибки?
  • не появился ли dropped_iterations?
  • не изменился ли набор данных?
  • не изменился ли режим кэша?
  • не поменялись ли JVM / Spring / Redis?
  • не переместилась ли очередь
  • из Hikari в PostgreSQL?
  • не измерили ли мы локальный хит вместо чтения из БД?

Только после этого изменение можно назвать оптимизацией.

Именно поэтому во второй статье нет победной таблицы:

Java = N RPS

Такая цифра сейчас была бы скорее вредной. Локальный smoke тест доказывает, что “лаборатория” работает.

Он не доказывает производственную мощность архитектуры.

В следующей части мы сделаем обратное тому, что разработчик обычно хочет делать со своим сервисом: начнем намеренно доводить его до насыщения.

Будем увеличивать arrival rate и смотреть, какой сигнал первым перестанет быть спокойным:

  • CPU?
  • Tomcat busy threads?
  • Hikari pending?
  • PostgreSQL?
  • GC?
  • Redis?
  • Nginx?
  • сам генератор нагрузки?

Затем сформулируем гипотезу.

После этого откроем JFR или трассировки. И только когда боттлнек будет подтвержден несколькими независимыми сигналами, разрешим себе первое изменение.

Главный результат второй части поэтому можно сформулировать одной фразой:

Производительность начинается не с быстрого кода. Она начинается с системы, в которой можно доказать, почему код стал быстрее.

Источники

Loading