Всем привет! Меня зовут Михаил, я работаю главным экспертом в ОТП Банке.
Я люблю тестировать свои решения и почти всегда пишу unit- и integration-тесты. Но вот с нагрузочным тестированием ситуация обычно совсем другая: о нем вспоминают ближе к релизу, когда архитектуру уже поздно менять.
В какой-то момент я поймал себя на мысли:
А как вообще заранее понять, сколько ресурсов будет потреблять сервис под нагрузкой?
Сколько памяти съест приложение? Когда упрется в CPU? Как поведет себя БД при разном кол-ве запросов?
Чтобы ответить на эти вопросы, я написал небольшую библиотеку для локального нагрузочного тестирования на Java Virtual Threads. Она запускает большое количество задач, собирает метрики и формирует отчет - прямо в консоли или в CSV.
Сегодня я покажу сам подход, разберу код библиотеки и оставлю ссылку на GitHub-репозиторий, чтобы вы могли попробовать ее у себя или адаптировать под свои задачи.
Что тестируем сегодня
Я написал небольшой и очень простой код. Создаем пользователя в бд и отправляем запрос на регистрацию в смежную систему:
@Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final OtherSystemClient otherSystemClient; @Transactional public void createUser() { String randomEmail = UUID.randomUUID() + "@gmail.com"; User user = buildUser(randomEmail); otherSystemClient.registrationUser(new RegistrationDto(randomEmail)); userRepository.save(user); } private User buildUser(String randomEmail) { return User.builder() .email(randomEmail) .status(UserStatus.NEW) .isActive(true) .name("Легенда") .build(); } } @Component public class OtherSystemClient { public RegistrationResponseDto registrationUser(RegistrationDto dto) { LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10)); return new RegistrationResponseDto(UserStatus.SUCCESS.name(), null); } }
Самый очевидный вариант - поднять приложение и начать дергать HTTP endpoint через Postman, JMeter или любой другой инструмент.
Но здесь появляются проблемы:
не вся логика доступна через endpoint;
иногда хочется протестировать конкретный сервис или даже небольшой участок кода;
для локальной проверки поднимать полноценный нагрузочный стенд часто слишком дорого и долго.
В итоге появляется странная ситуация:
мы умеем хорошо тестировать корректность кода, но почти не проверяем его поведение под реальной нагрузкой на ранних этапах разработки.
High-load-tester библиотека
Идея была простой:
хотелось получить нагрузочный тест буквально в несколько строк кода и запускать его на любом участке приложения - не только на HTTP endpoint.
Например:
сервис;
repository;
интеграция с БД;
вызов внешнего API;
или даже отдельный метод.
При этом тест можно запускать:
локально;
внутри integration tests;
или как часть CI.
Ниже - пример integration-теста.
Внутри TestContext поднимается PostgreSQL через Testcontainers, а сам тест запускается в обычном @SpringBootTest.
class UserServiceLoadCurveIT extends TestContext { @Autowired private UserService userService; @Test public void shouldCreateUsersWithMediumRpsCurrentWork() { LoadTestReport report = RunnableChecker.run( RunnableTesting.builder() .requestCount(1000) .task(userService::createUser) .build() ); Assertions.assertTrue(report.getErrors().isEmpty()); } }
И все, чтобы после этого мы получили полную сводку метрик, пример:
================= LOAD TEST REPORT ================= Total requests: 10000 Completed requests: 10000 Total duration: 1630 ms Throughput: 6134.97 requests/sec ---------------- LATENCY ---------------- Average latency: 1165.37 ms P95 latency: 1482.79 ms P99 latency: 1491.56 ms ---------------- RESOURCES ---------------- Peak CPU usage: 51.45 % Peak heap memory usage: 389 MB Heap limit (MB): — ---------------- SNAPSHOTS ---------------- Collected metrics snapshots: 17 ---------------- ERRORS ---------------- Errors count: 9763 ====================================================
Какие метрики собираются
Идея простая - не перегружать отчет сотнями метрик, а дать набор ключевых:
насколько быстро система обрабатывает нагрузку;
где находится latency (avg / p95 / p99);
упирается ли она в CPU или память;
есть ли ошибки и в каком объеме.
@Builder @Getter @AllArgsConstructor @NoArgsConstructor public class LoadTestReport { /** Запрошенное число выполнений задачи (как в конфигурации). */ private long totalRequests; /** Фактически завершённых задач после блока finally исполнителя. */ private long completedRequests; /** Длительность всего прогона по настенным часам, миллисекунды. */ private long durationMs; /** Завершённые запросы в секунду (число завершённых / длительность в секундах). */ private double throughput; /** Средняя латентность одной задачи, миллисекунды. */ private double avgLatencyMs; /** Оценка 95-го персентиля латентности, миллисекунды. */ private double p95LatencyMs; /** Оценка 99-го персентиля латентности, миллисекунды. */ private double p99LatencyMs; /** Максимальная оценка загрузки CPU по снимкам метрик, в процентах. */ private double peakCpuLoad; /** Максимальный зарегистрированный объём heap по снимкам, мегабайты. */ private long peakMemoryMb; /** Временной ряд снимков метрик во время прогона (может быть пустым). */ private List<MetricsSnapshot> snapshots; /** Краткие сообщения об ошибках (ожидание future, лимит памяти и т.п.). */ private List<String> errors; /** Лимит heap (МБ), скопированный из конфигурации; если не задавали — null. */ private Long heapLimitMb; // логика сбора метрик }
Сам тест описывается через простой конфиг:
@Builder @Getter @NoArgsConstructor @AllArgsConstructor public class RunnableTesting { /** Код одной единицы нагрузки; вызывается столько раз, сколько задано в поле ниже. */ private Runnable task; /** Сколько раз отправить задачу в пул виртуальных потоков. */ private int requestCount; /** Верхний предел используемого heap (МБ), или null — не проверять. */ private Long heapLimitMb; }
Сейчас библиотека поддерживает три базовых параметра:
сама нагрузочная задача;
количество запусков;
лимит heap (если нужно симулировать ограниченные условия).
Зачем это все нужно?
Давайте смоделируем довольно типичную ситуацию.
У нас есть приложение, которое работает с базой данных. И рано или поздно именно база становится узким местом - не CPU, не код, а количество одновременных подключений.
Хочется быстро ответить на вопросы:
что будет при высокой конкуренции за соединения?
как система деградирует под нагрузкой?
где начинаются блокировки и ожидания?
как ведет себя код при параллельных транзакциях?
Для примера ограничим пул подключений к БД:
spring: jpa: hibernate: ddl-auto: create-drop datasource: hikari: # Для демонстрации исчерпания пула: мало слотов + короткое ожидание выдачи соединения maximum-pool-size: 20 minimum-idle: 0 connection-timeout: 500
Теперь запускаем 1000 виртуальных потоков, каждый из которых пытается выполнить операцию с базой:
================= LOAD TEST REPORT ================= Total requests: 1000 Completed requests: 1000 Total duration: 587 ms Throughput: 1703.58 requests/sec ---------------- LATENCY ---------------- Average latency: 440.37 ms P95 latency: 566.43 ms P99 latency: 567.26 ms ---------------- RESOURCES ---------------- Peak CPU usage: 27.73 % Peak heap memory usage: 68 MB Heap limit (MB): — ---------------- SNAPSHOTS ---------------- Collected metrics snapshots: 6 ---------------- ERRORS ---------------- Errors count: 517 ==================================================== Также я еще вывывел ошибки: [org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction и бла бла бла]
С помощью такого подхода можно очень быстро менять условия эксперимента:
уменьшать или увеличивать pool;
менять количество конкурентных запросов;
проверять поведение кода при перегрузке БД;
находить узкие места до того, как это случится в проде.
Про ресурсы и честность локальных тестов
Когда начинаешь гонять нагрузочные тесты локально, быстро всплывает очевидная проблема:
твоя машина ≠ продакшен.
И это важно понимать.
Есть разные способы приблизить локальные тесты к реальности:
ограничение CPU (cgroups / Docker)
лимиты памяти (-Xmx, container limits)
имитация задержек сети
throttling через rate limit
запуск в контейнерах
использование выделенных стендов
Но важно другое: эта библиотека не пытается заменить полноценный load testing инструмент.
Ее задача другая:
быстро понять, как ведет себя конкретный кусок кода под конкурентной нагрузкой прямо в процессе разработки.
при необходимости тест можно дополнительно “приземлить” к реальным условиям - через Docker лимиты, настройку JVM или запуск в CI-окружении, но это уже слой над библиотекой, а не её ответственность.
Как все это работает
Теперь коротко разберем внутреннее устройство.
1. Запуск нагрузки через virtual threads
Все задачи отправляются в Executors.newVirtualThreadPerTaskExecutor():
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { ... }
Это позволяет дешево создать тысячи конкурентных задач без классических thread pool ограничений.
2. Сбор метрик во время выполнения
Параллельно запускается отдельный ScheduledExecutorService, который каждые 100 мс снимает:
CPU usage
heap memory
количество активных задач
прогресс выполнения
3. Постобработка результатов
После завершения прогона считаются:
latency (avg / p95 / p99)
throughput
пики CPU и памяти
ошибки выполнения
Возможность собирать отчеты в виде CSV
Иногда одного вывода в консоль недостаточно.
Например, когда нужно:
сравнить несколько прогонов;
зафиксировать результаты для анализа;
или просто сохранить историю изменений производительности.
Для этого в библиотеке есть возможность сохранить отчет в CSV:
@Test public void currentTest() { LoadTestReport report = RunnableChecker.run( RunnableTesting.builder() .requestCount(1000) .task(userService::createUser) .build() ); CsvReportGenerator.generateRunnableReport("report/load-report.csv", report); }
CSV содержит все ключевые метрики прогона, поэтому его можно:
открыть в Excel / Google Sheets;
сравнить разные конфигурации;
построить свои графики поверх данных.
Итог
Эта библиотека - не попытка заменить полноценные инструменты нагрузочного тестирования.
Она про другое: быстрые локальные эксперименты, когда нужно понять, как ведет себя конкретный кусок кода под конкурентной нагрузкой, без подготовки стендов и сложной инфраструктуры.
По сути, это способ задать себе несколько простых вопросов прямо во время разработки:
что будет, если увеличить нагрузку в 10–100 раз?
где упрется система: CPU, память или база?
как быстро деградирует код при конкуренции?
Virtual threads здесь выступают просто как удобный механизм для генерации высокой конкуренции без накладных расходов на потоки.
Если у вас есть идеи, что еще можно добавить в такие локальные тесты — пишите, интересно сравнить подходы.
Мне было интересно исследовать, как virtual threads ведут себя под высокой нагрузкой и можно ли сделать JVM-native performance framework для тестирования Runnable/Callable без HTTP-слоя.
В процессе получился небольшой experimental framework, которого пока нет в maven.
Сама библиотечка - https://github.com/MishaBucha/high-load-tester/tree/develop
Всем спасибо за внимание!)