Новаком
BACKEND

10 проблем Spring Boot-бэкенда, которые находим за 3 дня (на банковских кейсах)

Реальный чек-лист экспресс-аудита: GC-паузы, HikariCP, пропавшие индексы, at-most-once в RabbitMQ, Cache Stampede, fake health check. С симптомами, диагностикой, фиксами и метриками до/после.

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

Зачем это писать

За последние годы мы пересмотрели десятки production-бэкендов — банки, финтех, b2b-платформы. Перед тем как браться за полный аудит или приходить инженерами в команду, мы делаем экспресс-проход за 3 дня: метрики, GC-логи, slow query log, интервью с тех-лидом, короткий нагрузочный прогон. Этого достаточно, чтобы найти топ-10 проблем в 95% систем.

Ниже — паттерны, которые повторяются из проекта в проект. По каждому: симптом (что видно в проде), диагностика (как находим), фикс (что делаем), метрика до/после (где можем — с реальных кейсов под NDA, поэтому масштаб, не точное имя).

Эта статья — не «10 best practices от capitan obvious». Это то, что мы реально находим, когда нас зовут разобраться, почему «как-то медленно».


1. GC-паузы > 200 ms на G1 при том, что хип «не такой уж и большой»

Симптом. В Grafana виден ровный p50 = 30 ms, но p99 пилит до 400–800 ms. Latency-«хвосты» совпадают с пиками входящего трафика. Бизнес жалуется на «иногда тупит».

Диагностика.

# Включаем GC-логи (если ещё нет)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags

# Парсим
gceasy.io / gcviewer / GCToolkit

В отчёте видим: pause time p99 у G1 GC = 250–450 ms, при этом heap = 4 GB. Большинство пауз — Mixed/Concurrent Mark на молодом поколении, плюс эпизодические Full GC под аллокационным давлением.

Фикс.

  • Переключение на ZGC или Shenandoah (JDK 17+) — паузы становятся sub-10ms даже на 32 GB heap.
  • Если ZGC по причинам не подходит — тюнинг G1: -XX:MaxGCPauseMillis=100, -XX:G1HeapRegionSize=16m, -XX:InitiatingHeapOccupancyPercent=35.
  • Профилируем аллокации через async-profiler -e alloc — часто 80% мусора генерирует один логгер или ObjectMapper, который можно переиспользовать.

До → после. На одном из проектов (платёжный шлюз, JDK 21): p99 latency 380 ms → 78 ms за смену G1 → ZGC + reuse ObjectMapper. Heap −20%, GC throughput +3%.


2. HikariCP с пулом 200 при peak load 30 коннектов

Симптом. «На всякий случай» в конфиге выкручено maximum-pool-size: 200. DBA жалуется, что приложение «жрёт коннекты». PG hits max_connections ограничение, остальные приложения деградируют.

Диагностика. Метрика hikaricp_connections_active показывает, что peak за неделю — 30. Среднее активных — 8. Остальные 170 — открытые «на всякий случай» idle-коннекты, которые держат PostgreSQL backend-процессы.

spring:
  datasource:
    hikari:
      maximum-pool-size: 200  # ← а зачем?
      minimum-idle: 200       # ← это вообще катастрофа

Фикс. Простая формула от Hikari maintainer:

pool_size = ((core_count × 2) + effective_spindle_count)

Для типичного приложения с 4-CPU контейнером и SSD: pool_size = 10. Достаточно. Идемпотентно. PostgreSQL спасибо скажет.

spring:
  datasource:
    hikari:
      maximum-pool-size: 12
      minimum-idle: 4
      connection-timeout: 3000
      idle-timeout: 300000
      max-lifetime: 1800000
      leak-detection-threshold: 60000

До → после. Освобождается ~150 PG backend-процессов. Если у вас pool 200 на 10 микросервисах — это 2000 коннектов в PostgreSQL, который по дефолту держит max 100. Дальше деградация всего кластера.


3. Slow query log пустой, потому что его никто не включил

Симптом. Тех-лид: «У нас всё в кэше, запросов к БД почти нет». В Grafana метрика pg_stat_database_blks_read растёт линейно с трафиком.

Диагностика.

-- Включаем расширение, если ещё нет
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top-20 по суммарному времени
SELECT
  substring(query, 1, 100) AS q,
  calls,
  total_exec_time,
  mean_exec_time,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Регулярно находим:

  • Запрос с SELECT * FROM orders WHERE status = ? без индекса на status (sequential scan по 50M строк, mean 1.2s, calls = 8 млн/день)
  • N+1 от Spring Data JPA — findAll() тянет связные сущности отдельным запросом для каждой строки
  • LIMIT/OFFSET pagination на 100k+ страниц — OFFSET 1000000 это полный скан с пропуском

Фикс.

-- Частичный индекс (если статусы перекошены)
CREATE INDEX CONCURRENTLY idx_orders_active_status
  ON orders (created_at DESC)
  WHERE status IN ('PENDING', 'PROCESSING');

-- Cursor-based pagination вместо OFFSET
SELECT * FROM orders
WHERE id > :last_id
ORDER BY id
LIMIT 50;

-- N+1 → @EntityGraph или JOIN FETCH
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByCreatedAtBetween(Instant from, Instant to);

До → после. Один наш кейс: запрос на дашборд админа выполнялся 14 секунд (OFFSET 800000). После cursor-based — 80 ms. Не миллион оптимизаций — одна точечная.


4. RabbitMQ с auto-ack, или почему у вас «иногда теряются заказы»

Симптом. Раз в месяц бизнес присылает скриншот заказа, который «оплачен но не доехал до склада». В логах consumer ничего нет, никакой ошибки.

Диагностика. Открываем конфиг consumer-а:

@RabbitListener(queues = "orders.in", ackMode = "AUTO")
public void handle(Order order) {
    warehouseService.dispatch(order); // ← может бросить runtime exception
}

ackMode = "AUTO" (или acknowledgeMode: AUTO в application.yml) — RabbitMQ снимает сообщение сразу при доставке consumer-у. Если в dispatch бросается исключение — сообщение уже подтверждено и в очередь не вернётся. Заказ потерян.

Фикс. Явное подтверждение после успешной обработки + outbox-паттерн для гарантий end-to-end:

@RabbitListener(queues = "orders.in", ackMode = "MANUAL")
public void handle(Order order, Channel channel,
                   @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
    try {
        warehouseService.dispatch(order);
        channel.basicAck(tag, false);
    } catch (TransientException e) {
        channel.basicNack(tag, false, true);  // requeue для ретрая
    } catch (Exception e) {
        channel.basicNack(tag, false, false); // в DLQ
    }
}

Плюс настраиваем Dead Letter Queue для сообщений, которые не получилось обработать после N попыток — иначе они зациклятся.

До → после. На одном проекте мы нашли в DLQ 14 000 сообщений, накопленных за полгода — реальные заказы и платежи. Бизнес не знал, что они есть.


5. Synchronous HTTP-вызов внешнего API в критическом пути

Симптом. Сервис идеально работает, пока сторонний поставщик («внешний биллинг», «KYC», «справочник адресов») жив. Когда поставщик ложится — наш сервис вместе с ним: thread pool забивается синхронными вызовами с 30-секундным timeout.

Диагностика. Thread dump (jstack или async-profiler --wallclock) под нагрузкой:

"http-nio-8080-exec-200" RUNNABLE
  at java.net.SocketInputStream.socketRead0
  at sun.security.ssl.SSLSocketImpl.readApplicationRecord
  at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer
  at ...ExternalBillingClient.charge(ExternalBillingClient.java:42)
  at OrderService.process(OrderService.java:78)

200 потоков из Tomcat пула стоят и ждут внешний API. У нас 0 свободных потоков. Все следующие запросы — 503.

Фикс.

  1. Timeout. 30 секунд → 2 секунды (для не-критичных интеграций).
  2. Circuit Breaker через Resilience4j:
@CircuitBreaker(name = "billing", fallbackMethod = "billingFallback")
@TimeLimiter(name = "billing")
public CompletableFuture<ChargeResult> charge(ChargeRequest req) {
    return billingClient.chargeAsync(req);
}

public CompletableFuture<ChargeResult> billingFallback(ChargeRequest req, Throwable t) {
    // Сохраняем в outbox, отдаём бизнесу «pending»
    outbox.save(req);
    return CompletableFuture.completedFuture(ChargeResult.pending());
}
  1. Bulkhead — изолируем пул потоков для внешнего вызова, чтобы он не утащил весь Tomcat:
resilience4j.bulkhead.instances.billing:
  max-concurrent-calls: 20
  max-wait-duration: 100ms

До → после. Тот же платёжный шлюз: при падении внешнего биллинга наш сервис теперь продолжает принимать заказы со статусом pending, а не возвращает 503 всему трафику.


6. Cache Stampede на популярном ключе

Симптом. В 18:00 (час пик) каталог в Redis истекает по TTL. 50 параллельных запросов одновременно идут в БД восстанавливать кэш. PostgreSQL CPU → 100%, p99 latency пилит вверх на 2 минуты. Через 2 минуты кэш восстанавливается — всё успокаивается. До следующего истечения.

Диагностика.

public Catalog getCatalog() {
    Catalog cached = redis.get("catalog");
    if (cached != null) return cached;

    Catalog fresh = db.loadCatalog();  // ← 50 потоков сюда одновременно
    redis.setex("catalog", 600, fresh);
    return fresh;
}

Это классический Cache Stampede (или thundering herd). Когда популярный ключ истекает, все consumer-ы видят null одновременно и идут восстанавливать.

Фикс. Single-flight через распределённый лок или Caffeine + Redis:

private final AsyncLoadingCache<String, Catalog> cache = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofMinutes(10))
    .refreshAfterWrite(Duration.ofMinutes(8))  // ← refresh ДО истечения, асинхронно
    .buildAsync((key, executor) -> db.loadCatalogAsync());

public CompletableFuture<Catalog> getCatalog() {
    return cache.get("catalog");
}

Альтернативы: probabilistic early expiration (XFetch), Redis-lock через SETNX + ожидание, либо просто не истекать кэш по времени, а инвалидировать событиями.

До → после. Пики PostgreSQL CPU на 100% исчезают. Кэш «refresh-on-read» работает как буфер.


7. Логирование на DEBUG в production

Симптом. Sysadmin жалуется на 200 GB/день логов. Контейнер с медленным диском становится узким местом. p99 коррелирует с записью в файл.

Диагностика.

# Сколько логов пишет приложение
du -sh /var/log/app/*.log
# По уровню
grep -c "DEBUG" /var/log/app/app.log
grep -c "INFO" /var/log/app/app.log

Видим: app.log — 8 GB за час. 95% — DEBUG-строки. В коде что-то типа:

log.debug("Processing request: " + huge.toString() + " for user " + user.toJson());

Каждое сообщение — конкатенация и сериализация JSON. Даже если DEBUG отключён, конкатенация выполнится (если код не использует параметризованный logger).

Фикс.

  1. Production уровень — INFO или WARN, не DEBUG. DEBUG включается через эндпоинт /actuator/loggers точечно для конкретного пакета на короткое время.

  2. Параметризованный logger:

log.debug("Processing request: {} for user {}", request, user);  // toString не вызовется, если DEBUG off
  1. Структурированные логи в JSON (logstash-encoder или logback-spring → ELK / Loki). Гораздо быстрее grep-ить.

  2. MDC для correlation_id — без этого в микросервисах невозможно собрать трейс запроса.

До → после. На одном проекте: 8 GB/час → 400 MB/час, p99 −30 ms (за счёт меньшего I/O wait).


8. Grafana показывает p50, а тех-лид думает, что это «среднее latency»

Симптом. В Grafana график «latency» — ровная линия 40 ms. Бизнес жалуется: «иногда отвечает по 2 секунды». Спор: бизнес врёт или график.

Диагностика. Открываем дашборд. Метрика — rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m]). Это среднее. Среднее latency бесполезно для highload.

Фикс. Считать перцентили — это то, что чувствуют пользователи:

# p50, p95, p99 latency
histogram_quantile(0.50,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)

Важно: для этого Micrometer должен экспортировать histograms, не только counters. В Spring Boot:

management.metrics.distribution.percentiles-histogram.http.server.requests: true
management.metrics.distribution.sla.http.server.requests: 100ms,500ms,1s

И отдельный график percentile heatmap — он показывает не только верхний хвост, но и распределение. SRE-команды Google этот вид считают must-have.

До → после. «p50 = 40 ms» превращается в «p50 = 40 ms, p99 = 1200 ms, p99.9 = 4500 ms». И вот теперь видно проблему. И сразу видно, какие 1% запросов её создают.


9. Kafka consumer rebalance каждые 30 минут на ровном месте

Симптом. В логах consumer регулярно — Attempt to heartbeat failed since group is rebalancing. Не падает, но раз в полчаса теряет ~30 секунд на rebalance. Если consumer внутри обрабатывает batch — сообщения дублируются.

Диагностика.

spring.kafka.consumer:
  properties:
    session.timeout.ms: 10000          # ← дефолт
    heartbeat.interval.ms: 3000
    max.poll.interval.ms: 300000       # ← 5 минут
    max.poll.records: 500

В коде consumer обрабатывает batch до 500 сообщений. Одно сообщение — INSERT в БД + вызов внешнего API. На batch уходит ~6 минут. max.poll.interval.ms = 5 минут — consumer не успевает вернуться за следующим poll() → broker считает его мёртвым → rebalance.

Фикс.

spring.kafka.consumer:
  properties:
    max.poll.records: 100               # меньше batch
    max.poll.interval.ms: 600000        # 10 минут на batch
    session.timeout.ms: 30000           # дольше живёт без heartbeat
    heartbeat.interval.ms: 10000

Плюс — никогда не делать длинные I/O в основном потоке consumer. Лучше складывать в локальную очередь и обрабатывать пулом:

@KafkaListener(topics = "events")
public void handle(List<Event> batch) {
    CompletableFuture.allOf(
        batch.stream()
             .map(e -> CompletableFuture.runAsync(() -> process(e), workerPool))
             .toArray(CompletableFuture[]::new)
    ).join();
}

До → после. В одном из проектов rebalance loop приводил к дублям обработки — финансовых транзакций. Нашли это через trace-логи, не через метрики. Метрики rebalance в Grafana — must-have.


10. Health check возвращает 200 OK при недоступной БД

Симптом. Kubernetes не убивает pod, который «не работает». Liveness probe говорит «alive». А реальные запросы — все 500. Балансировщик продолжает слать трафик в мёртвый pod.

Диагностика. Открываем /actuator/health:

{ "status": "UP" }

Открываем код:

@Component
public class CustomHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        return Health.up().build();  // ← всегда UP. Почему — никто не помнит.
    }
}

Кто-то когда-то написал «временно», чтобы k8s перестал перезапускать pod, и забыл.

Фикс. Используем встроенные health indicators Spring Boot, они проверяют реальные зависимости:

management.endpoint.health.show-details: when-authorized
management.endpoint.health.probes.enabled: true
management.health.db.enabled: true
management.health.redis.enabled: true
management.health.rabbit.enabled: true

Это даёт два эндпоинта:

  • /actuator/health/liveness — «процесс жив». Только базовые проверки, не включает БД.
  • /actuator/health/readiness — «готов принимать трафик». Включает БД, Redis, очереди.

В k8s liveness не должен зависеть от БД (если БД упала — k8s начнёт killing loop). Readiness — должен. Балансировщик уберёт pod из ротации, БД восстановится — pod вернётся в ротацию сам.

До → после. На одном проекте мы нашли это перед Big Friday. До этого 5 раз за квартал ловили инцидент «pod зомби» с разными RCA. Reality: всегда был один и тот же баг с health check, просто проявлялся по-разному.


Что со всем этим делать

Если читаете и думаете «у нас наверняка часть из этого есть» — скорее всего да. Это не редкие случаи. Это дефолтная картина у систем, которые 3+ года в продакшене и переживали несколько ротаций команды.

Есть три пути:

  1. Найти и закрыть самим. Этот пост — рабочий чек-лист. Возьмите 4 часа в спокойное утро, пройдитесь по 10 пунктам. Найдёте 3–5. Это уже большой выигрыш.

  2. Позвать SRE/DevOps на разбор. Если в команде есть кто-то с опытом — поручите ему. Поделить с разработчиками 10 пунктов выше, дать неделю на ресёрч.

  3. Заказать экспресс-аудит сбоку. Это то, что мы делаем за 3 дня — заходим, копаем, отдаём отчёт. Дальше — фиксите сами или зовёте нас инженерами. Без давления продаж, отчёт остаётся у вас в любом случае.


Ещё один disclaimer

Все примеры выше — обобщённые. Конкретные клиенты под NDA, цифры намеренно округлены. Если узнали свою систему — это не потому, что у нас есть фотографии, это потому что эти паттерны повторяются у всех.

И последнее. «Не реализуйте это сразу» — это плохой совет. «Реализуйте все 10 пунктов разом» — тоже плохой. Берите по одному в спринт, замеряйте до/после, фиксируйте в журнал. Аудит без последующих метрик — это просто красивый PDF.

РАЗРАБОТКА

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

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

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