Новаком
JAVA

Java highload-архитектура: как строить системы на 10 000+ RPS в 2026

Архитектура highload-систем на Java/Kotlin: Spring Boot, микросервисы, Kafka, Redis, PostgreSQL. Паттерны, антипаттерны, метрики. Опыт банков и e-commerce.

Н
Новаком
2026-05-24 · 18 минут чтения

Содержание


Почему советы из 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.

Два пути:

  1. Istio/Envoy sidecar — всё из инфраструктуры, код чистый.
  2. 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 000Consumer не успевает
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 GB3 пода
Catalog Service4 vCPU, 8 GB, JDK 218 подов
Order Service4 vCPU, 8 GB4 пода
Payment Service2 vCPU, 4 GB3 пода
PostgreSQL Primary16 vCPU, 64 GB, NVMe1
PostgreSQL Replica16 vCPU, 64 GB, NVMe2
Redis Cluster8 GB × 3 masters + 3 replicas6 нод
Kafka8 vCPU, 32 GB, SSD3 брокера
ClickHouse16 vCPU, 64 GB2 шарда × 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 месяцев.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект