Новаком
Главная/Блог/ПРОИЗВОДИТЕЛЬНОСТЬ
ПРОИЗВОДИТЕЛЬНОСТЬ

Аудит и оптимизация производительности backend: нагрузочное тестирование, JVM и поиск узких мест

Практическая методология аудита производительности backend на Java/Kotlin: метрики latency p50/p95/p99 и throughput, нагрузочное тестирование на k6/Gatling/JMeter, профилирование JVM с async-profiler, типичные узкие места (БД, N+1, пулы, GC), настройка G1/ZGC и кэширования.

ЯА
Яковлев Александр
2026-06-28 · 16 минут чтения

Содержание

«А бэк просто не пробовали нагрузочным тестированием? Может, узкое место где есть? Запросы к базе там или ещё что-то». Эта фраза из корпоративного чата — почти готовое техзадание на аудит производительности. Сервис начинает «задыхаться» под нагрузкой, отклик плавает, а команда гадает: дело в базе, в пулах, в сборщике мусора или в коде. Гадать не нужно — нужно измерять.

Эта статья — практический разбор того, как мы в Новакоме проводим аудит и оптимизацию производительности backend на Java/Kotlin: какие метрики собирать, как строить нагрузочный тест, чем профилировать JVM, где чаще всего прячутся узкие места и как считать эффект от оптимизации в цифрах, а не в ощущениях. Материал продолжает тему высоконагруженной архитектуры на Java, но смотрит на неё с другой стороны — не «как спроектировать», а «как найти и убрать то, что уже тормозит».

Зачем нужен аудит производительности

Производительность — это не «быстро» или «медленно». Это измеримый контракт между сервисом и бизнесом: сколько запросов в секунду система держит и за какое время отвечает на заданном перцентиле. Когда инженер пишет «поднял производительность, теперь сервер спокойно обрабатывает 6000 запросов в минуту, провёл нагрузочное тестирование — ничего не падало, выдавало нужную скорость», он формулирует ровно такой контракт: целевой throughput плюс подтверждённая под нагрузкой стабильность.

Аудит нужен, когда контракт нарушается или его вообще нет:

  • отклик растёт по мере нагрузки, появляются таймауты и ошибки 5xx;
  • p99 latency в разы выше медианы — часть пользователей видит «тормоза», хотя «в среднем всё хорошо»;
  • сервис требует всё больше CPU и памяти при том же трафике;
  • перед запуском новой функции или маркетинговой акции нужно понять потолок системы;
  • регулярно происходят деградации, причину которых «трудно воспроизвести и устранить».

Главный принцип: оптимизация без измерения — это угадывание. Сначала методология и метрики, потом нагрузочный тест и профилирование, и только в конце — правки кода и конфигурации. Иначе легко неделю «ускорять» сериализацию, когда 80% времени уходит на один N+1-запрос к базе.

Что измерять: метрики, на которые опираются

Базовый набор метрик для backend-сервиса делится на три группы.

Latency (время отклика). Среднее значение почти бесполезно — оно маскирует выбросы. Смотрят на перцентили:

  • p50 (медиана) — типичный отклик, что чувствует «средний» пользователь;
  • p95 — отклик для 5% самых медленных запросов;
  • p99 / p99.9 — «хвост» распределения, где живут GC-паузы, блокировки и исчерпание пулов.

Именно хвосты определяют восприятие сервиса. Показательна формулировка из разбора оптимизации хранилища заказов в одной CMS: «общие запросы быстрее на 30%, а для 95-го перцентиля время отклика — на 43%». Хвост улучшился сильнее медианы, и это правильная цель: убирать не «среднее», а худшие случаи.

Throughput (пропускная способность). Сколько запросов в секунду (RPS) или в минуту система обрабатывает без роста ошибок и latency. Важно фиксировать throughput вместе с latency: 1000 RPS при p99 = 80 мс и 1000 RPS при p99 = 4 с — это два разных мира.

Ресурсы и насыщение. Утилизация CPU, потребление heap и non-heap памяти, частота и длительность GC-пауз, занятость пулов (соединений к БД, потоков, HTTP-клиента), длины очередей. Это «приборная панель», по которой видно, какой ресурс упирается в потолок первым.

МетрикаЧто показываетТревожный сигнал
p50 latencyТипичный откликРост относительно базовой линии
p95 / p99 latency«Хвост» медленных запросовp99 в 5–10 раз выше p50
Throughput (RPS)Пропускная способностьНе растёт при росте нагрузки
Error rateДоля ошибок и таймаутовПоявляется до достижения целевого RPS
CPU utilizationЗагрузка процессораСтабильно > 80% под целевой нагрузкой
GC pause timeПаузы сборщика мусораПаузы > 100–200 мс, частые Full GC
Pool saturationЗанятость пуловОчередь ожидания соединений/потоков

Целевые значения задаёт бизнес. Для одних сервисов ориентир — TTFB порядка 200 мс, для других важнее держать заданный RPS при приемлемом проценте ошибок. Без явных SLO («p99 < 300 мс при 2000 RPS, error rate < 0.1%») аудит превращается в спор о вкусах.

Методология аудита: семь шагов

Аудит производительности — это воспроизводимый процесс, а не разовый «потыкать профайлером».

  1. Зафиксировать SLO и сценарии. Какие эндпоинты критичны, какой профиль нагрузки реалистичен, какие целевые p95/p99 и throughput.
  2. Снять базовую линию (baseline). Прогнать нагрузочный тест на текущей системе и записать метрики. Без baseline нельзя доказать улучшение.
  3. Найти точку насыщения. Постепенно повышать нагрузку, пока latency не начнёт расти, а throughput — упираться в полку. Это «потолок» системы.
  4. Профилировать под нагрузкой. В момент насыщения снять профиль CPU, аллокаций и блокировок, посмотреть GC-логи и метрики пулов.
  5. Сформулировать гипотезы об узких местах. «Тормозит база», «упёрлись в пул соединений», «GC ест 15% CPU» — каждая гипотеза подтверждается данными.
  6. Внести одну правку и перемерить. Менять по одному фактору за раз. Иначе невозможно понять, что именно сработало.
  7. Повторять до достижения SLO, затем закрепить результат регрессионным нагрузочным тестом в CI.

Ключевое здесь — измерять до и после каждого изменения. Оптимизация без контрольного замера легко делает хуже: новый кэш экономит запрос, но добавляет давление на heap и удлиняет GC-паузы.

Нагрузочное тестирование: k6, Gatling, JMeter

Нагрузочный тест воспроизводит реальный трафик и отвечает на вопрос «сколько система держит и когда ломается». Три инструмента покрывают почти все задачи.

  • k6 — современный инструмент, сценарии на JavaScript, легко встраивается в CI, хорошо отдаёт метрики по перцентилям. Удобен для API.
  • Gatling — сценарии на Scala/Java DSL, высокая нагрузка с одной машины, детальные HTML-отчёты. Близок Java-командам.
  • JMeter — ветеран с GUI и огромной экосистемой плагинов, хорош для сложных протоколов и legacy.

Важнее инструмента — правильный профиль теста. Нужны разные виды:

  • Smoke — минимальная нагрузка, проверка, что тест и система вообще работают.
  • Load — целевая нагрузка, подтверждение SLO.
  • Stress — рост нагрузки до отказа, поиск точки насыщения и потолка.
  • Soak (endurance) — длительный прогон на целевой нагрузке: ловит утечки памяти и деградацию, которую не видно за пять минут.

Пример нагрузочного сценария на k6 с явными порогами по перцентилям и ступенчатым ростом нагрузки:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 200 },   // разгон до 200 VU
    { duration: '5m', target: 200 },   // плато — целевая нагрузка
    { duration: '2m', target: 500 },   // стресс — ищем потолок
    { duration: '2m', target: 0 },     // плавное завершение
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],                  // < 1% ошибок
    http_req_duration: ['p(95)<300', 'p(99)<800'],   // SLO по хвостам
  },
};

export default function () {
  const res = http.get('https://api.internal/orders?status=NEW');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}

Тест запускают в окружении, близком к продакшену (те же ресурсы, та же база с реалистичным объёмом данных) и с нагрузочной машины, которая сама не становится узким местом. Частая ошибка — гонять тест по «пустой» базе из десяти строк: N+1-запросы и отсутствие индексов на таких данных просто не проявляются.

Профилирование JVM: GC, heap, async-profiler

Нагрузочный тест говорит, что система тормозит. Профилирование объясняет, почему. Для JVM это отдельное искусство: как формулируют на профильных конференциях, важно «затюнить JVM так, чтобы сервис работал с нужной производительностью» — и для этого нужно понимать, что происходит внутри виртуальной машины.

async-profiler — основной инструмент. Он снимает CPU-профиль с минимальным оверхедом и строит flame graph, на котором сразу видно, где сжигается процессорное время. Он же умеет профилировать аллокации (alloc) и блокировки на мониторах/локах (lock):

# CPU-профиль работающего сервиса в flame graph (PID 12345, 60 секунд)
./asprof -d 60 -e cpu -f cpu-flame.html 12345

# Профиль аллокаций — кто и где создаёт мусор и давит на GC
./asprof -d 60 -e alloc -f alloc-flame.html 12345

# Профиль блокировок — где потоки ждут на локах и synchronized
./asprof -d 60 -e lock -f lock-flame.html 12345

GC-логи — второй источник. Их включают всегда, оверхед минимален:

-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

По логам и метрикам JVM смотрят: частоту и длительность пауз, долю времени в GC, происходят ли дорогие Full GC, не растёт ли занятый heap после каждого цикла (признак утечки). Если сборщик ест заметную долю CPU или даёт паузы в сотни миллисекунд — это прямая причина «хвостов» p99.

Отдельная боль — память на строках и объектах. В enterprise-приложениях, активно работающих с текстом, «приличное количество памяти и времени тратится на возню со строками»: бездумная конкатенация, лишние подстроки, неоправданное интернирование. Профиль аллокаций показывает это сразу, а в запущенных случаях такие вещи приводят прямо к OutOfMemoryError. Классическая настольная книга по теме — «Java Performance: The Definitive Guide» Скотта Оукса; в команде полезно, чтобы её принципы понимал не один человек.

Типичные узкие места backend

За годы аудитов набор «подозреваемых» меняется мало. Вот где мы находим проблемы чаще всего.

База данных. Узкое место номер один. Медленные запросы без индексов, полные сканы, блокировки, неоптимальные планы. Первый шаг — включить лог медленных запросов и посмотреть реальные планы (EXPLAIN ANALYZE). Часто 90% времени отклика эндпоинта — это один тяжёлый запрос.

N+1 запросы. Бич ORM. Код загружает список из N сущностей, а потом на каждую делает отдельный запрос за связанными данными — итого N+1 обращений к базе вместо одного-двух. На пустой тестовой базе незаметно, под нагрузкой с реальными объёмами кладёт сервис:

// Антипаттерн: N+1 — по запросу на заказ за позициями
val orders = orderRepository.findByStatus(Status.NEW)
orders.forEach { order ->
    val items = itemRepository.findByOrderId(order.id) // +1 запрос на каждый заказ
    process(order, items)
}

// Решение: один запрос с join fetch
@Query("select distinct o from Order o join fetch o.items where o.status = :status")
fun findNewWithItems(status: Status): List<Order>

Пулы соединений и потоков. Слишком маленький пул к БД — потоки стоят в очереди за соединением, latency растёт, хотя CPU простаивает. Слишком большой — перегружает базу. Размер пула подбирают по данным нагрузочного теста, а не «на глаз». То же касается пулов HTTP-клиентов и тред-пулов: занятость пула надо мониторить как отдельную метрику.

Блокировки и contention. Излишняя синхронизация, «горячие» локи, на которых сериализуются все потоки. На lock-профиле async-profiler такие места видны сразу. Лечится переходом на неблокирующие структуры, уменьшением критических секций, шардированием состояния.

Сериализация. JSON-сериализация/десериализация на горячем пути способна съедать заметную долю CPU, особенно на крупных ответах. Здесь помогает выбор быстрого маппера, отказ от лишних преобразований и пагинация вместо «отдать всё».

Внешние вызовы. Синхронные обращения к чужим сервисам и БД без таймаутов и предохранителей превращают чужую деградацию в свою. Здесь производительность смыкается с устойчивостью: таймауты, ретраи и circuit breaker на Resilience4j не дают одному медленному соседу выстроить очередь и положить весь сервис. Для тяжёлых интеграций часто правильнее уйти от синхронного вызова в асинхронную обработку через событийную шину на Kafka и паттерн Outbox, сняв нагрузку с критичного пути запроса.

Настройка JVM: G1 или ZGC

Сборщик мусора напрямую влияет на хвосты latency. Опытные команды давно «не используют стандартный сборщик по умолчанию» там, где важны паузы. Выбор сводится к двум современным вариантам.

G1 GC (по умолчанию с Java 9) — сбалансированный сборщик для большинства сервисов. Позволяет задать целевую паузу и обычно держит её в пределах десятков миллисекунд. Хороший дефолт, пока паузы укладываются в SLO.

-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms4g -Xmx4g

ZGC — сборщик с паузами, которые практически не зависят от размера heap и измеряются долями миллисекунды. Выбор для latency-критичных сервисов и больших куч, где паузы G1 уже не помещаются в требования по p99:

-XX:+UseZGC -Xms16g -Xmx16g

Несколько общих правил настройки heap, проверенных практикой:

  • Xms = Xmx. Фиксированный размер кучи убирает дорогие ресайзы под нагрузкой.
  • Не раздувать heap бездумно. Показателен приём из эксплуатации Kafka: память ограничивают сознательно (например, «не больше 24 ГБ»), потому что «лишнюю она съест из кэша операционной системы — и тогда мы получим деградацию». Память, отнятая у page cache ОС, мстит.
  • Сначала измерить, потом крутить флаги. Смена сборщика без baseline и нагрузочного теста — это вера, а не инженерия.

Менять GC и параметры heap нужно по одному и каждый раз прогонять нагрузочный тест: цель — конкретное улучшение p99 и доли CPU в GC, а не «поставили модный сборщик».

Кэширование как рычаг

«Может, лучше кэширование сделаете, проработаете узкое место — как будто не сильно сложно». В чате это звучит наивно, но по сути верно: кэш — самый сильный рычаг, когда узкое место — повторяющееся дорогое чтение.

Уровни, на которых кэшируют backend:

  • Локальный кэш в памяти процесса (Caffeine) — самый быстрый, но не общий для инстансов и ест heap.
  • Распределённый кэш (Redis) — общий для всех инстансов, переживает рестарт сервиса, но добавляет сетевой хоп.
  • Кэш результатов запросов и HTTP-ответов — с аккуратной инвалидацией по событиям.

Кэш не бесплатен. Локальный кэш увеличивает потребление памяти и давление на GC — это снова возвращает к профилированию heap. Распределённый добавляет зависимость и сетевую задержку. И главная проблема любого кэша — инвалидация: устаревшие данные хуже, чем медленные. Поэтому кэш вводят как одну измеряемую правку: сняли baseline, добавили кэш, перемерили p95/p99, hit rate и потребление памяти — и только тогда оставляют.

Что фиксировать до и после

Ценность аудита — в доказанном результате, а не в списке внесённых правок. Поэтому каждое изменение сопровождается замером по единому шаблону «до/после»:

ПоказательДоПосле
p50 latency, мс12045
p95 latency, мс480110
p99 latency, мс1900240
Throughput, RPS8502100
Error rate, %0.70.02
CPU под целевой нагрузкой, %9555
Доля времени в GC, %143

Цифры в таблице — иллюстративные, но именно в таком виде результат должен ложиться на стол. Из практики гонки за пропускной способностью известно, что грамотная работа со стеком и кодом даёт прирост на порядки — например, оптимизация обработчика JSON-API поднимала пропускную способность с 224 тысяч до 1.2 миллиона запросов в секунду на той же машине. Большинству enterprise-сервисов такие рекорды не нужны — нужно предсказуемо попасть в SLO. Но логика одна: без baseline и контрольных замеров любое из этих чисел недоказуемо.

И последнее: закрепляйте результат. Нагрузочный тест с порогами по перцентилям должен жить в CI и падать, если новая версия деградировала. Иначе через три релиза система незаметно вернётся к тому, с чего начинали.

Как заказать аудит производительности

Аудит производительности — это компетенция на стыке разработки, JVM-эксплуатации и работы с базами данных. Чтобы он принёс пользу, на входе полезно дать:

  • описание критичных сценариев и текущие жалобы («тормозит вот этот эндпоинт под нагрузкой»);
  • целевые SLO или хотя бы бизнес-ориентиры по latency и нагрузке;
  • доступ к метрикам, логам и тестовому окружению, близкому к продакшену.

На выходе вы получаете воспроизводимый нагрузочный профиль, найденные узкие места с доказательствами (flame graph, планы запросов, GC-логи), внесённые оптимизации и таблицу «до/после» по ключевым метрикам, а также регрессионный тест, который не даст деградации вернуться.

Мы в Новакоме проводим аудит и оптимизацию производительности backend на Java/Kotlin — от нагрузочного тестирования и профилирования JVM до настройки сборщика, пулов, кэширования и переработки горячих участков кода. Посмотрите наши услуги по разработке на Spring и аутстаффингу Java-команд или напишите нам с описанием вашего сервиса и симптомов — начнём с baseline и точки насыщения.

FAQ

Чем p99 важнее среднего времени отклика? Среднее маскирует выбросы: при среднем 60 мс часть запросов может занимать секунды из-за GC-пауз, блокировок или исчерпания пулов. Именно эти «хвосты» (p95/p99) пользователи воспринимают как «тормоза». Оптимизируют в первую очередь хвосты, а не среднее.

Можно ли проводить нагрузочное тестирование прямо на продакшене? Полноценный стресс-тест на боевом окружении рискован. Правильнее — отдельный стенд с теми же ресурсами и реалистичным объёмом данных в базе. На продакшене допустимы аккуратные приёмы (теневой трафик, ограниченные канареечные нагрузки) с готовым планом отката.

G1 или ZGC — что выбрать? G1 — разумный дефолт для большинства сервисов, держит паузы в пределах десятков миллисекунд. ZGC берут, когда нужны субмиллисекундные паузы или большой heap, на котором паузы G1 уже не помещаются в SLO по p99. В обоих случаях выбор подтверждают нагрузочным тестом, а не модой.

Сколько времени занимает аудит производительности? Зависит от размера системы, но базовый цикл — снять baseline, найти точку насыщения, профилировать и закрыть несколько ключевых узких мест — обычно укладывается в одну-три недели. Дальше — итеративная оптимизация под конкретные SLO.

С чего начать, если сервис «тормозит», но непонятно где? Не с правок кода. Сначала включить метрики latency по перцентилям и GC-логи, прогнать нагрузочный тест до точки насыщения и снять CPU-профиль async-profiler в этот момент. В 9 случаях из 10 flame graph и планы запросов сразу показывают, куда уходит время.

РАЗРАБОТКА

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

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

Обсудить проект
ЧИТАЙТЕ ДАЛЬШЕ

Похожие материалы.

ИСКУССТВЕННЫЙ-ИНТЕЛЛЕКТ

Гениальные применения ИИ: когда нейросети делают то, что не могли люди

Подборка по-настоящему впечатляющих применений ИИ с ссылками на репозитории: AlphaEvolve и FunSearch, открывающие новые алгоритмы, самоулучшающиеся агенты (AI Scientist, Gödel Agent, DGM), автоматизация научных открытий, ИИ как «великий уравнитель» и что из этого реально применимо в бизнесе.

2026-06-28 · 16 мин
МЕЖБАНКОВСКИЕ-РАСЧЕТЫ

Межбанковская расчётная система: ностро, лоро и ликвидность на зарубежных счетах

Как устроена межбанковская расчётная система и как вообще перемещать деньги по миру: корреспондентские счета ностро/востро/лоро, клиринг и неттинг (CHIPS, RTGS, SWIFT), все способы образования ликвидности и перевода средств — префандинг, FX-свопы, репо, MTO (Wise, Золотая Корона), P2P-мэтчинг, P2P-крипто, стейблкоины, hawala — и инженерный взгляд на разработку такой системы.

2026-06-28 · 25 мин
ESP32

ESP32 и mesh-сети: ESP-NOW, ESP-WIFI-MESH, BLE Mesh и Thread/Matter

Серьёзный технический разбор mesh-сетей на ESP32: чем mesh отличается от обычного Wi-Fi, протоколы ESP-NOW, ESP-WIFI-MESH/Mesh-Lite, BLE Mesh и Thread/Matter на ESP32-H2, роли узлов, self-healing, реальные грабли (RSSI, расстояние между узлами, документация) и как выбрать протокол под задачу.

2026-06-28 · 17 мин