Содержание
- Почему советы из 2009 больше не работают
- Слои архитектуры: от запроса до данных
- Virtual Threads (Project Loom): когда помогают, когда мешают
- Kafka-паттерны для Java-бэкенда
- Стратегия базы данных: PostgreSQL, реплики, CQRS
- Кэширование: L1 + L2 и типичные ошибки
- Отказоустойчивость: Circuit Breaker, Bulkhead, Retry
- Observability: логи, трейсы, алерты
- Что значит 10 000 RPS на практике
- Антипаттерны, которые мы находим на аудитах
- Чек-лист перед production
Почему советы из 2009 больше не работают
Если вы загуглите «java highload архитектура» на русском, первый результат — статья 2009 года. Второй — пересказ той же статьи. Третий — вводная презентация с конференции 2014-го.
Проблема не в том, что там написано неправильно. Проблема в том, что мир поменялся.
В 2009-м highload на Java означал: Tomcat, JBoss, один толстый WAR, Oracle Database, SOAP-интерфейсы. Горизонтальное масштабирование — купить ещё один сервер Dell за $15 000. Мониторинг — смотреть в catalina.out и надеяться на лучшее.
Сегодня стек другой. Java 21 с virtual threads. Spring Boot 3 на GraalVM native image. Kubernetes. Kafka вместо RabbitMQ. PostgreSQL вместо Oracle. ClickHouse для аналитики. OpenTelemetry вместо «добавь log.info побольше». Redis Cluster вместо EhCache.
Но главное — изменились не инструменты, а паттерны. CQRS, Event Sourcing, saga-оркестрация, Outbox Pattern — всего этого не было в production-практике в 2009-м.
Мы в Новаком строим highload-системы для банков, страховых компаний, e-commerce-платформ. Это статья из практики — не из документации Spring. Каждый раздел подкреплён кодом, метриками и историями отладки из реальных проектов (имена клиентов обезличены по NDA).
Если вы проектируете систему на 10 000+ RPS или хотите понять, почему ваша текущая не тянет 2 000 — это для вас.
Слои архитектуры: от запроса до данных
Highload-система на Java в 2026 году — это не один Spring Boot-сервис. Это четыре слоя, каждый со своей зоной ответственности. Мы строим эти слои при разработке цифровых платформ и веб-сервисов для enterprise-клиентов.
Слой 1: API Gateway
Входная точка. Принимает все внешние запросы, терминирует TLS, маршрутизирует, лимитирует.
Выбор:
- Kong / APISIX — если нужна гибкость плагинов.
- Spring Cloud Gateway — если вся экосистема на Spring и хочется единый стек.
- Envoy — если уже на Istio service mesh.
В банковском проекте мы начали со Spring Cloud Gateway. На 5 000 RPS заметили, что gateway сам становится bottleneck — Netty event loop thread pool забивался из-за тяжёлых фильтров (JWT-валидация с обращением к Redis за каждый запрос). Перенесли JWT-проверку в sidecar Envoy, gateway стал thin-proxy. Latency на gateway упала с 12 ms до 2 ms.
Слой 2: Service Mesh / межсервисная коммуникация
Между gateway и вашими доменными сервисами — прослойка, которая обеспечивает mTLS, load balancing, circuit breaking, retry.
Два пути:
- Istio/Envoy sidecar — всё из инфраструктуры, код чистый.
- Spring Cloud + Resilience4j — всё в коде, без дополнительного слоя.
Мы предпочитаем комбинацию: mTLS и балансировка — в mesh (Istio), circuit breaker и retry — в коде (Resilience4j). Причина: circuit breaker с fallback-логикой — это бизнес-решение, его место в коде, а не в yaml-конфиге sidecar-а.
Слой 3: Доменные сервисы
Основная логика. Здесь живут ваши bounded context-ы, бизнес-правила, координация.
Золотое правило highload-архитектуры: один запрос — один сервис-владелец. Если для обработки HTTP-запроса вы синхронно вызываете 4 внутренних сервиса — это не микросервисы, это распределённый монолит. У него все минусы микросервисов (сетевые задержки, partial failure) и ни одного плюса (независимый деплой, изоляция).
Слой 4: Data Layer
Данные. PostgreSQL для транзакционных нагрузок, ClickHouse/TimescaleDB для аналитики, Redis для кэша и короткоживущих данных, S3 для объектов.
Ключевой принцип: каждый сервис владеет своей базой. Shared database между микросервисами — антипаттерн, который убивает масштабируемость. Мы видели e-commerce-проект, где 12 сервисов ходили в одну PostgreSQL. При 3 000 RPS connection pool на 300 соединений был забит наглухо.
Virtual Threads (Project Loom)
Virtual threads — самое значимое изменение в Java за десятилетие. И самое непонятое.
Суть
До Java 21 каждый поток в Java — это OS-тред. Дорогой: 1 MB стека, переключение контекста в ядре ОС. Поэтому Tomcat держит пул из 200 потоков, и 200 параллельных запросов — потолок без async.
Virtual threads — лёгкие потоки, управляемые JVM. Создать миллион virtual threads стоит десятки мегабайт памяти. Блокирующий вызов (JDBC, HTTP, sleep) не блокирует OS-тред — JVM паркует virtual thread и отдаёт carrier thread другому.
Когда помогают
Ваш код IO-bound: обращения к БД, HTTP-вызовы к внешним API, чтение из Redis. Типичная ситуация для большинства бизнес-сервисов. Включаете virtual threads — и вместо 200 параллельных запросов обрабатываете 10 000, без переписывания на реактивный стек.
Конфигурация Spring Boot 3:
# application.yml
spring:
threads:
virtual:
enabled: true
server:
tomcat:
threads:
max: 10000 # с virtual threads можно ставить тысячи
Один yml-флаг. Серьёзно.
В страховом проекте (Spring Boot 3.2, JDK 21) мы включили virtual threads на сервисе расчёта премий. Этот сервис на каждый запрос делал 3 вызова: в справочник тарифов (Redis), в историю клиента (PostgreSQL), в сервис скоринга (HTTP). Каждый вызов — 10-50 ms latency.
Результат: throughput вырос с 800 RPS до 4 200 RPS на том же hardware. p99 latency снизилась с 320 ms до 95 ms. Единственное изменение — spring.threads.virtual.enabled: true и увеличение пула коннектов HikariCP.
Когда НЕ помогают (и вредят)
CPU-bound задачи. Если ваш сервис занимается расчётами, шифрованием, парсингом XML — virtual threads не дадут прироста. Вы упрётесь в CPU, а не в потоки.
Synchronized блоки. Virtual thread, зашедший в synchronized, пиннит carrier thread. Если таких мест много — вы быстро израсходуете все carrier threads (по умолчанию = числу CPU ядер), и система встанет. Заменяйте synchronized на ReentrantLock:
// ❌ Пиннинг carrier thread
private synchronized void updateCounter() {
counter++;
}
// ✅ Без пиннинга
private final ReentrantLock lock = new ReentrantLock();
private void updateCounter() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
ThreadLocal с тяжёлыми объектами. Virtual threads дешёвые, их создаётся много. Если каждый хранит в ThreadLocal буфер на 10 KB — миллион threads = 10 GB памяти. Мы наблюдали такую ситуацию с библиотекой XML-парсинга, которая кэшировала SAXParser в ThreadLocal. Переход на virtual threads + утечка памяти через 20 минут.
JDBC-драйверы с synchronized internals. Старые версии Oracle JDBC driver и MySQL Connector/J содержат synchronized-секции внутри. Перед включением virtual threads — обновите драйверы или протестируйте под нагрузкой с -Djdk.tracePinnedThreads=full.
Kafka-паттерны для Java-бэкенда
Kafka в highload-системе — это не «очередь сообщений». Это распределённый лог, который становится центральной нервной системой архитектуры.
Exactly-once semantics
По умолчанию Kafka даёт at-least-once доставку. Сообщение может быть обработано дважды. Для платёжного сервиса — неприемлемо.
Exactly-once в Spring Kafka:
@Configuration
class KafkaProducerConfig {
@Bean
fun producerFactory(): ProducerFactory<String, PaymentEvent> {
val props = mapOf(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "kafka:9092",
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true,
ProducerConfig.TRANSACTIONAL_ID_CONFIG to "payment-tx-",
ProducerConfig.ACKS_CONFIG to "all",
ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 5,
)
val factory = DefaultKafkaProducerFactory<String, PaymentEvent>(props)
factory.setTransactionIdPrefix("payment-tx-")
return factory
}
@Bean
fun kafkaTransactionManager(
producerFactory: ProducerFactory<String, PaymentEvent>
) = KafkaTransactionManager(producerFactory)
}
Три флага: enable.idempotence=true, transactional.id, acks=all. Продюсер гарантирует, что дубликатов не будет, даже при retry.
На стороне consumer — другая половина задачи: read-process-write в одной транзакции.
Idempotency на consumer
Even с exactly-once semantics на продюсере, consumer может обработать сообщение повторно (rebalance, crash до commit offset). Защита — идемпотентность на уровне бизнес-логики:
@KafkaListener(topics = ["payments"])
fun handlePayment(record: ConsumerRecord<String, PaymentEvent>) {
val eventId = record.headers()
.lastHeader("idempotency-key")?.value()
?.let { String(it) }
?: record.key()
// Атомарная проверка + вставка
val inserted = jdbcTemplate.update(
"""INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON CONFLICT (event_id) DO NOTHING""",
eventId
)
if (inserted == 0) {
log.info("Duplicate event $eventId, skipping")
return
}
paymentService.process(record.value())
}
ON CONFLICT DO NOTHING — один SQL, без race condition. Мы ставим индекс на event_id с TTL (через pg_cron удаляем записи старше 7 дней).
Dead Letter Queue
Сообщение, которое не удалось обработать после N retry — не должно блокировать очередь. Dead Letter Topic:
@Bean
fun kafkaListenerContainerFactory(
consumerFactory: ConsumerFactory<String, Any>
): ConcurrentKafkaListenerContainerFactory<String, Any> {
val factory = ConcurrentKafkaListenerContainerFactory<String, Any>()
factory.consumerFactory = consumerFactory
val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
TopicPartition("${record.topic()}.DLT", record.partition())
}
factory.setCommonErrorHandler(
DefaultErrorHandler(recoverer, FixedBackOff(1000L, 3L))
)
return factory
}
Три ретрая с паузой 1 секунда. После третьего — в Dead Letter Topic. Мониторинг: алерт, если в DLT появляются сообщения. На одном проекте мы обнаружили 40 000 сообщений в DLT, которые никто не смотрел три месяца. Они содержали неконвертированные валютные операции. Три месяца данных потерялись бы, если бы не аудит.
Партиционирование и ordering
Частая ошибка: создать topic с 1 партицией. Один consumer, один поток обработки. При 10 000 msg/sec — не вывозит.
Правило: число партиций >= числу consumer-инстансов. Мы обычно ставим 12-24 партиции для production topics. Key-based partitioning обеспечивает ordering в пределах одного ключа (например, все операции одного клиента идут в одну партицию).
Стратегия базы данных
PostgreSQL — отличная БД для highload. Но только если вы не обращаетесь с ней как с «просто базой».
Read replicas
В e-commerce-проекте соотношение read/write было 15:1. Один PostgreSQL instance на 8 vCPU, 32 GB RAM держал 2 000 RPS. На распродажах приходило 8 000. Решение — read replicas с routing на уровне Spring:
@Configuration
class DataSourceConfig {
@Bean
@Primary
fun dataSource(): DataSource {
val routing = ReadWriteRoutingDataSource()
routing.setTargetDataSources(mapOf(
"primary" to primaryDataSource(),
"replica" to replicaDataSource(),
))
routing.setDefaultTargetDataSource(primaryDataSource())
return routing
}
}
class ReadWriteRoutingDataSource : AbstractRoutingDataSource() {
override fun determineCurrentLookupKey(): Any =
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly())
"replica"
else
"primary"
}
Теперь @Transactional(readOnly = true) автоматически идёт на реплику. Пометили все GET-endpoint'ы readOnly = true — primary сразу разгрузился на 70%.
HikariCP: настройки, которые реально влияют
Дефолтный HikariCP-конфиг — 10 соединений, connectionTimeout = 30s. Для highload это катастрофа. Наша стартовая конфигурация:
spring:
datasource:
hikari:
maximum-pool-size: 30 # CPU cores × 2 + disk spindles
minimum-idle: 10
connection-timeout: 2000 # 2 сек, не 30!
idle-timeout: 300000 # 5 мин
max-lifetime: 1800000 # 30 мин
leak-detection-threshold: 5000
validation-timeout: 1000
Почему maximum-pool-size: 30? Формула PostgreSQL: connections = CPU cores × 2 + effective_spindle_count. Для SSD — spindle count = 1. На 16-ядерном сервере: 16 × 2 + 1 = 33. Больше — не значит лучше. 200 соединений к PostgreSQL — это 200 процессов, каждый жрёт память, каждый конкурирует за CPU. Мы видели проект, где max-pool-size: 200 приводил к p99 latency хуже, чем с 20.
connection-timeout: 2000 — если за 2 секунды соединение не получено, лучше вернуть 503, чем ждать 30 секунд. Пользователь всё равно уйдёт.
leak-detection-threshold: 5000 — HikariCP залогирует stack trace, если соединение не возвращено за 5 секунд. Это ваш лучший друг при поиске утечек коннектов. В одном банковском проекте мы включили leak detection и за час нашли endpoint, который открывал транзакцию, делал HTTP-вызов к внешней системе (2-5 секунд), и только потом коммитил. Соединение удерживалось всё это время.
CQRS: PostgreSQL + ClickHouse
Для систем, где запросы чтения кардинально отличаются от запросов записи — CQRS. Типичный кейс: транзакционный сервис (запись заказа, обновление баланса) + аналитический дашборд (агрегации по миллионам записей за период).
PostgreSQL для write-модели. ClickHouse для read-модели. Между ними — Kafka Connect (Debezium) для CDC:
PostgreSQL → Debezium CDC → Kafka → ClickHouse Kafka Engine → Materialized View
В e-commerce-проекте аналитический запрос «средний чек за последние 90 дней по категориям» на PostgreSQL (130 млн строк) выполнялся 45 секунд. На ClickHouse тот же запрос — 0.3 секунды. Это не опечатка.
Цена: eventual consistency между write и read моделями. Задержка CDC обычно 1-5 секунд. Для аналитики — приемлемо. Для баланса счёта — нет (читайте из primary PostgreSQL).
Кэширование: L1 + L2 и типичные ошибки
Кэш — быстрый способ увеличить throughput. И быстрый способ добавить в систему неочевидные баги.
Двухуровневая схема
L1 — Caffeine (in-process, 0 latency):
- Горячие данные: справочники, конфигурация, курсы валют.
- Маленький объём: 1 000–10 000 записей.
- TTL: 30 секунд–5 минут.
L2 — Redis (remote, 1-3 ms latency):
- Всё остальное: сессии, каталоги, результаты вычислений.
- Объём: сотни тысяч–миллионы ключей.
- TTL: 5 минут–24 часа.
@Service
class CatalogService(
private val redis: RedisTemplate<String, Catalog>,
private val repo: CatalogRepository,
) {
private val localCache: Cache<String, Catalog> = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(Duration.ofSeconds(30))
.build()
fun getCatalog(categoryId: String): Catalog {
// L1
localCache.getIfPresent(categoryId)?.let { return it }
// L2
val fromRedis = redis.opsForValue().get("catalog:$categoryId")
if (fromRedis != null) {
localCache.put(categoryId, fromRedis)
return fromRedis
}
// DB
val fresh = repo.loadCatalog(categoryId)
redis.opsForValue().set("catalog:$categoryId", fresh, Duration.ofMinutes(10))
localCache.put(categoryId, fresh)
return fresh
}
}
Cache Stampede — проблема, которую не замечают
50 нод приложения. TTL кэша истёк. Все 50 одновременно идут в PostgreSQL за свежими данными. CPU базы — 100%. Latency — в потолок.
Мы подробно разбирали эту проблему и три стратегии защиты (single-flight, probabilistic early expiration, stale-while-revalidate) в отдельной статье про cache stampede. Если вы используете Redis в production — прочитайте, это одна из самых частых причин «необъяснимых» спайков latency.
Антипаттерны кэширования
Кэширование null-результатов без TTL. Запрос пользователя, которого нет в БД, возвращает null. Вы кэшируете null. Пользователь регистрируется. Следующий запрос получает null из кэша. Кэш врёт.
Одинаковый TTL для всех ключей. Если 10 000 ключей истекают одновременно — это тот же stampede, но по 10 000 запросам сразу. Добавляйте jitter: TTL + random(0, TTL * 0.1).
Кэширование изменяемых объектов. Caffeine хранит ссылки, не копии. Если вы достали объект из кэша и поменяли поле — вы поменяли объект в кэше для всех потоков. Без блокировки. Data race гарантирован.
Инвалидация «на всякий случай». Мы видели проект, где при обновлении одного товара инвалидировался весь каталог (5 000 товаров). Hit rate кэша — 12%. При hit rate ниже 80% кэш — обуза, а не ускорение.
Отказоустойчивость
Система на 10 000 RPS не может позволить себе каскадный отказ. Один упавший downstream-сервис не должен уронить всё остальное.
Circuit Breaker
Подробный гайд по настройке Resilience4j Circuit Breaker для Spring Boot — в нашей отдельной статье. Здесь — конфиг, который мы используем как стартовую точку:
resilience4j:
circuitbreaker:
instances:
paymentService:
sliding-window-type: COUNT_BASED
sliding-window-size: 100
failure-rate-threshold: 50
slow-call-rate-threshold: 80
slow-call-duration-threshold: 3s
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 10
minimum-number-of-calls: 20
Ключевое: slow-call-duration-threshold: 3s и slow-call-rate-threshold: 80. Это значит, что circuit breaker откроется не только при ошибках, но и когда 80% запросов стали медленнее 3 секунд. Slow calls — такой же сигнал деградации, как и ошибки.
Bulkhead — изоляция потоков
resilience4j:
bulkhead:
instances:
paymentService:
max-concurrent-calls: 25
max-wait-duration: 500ms
25 параллельных вызовов к payment-сервису. 26-й — получит отказ через 500 ms. Без bulkhead один медленный downstream-сервис может съесть все потоки вашего приложения.
Совет из практики: считайте bulkhead limit от общего числа потоков. Если у вас 200 Tomcat-потоков и 4 downstream-сервиса — не давайте одному сервису больше 50 слотов (25%). Оставьте запас для health check и служебных endpoint-ов.
Retry с exponential backoff и jitter
@Bean
fun retryConfig(): RetryConfig = RetryConfig.custom<Any>()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
Duration.ofMillis(500), // initial interval
2.0, // multiplier
Duration.ofSeconds(10), // max interval
))
.retryExceptions(IOException::class.java, TimeoutException::class.java)
.ignoreExceptions(BusinessValidationException::class.java)
.build()
Jitter обязателен. Без jitter 50 инстансов после первого failure ретраят одновременно через 500 ms, потом через 1 s, потом через 2 s — синхронно. Downstream получает burst, падает снова. С random jitter каждый инстанс ретраит в случайный момент внутри интервала.
Не ретрайте бизнес-ошибки. BusinessValidationException — это не temporary failure. Ретрай с тем же payload даст тот же результат. В одном банковском проекте ретрай был настроен на все исключения. Бизнес-ошибка «недостаточно средств» ретраилась 5 раз с интервалом 2 секунды. Клиент видел задержку 10 секунд вместо мгновенного отказа.
Observability
Highload-система без observability — самолёт без приборов. Летит, пока не упадёт.
Structured logging
import io.github.oshai.kotlinlogging.KotlinLogging
private val log = KotlinLogging.logger {}
@Service
class OrderService {
fun createOrder(request: CreateOrderRequest): Order {
log.info {
"Creating order" to mapOf(
"customerId" to request.customerId,
"itemCount" to request.items.size,
"totalAmount" to request.total,
)
}
// ...
}
}
JSON-формат в stdout. Никаких log.info("Creating order for customer " + customerId) — это невозможно парсить в Kibana/Loki на масштабе 50 000 логов в секунду. Structured logging — обязательное условие для любой системы свыше 1 000 RPS.
Distributed tracing (OpenTelemetry)
В микросервисной архитектуре один пользовательский запрос проходит через 3-7 сервисов. Без tracing вы не знаете, какой из них тормозит.
OpenTelemetry SDK для Spring Boot 3:
// build.gradle.kts
dependencies {
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.12.0")
}
# application.yml
otel:
service:
name: order-service
exporter:
otlp:
endpoint: http://tempo:4317
traces:
sampler: parentbased_traceidratio
sampler.arg: 0.1 # 10% семплирование
Один dependency, пять строк конфига. Spring Boot auto-configuration подхватывает RestTemplate, WebClient, Kafka, JDBC — и добавляет спаны автоматически.
Семплирование — 0.1 (10%). При 10 000 RPS без семплирования вы генерируете 10 000 трейсов в секунду. Tempo/Jaeger захлебнётся. 10% — достаточно для анализа p99 и поиска аномалий.
Реальная история. E-commerce, чёрная пятница. p99 на checkout — 8 секунд (норма — 1.2 секунды). Без tracing искали бы проблему часами. С tracing — открыли один slow trace в Grafana Tempo и увидели: спан inventory-service.checkStock занимает 6.5 секунд. Причина — inventory-service делал SELECT ... FOR UPDATE на строки, которые одновременно обновлялись фоновым импортом из ERP. Lock contention на уровне строки. Фикс — перенести импорт на реплику + убрать FOR UPDATE (заменили на оптимистичную блокировку через version column).
Алерты: что мониторить
Минимальный набор для highload-системы:
| Метрика | Порог | Почему |
|---|---|---|
| p99 latency | > 2× от baseline | Деградация, но ещё не outage |
| Error rate (5xx) | > 1% от трафика | Что-то ломается |
| HikariCP active connections | > 80% от pool size | Скоро кончатся |
| Kafka consumer lag | > 10 000 | Consumer не успевает |
| JVM heap used after GC | > 80% | Утечка памяти |
| CPU usage | > 70% sustained | Нужно масштабирование |
| Pod restarts (k8s) | > 0 за 10 мин | OOM или liveness fail |
Не мониторьте среднее. Среднее — ложь. Среднее latency = 50 ms, а 1% пользователей ждёт 5 секунд. p99 — минимум, p99.9 — для критичных сервисов (платежи, авторизация).
Что значит 10 000 RPS на практике
«Нам нужен highload на 10 000 RPS» — фразу говорят часто. Но что за ней стоит в железе и деньгах?
Конкретные цифры из production-проекта, который мы архитектурили для крупного e-commerce (каталог 500K SKU, пиковый трафик — распродажи).
Профиль нагрузки
Не все 10 000 RPS одинаковые.
- 7 000 RPS — read (каталог, карточка товара, поиск). Идут на read replicas + Redis.
- 2 000 RPS — полу-write (добавление в корзину, wishlist, просмотры). Идут на primary PostgreSQL, но лёгкие.
- 1 000 RPS — тяжёлые write (оформление заказа, оплата, обновление остатков). Kafka + transactional outbox.
Инфраструктура
| Компонент | Ресурсы | Количество |
|---|---|---|
| API Gateway (Envoy) | 2 vCPU, 2 GB | 3 пода |
| Catalog Service | 4 vCPU, 8 GB, JDK 21 | 8 подов |
| Order Service | 4 vCPU, 8 GB | 4 пода |
| Payment Service | 2 vCPU, 4 GB | 3 пода |
| PostgreSQL Primary | 16 vCPU, 64 GB, NVMe | 1 |
| PostgreSQL Replica | 16 vCPU, 64 GB, NVMe | 2 |
| Redis Cluster | 8 GB × 3 masters + 3 replicas | 6 нод |
| Kafka | 8 vCPU, 32 GB, SSD | 3 брокера |
| ClickHouse | 16 vCPU, 64 GB | 2 шарда × 2 реплики |
Суммарно: ~130 vCPU, ~400 GB RAM. В Yandex Cloud это примерно 350 000–450 000 ₽/месяц. Не космос, но и не дёшево. Оптимизация архитектуры экономит реальные деньги.
JVM-настройки
JAVA_OPTS="-Xms4g -Xmx4g \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:MaxGCPauseMillis=10 \
-XX:SoftMaxHeapSize=3500m \
-XX:+UseStringDeduplication \
-Xlog:gc*:file=/var/log/gc.log:time,level,tags \
-Djdk.virtualThreadScheduler.parallelism=8"
ZGC Generational (JDK 21+) — sub-10ms GC pauses на 4 GB heap. SoftMaxHeapSize — подсказка ZGC возвращать память ОС, полезно в Kubernetes (pod не съедает весь memory limit).
Реальные метрики
Под нагрузкой 10 200 RPS (нагрузочный тест, Gatling, 30 минут):
- p50 latency: 18 ms
- p99 latency: 95 ms
- p99.9 latency: 280 ms
- Error rate: 0.02%
- CPU primary PostgreSQL: 45%
- CPU Catalog Service pods: 35% (среднее по 8 подам)
- Redis hit rate: 94%
- GC pause max (ZGC): 4 ms
- Kafka consumer lag: < 50
Эти числа — не из синтетического бенчмарка. Это продакшн-профиль после двух итераций тюнинга. Первая итерация давала p99 = 450 ms (bottleneck — HikariCP pool exhaustion). Вторая — p99 = 180 ms (bottleneck — неоптимальный запрос в catalog-service, N+1 через JPA).
Антипаттерны, которые мы находим на аудитах
За годы аудитов production-систем мы собрали коллекцию повторяющихся ошибок. Каждый из этих антипаттернов встречается минимум в 30% проектов, которые к нам приходят.
Подробный чек-лист из 10 типичных проблем Spring Boot-бэкенда с диагностикой и фиксами — в нашей статье по экспресс-аудиту.
Антипаттерн 1: Синхронные цепочки
POST /api/orders
→ OrderService.create()
→ InventoryService.reserve() // HTTP, 50ms
→ PricingService.calculate() // HTTP, 30ms
→ FraudService.check() // HTTP, 100ms
→ NotificationService.send() // HTTP, 200ms
Общий latency: 50 + 30 + 100 + 200 = 380 ms минимум. Плюс каждый вызов может упасть. Probability(all succeed) = 0.99^4 = 0.96. 4% запросов будут ломаться, даже если каждый сервис имеет 99% uptime.
Решение: оставить синхронными только те вызовы, результат которых нужен для ответа клиенту. Inventory reserve — да. Fraud check — да (или async с оптимистичным подтверждением). Notification — однозначно async через Kafka. Pricing можно закэшировать.
После рефакторинга: OrderService → reserve (50 ms) + fraud (100 ms, параллельно) → ответ клиенту (100 ms). Notification → Kafka. Latency снизилась с 380 ms до 100 ms.
Антипаттерн 2: N+1 между сервисами
Классический N+1 из мира ORM, но на уровне микросервисов. Catalog-сервис для отображения страницы категории:
// ❌ N+1 между сервисами
fun getCategoryPage(categoryId: String): CategoryPage {
val products = catalogClient.getProducts(categoryId) // 1 запрос
val enriched = products.map { product ->
val price = pricingClient.getPrice(product.id) // N запросов!
val stock = inventoryClient.getStock(product.id) // ещё N запросов!
product.copy(price = price, stock = stock)
}
return CategoryPage(enriched)
}
50 товаров в категории = 1 + 50 + 50 = 101 HTTP-запрос. При latency каждого 10 ms — 1 секунда только на network round-trips.
Решение: batch API. GET /api/prices?ids=1,2,3,...,50 вместо 50 отдельных вызовов. Один запрос, один ответ.
// ✅ Batch
fun getCategoryPage(categoryId: String): CategoryPage {
val products = catalogClient.getProducts(categoryId)
val ids = products.map { it.id }
val prices = pricingClient.getPricesBatch(ids) // 1 запрос
val stocks = inventoryClient.getStockBatch(ids) // 1 запрос
// ...
}
1 + 1 + 1 = 3 запроса. С параллельным выполнением (CompletableFuture или coroutines) — latency = max(catalog, pricing, inventory) ≈ 50 ms.
Антипаттерн 3: Shared Database
Шесть сервисов ходят в одну PostgreSQL. «У нас микросервисы» — говорит команда. Нет. У вас распределённый монолит с network overhead.
Проблемы:
- Schema coupling: сервис A добавляет колонку → миграция ломает сервис B.
- Connection exhaustion: 6 × 30 connections = 180, при лимите PostgreSQL
max_connections = 200осталось 20 для maintenance, мониторинга и pg_dump. - Lock contention: сервис A держит транзакцию на таблице orders, сервис B ждёт блокировки.
Решение: database-per-service. Если два сервиса нуждаются в данных друг друга — API-вызов или event через Kafka. Да, это сложнее. Да, это eventual consistency. Но альтернатива — потолок масштабирования на 3 000 RPS и ночные звонки от DBA.
Антипаттерн 4: Отсутствие backpressure
Сервис принимает запросы быстрее, чем обрабатывает. Очередь растёт. Память кончается. OOM.
# Tomcat — ограничение accept queue
server:
tomcat:
accept-count: 100 # макс. запросов в TCP backlog
max-connections: 8192
threads:
max: 200
min-spare: 10
Для Kafka consumer — max.poll.records:
spring:
kafka:
consumer:
max-poll-records: 100 # не 500 (дефолт) и не 10000
fetch-max-wait-ms: 500
Если consumer не успевает — лучше пусть Kafka consumer lag растёт (мониторим, алерт), чем OOM на поде.
Антипаттерн 5: Health check, который не проверяет здоровье
// ❌ Бессмысленный health check
@GetMapping("/health")
fun health() = "OK"
Pod alive, но PostgreSQL connection pool exhausted, Redis disconnected, Kafka consumer отстал на 100 000. Kubernetes считает pod здоровым, шлёт трафик. Пользователи видят ошибки.
// ✅ Spring Boot Actuator с деталями
management:
endpoint:
health:
show-details: always
health:
db:
enabled: true
redis:
enabled: true
kafka:
enabled: true
Liveness — «процесс живой?». Readiness — «готов принимать трафик?». Это разные вещи. Если PostgreSQL недоступна — readiness = false (не шли трафик), но liveness = true (не убивай pod, БД может вернуться).
Чек-лист перед production
Мы используем этот список перед каждым релизом highload-системы. 22 пункта, сгруппированных по слоям.
Инфраструктура
- Kubernetes liveness и readiness пробы настроены (и это разные endpoint-ы)
- Resource limits (CPU, memory) установлены для каждого пода
- HPA (Horizontal Pod Autoscaler) настроен по CPU и custom metrics
- Network policies ограничивают межсервисное общение (zero trust)
- Secrets — в Vault или Kubernetes Secrets, не в env vars
База данных
- Read replicas настроены, read-only транзакции идут на реплику
- HikariCP pool size рассчитан (CPU × 2 + 1), leak detection включён
- Slow query log включён (порог 100 ms)
- Индексы покрывают все WHERE/JOIN в горячих запросах
-
pg_stat_statementsвключён для анализа нагрузки
Кэширование
- Двухуровневый кэш (L1 Caffeine + L2 Redis) для горячих данных
- TTL с jitter для предотвращения cache stampede
- Hit rate мониторится (алерт при < 80%)
- Инвалидация протестирована (обновление данных отражается в кэше)
Отказоустойчивость
- Circuit breaker на каждый внешний вызов
- Bulkhead ограничивает параллельные вызовы к каждому downstream
- Retry с exponential backoff и jitter (не fixed delay!)
- Timeout на каждый HTTP-вызов (connection + read)
- Fallback-сценарии определены для каждого circuit breaker
Observability
- Structured logging в JSON
- Distributed tracing (OpenTelemetry) с семплированием
- Алерты на p99 latency, error rate, consumer lag, connection pool
- Dashboards в Grafana для каждого сервиса
Когда строить самим, а когда звать помощь
Highload-архитектура — это не набор конфигов, которые можно скопировать из статьи. Это инженерные решения, каждое из которых зависит от вашего конкретного профиля нагрузки, бизнес-требований и бюджета.
Один проект — это синхронный REST-монолит, который можно оптимизировать за 2 недели тюнингом JVM и добавлением кэша. Другой — распределённая система из 30 сервисов, где нужен полный рефакторинг data layer и переход на event-driven.
Мы в Новаком специализируемся на Java/Kotlin-разработке для enterprise: банки, финтех, e-commerce, страховые. Если вам нужна команда, которая построит highload с нуля или разберёт проблемы в существующей системе — напишите. Первичный аудит — бесплатно.
Если нужна выделенная команда Java-разработчиков для длительного проекта — работаем и по такой модели.
А если хотите разобраться сами — в нашем блоге есть детальные гайды по каждой теме из этой статьи: от настройки circuit breaker до борьбы с cache stampede и типичных проблем Spring Boot-бэкенда.
FAQ
Сколько стоит инфраструктура для 10 000 RPS?
В Yandex Cloud: 350 000–450 000 ₽/месяц за полный стек (PostgreSQL, Redis Cluster, Kafka, Kubernetes на ~130 vCPU). В managed-варианте (Managed PostgreSQL, Managed Kafka) — дороже на 30-40%, но меньше работы DevOps. Можно начать с меньшего и масштабироваться по мере роста: стартовая конфигурация на 2 000 RPS обойдётся в 80 000–120 000 ₽/месяц.
Нужны ли микросервисы для highload?
Нет. Хорошо спроектированный монолит на Spring Boot с virtual threads, read replicas и Redis-кэшем может держать 5 000–8 000 RPS. Микросервисы нужны, когда у вас разные команды, разные циклы релизов, разные требования к масштабированию для разных компонентов. Не начинайте с микросервисов, если у вас одна команда из 5 человек.
Virtual threads заменяют реактивный стек (WebFlux)?
Для большинства бизнес-приложений — да. Virtual threads дают сопоставимый throughput при IO-bound нагрузке, но код остаётся синхронным — проще читать, проще дебажить, проще нанимать разработчиков. WebFlux оправдан для edge-сервисов с экстремальным fan-out (API gateway, агрегаторы) и для систем, где критичен каждый мегабайт RAM.
Какой JDK выбрать для production в 2026?
Для highload — Eclipse Temurin (Adoptium) JDK 21 LTS. Бесплатный, с долгой поддержкой, проверенный. Для GraalVM native image — GraalVM CE или Liberica NIK. Для тех, кому нужна коммерческая поддержка — Axiom JDK (бывший BellSoft) или Red Hat Build of OpenJDK. Oracle JDK — можно, но следите за лицензией.
Как мигрировать существующую систему на highload-архитектуру?
Не переписывайте всё сразу. Начните с трёх вещей: (1) включите observability — метрики, трейсы, structured logs — чтобы видеть, где bottleneck; (2) добавьте кэширование горячих данных (Caffeine + Redis); (3) настройте read replicas для read-heavy запросов. Эти три шага дадут 2-4× прирост throughput без изменения архитектуры. Дальше — итеративно: выделяйте из монолита самые нагруженные домены в отдельные сервисы, добавляйте Kafka для async-коммуникации, настраивайте circuit breaker-ы. Полная миграция крупной системы занимает 6-12 месяцев.