Коротко: во второй части цикла мы не разгоняем сервис и не пытаемся получить красивую цифру RPS. Мы собираем воспроизводимый базовый стенд, на котором любой следующий результат можно объяснить: фиксируем API и набор данных, делаем три явно переключаемых пути чтения, добавляем метрики, структурированные логи, трассировки, Grafana и k6, а затем проверяем, что для каждого запроса можем ответить, откуда пришли данные и где было потрачено время.
Результат этой статьи – не “Java держит N RPS”, а система, в которой следующую оптимизацию уже нельзя будет обосновать ощущениями.
Оглавление
- Введение
- Три режима чтения и явный cache-aside
- Наблюдаемость как часть базовой реализации
- Нагрузка, тесты и воспроизводимость
- Практический сценарий воспроизведения
- Заключение
Введение
В первой части цикла мы начали с неприятной, но необходимой мысли: фраза “сервис держит 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 секунд |
| Availability | 99,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, один набор данных и один контур наблюдаемости.
| Режим | Последовательность | Плюсы | Минусы | Когда используем |
|---|---|---|---|---|
| none | HTTP ? PostgreSQL | Прозрачно показывает стоимость пути чтения из БД, простейшая семантика | Максимальная нагрузка на БД | Нулевая точка сравнения |
| redis | HTTP ? Redis ? PostgreSQL при промахе | Убирает большую часть повторных чтений из БД | Сеть, сериализация, устаревшие данные, отдельный отказоустойчивый компонент | Измеряем цену распределённого кэша |
| local-redis | HTTP ? 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 count | http_server_requests_seconds_count |
| HTTP latency histogram | http_server_requests_seconds_bucket |
| JVM memory | jvm_memory_used_bytes |
| GC pauses | jvm_gc_pause_seconds_count |
| Live threads | jvm_threads_live_threads |
| Process CPU | process_cpu_usage |
| System CPU | system_cpu_usage |
| Tomcat busy threads | tomcat_threads_busy_threads |
| Hikari active | hikaricp_connections_active |
| Hikari idle | hikaricp_connections_idle |
| Hikari pending | hikaricp_connections_pending |
| Чтения Offer | offer_snapshot_reads_total |
| Время чтения | offer_snapshot_read_duration_seconds |
| Операции кэша | offer_cache_operations_total |
| Ошибки Redis | offer_redis_errors_total |
| Ошибки PostgreSQL | offer_database_errors_total |
| Запросы в обработке | offer_requests_in_flight |
| Возраст snapshot | offer_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 / Overview | RPS, p50/p95/p99/p999, ошибки, CPU, Hikari, source distribution | Обязательный скриншот |
| Java Baseline / HTTP & Tomcat | HTTP latency, статусы, busy threads, Nginx | Скриншот при нагрузке |
| Java Baseline / JVM | heap, GC, threads, CPU | Скриншот при smoke/baseline |
| Java Baseline / PostgreSQL & Hikari | pool, pending, DB connections, transactions | Обязательный скриншот |
| Java Baseline / Cache | local/Redis hits, misses, source distribution | Скриншот трёх режимов |
| Java Baseline / Logs | ошибки, request ID, поиск | Скриншот одного запроса |
| Java Baseline / Traces | slow/failed traces, JDBC/Redis spans | Обязательный скриншот |
| Observability / Stack Health | Alloy, Mimir, Loki, Tempo, exporters | Достаточно контрольного кадра |
| Load Testing / k6 | offered/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-redis | Redis + DB fallback |
| baseline-local | Caffeine + 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
Отсюда четыре правила:
- негативное кэширование должно включаться осознанно
- TTL отсутствия обычно короче позитивного TTL
- создание объекта должно инвалидировать NOT_FOUND
- максимальная жизнь “негативной записи” должна соответствовать контракту “свежести” данных
В базовой реализации негативное кэширование подготовлено, но по умолчанию выключено:
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 или трассировки. И только когда боттлнек будет подтвержден несколькими независимыми сигналами, разрешим себе первое изменение.
Главный результат второй части поэтому можно сформулировать одной фразой:
Производительность начинается не с быстрого кода. Она начинается с системы, в которой можно доказать, почему код стал быстрее.
Источники
- Первая статья цикла – постановка задачи, SLO, нагрузочная модель и правила будущих сравнений.
- Spring Boot Reference – Actuator, Prometheus, JVM, Tomcat, DataSource и HikariCP metrics.
- Spring Boot Reference – liveness/readiness и внешние зависимости.
- Micrometer Reference – percentile histograms и агрегируемые перцентили.
- Grafana k6 – open/closed workload models и constant-arrival-rate.
- Grafana Alloy и Mimir – сбор Prometheus/OpenTelemetry telemetry и локальный monolithic Mimir.
- PROSELYTE – наблюдаемость в микросервисах и корреляция метрик, логов и трассировок.
- PROSELYTE – Transactional Outbox Pattern.
![]()
You must be logged in to post a comment.