Зачем это писать
За последние годы мы пересмотрели десятки 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.
Фикс.
- Timeout. 30 секунд → 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());
}
- 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).
Фикс.
-
Production уровень — INFO или WARN, не DEBUG. DEBUG включается через эндпоинт
/actuator/loggersточечно для конкретного пакета на короткое время. -
Параметризованный logger:
log.debug("Processing request: {} for user {}", request, user); // toString не вызовется, если DEBUG off
-
Структурированные логи в JSON (logstash-encoder или logback-spring → ELK / Loki). Гораздо быстрее grep-ить.
-
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+ года в продакшене и переживали несколько ротаций команды.
Есть три пути:
-
Найти и закрыть самим. Этот пост — рабочий чек-лист. Возьмите 4 часа в спокойное утро, пройдитесь по 10 пунктам. Найдёте 3–5. Это уже большой выигрыш.
-
Позвать SRE/DevOps на разбор. Если в команде есть кто-то с опытом — поручите ему. Поделить с разработчиками 10 пунктов выше, дать неделю на ресёрч.
-
Заказать экспресс-аудит сбоку. Это то, что мы делаем за 3 дня — заходим, копаем, отдаём отчёт. Дальше — фиксите сами или зовёте нас инженерами. Без давления продаж, отчёт остаётся у вас в любом случае.
Ещё один disclaimer
Все примеры выше — обобщённые. Конкретные клиенты под NDA, цифры намеренно округлены. Если узнали свою систему — это не потому, что у нас есть фотографии, это потому что эти паттерны повторяются у всех.
И последнее. «Не реализуйте это сразу» — это плохой совет. «Реализуйте все 10 пунктов разом» — тоже плохой. Берите по одному в спринт, замеряйте до/после, фиксируйте в журнал. Аудит без последующих метрик — это просто красивый PDF.