Всем привет! Меня зовут Михаил, я работаю главным экспертом в ОТП Банке.
Я люблю тестировать свои решения и почти всегда пишу unit- и integration-тесты. Но вот с нагрузочным тестированием ситуация обычно совсем другая: о нем вспоминают ближе к релизу, когда архитектуру уже поздно менять.
В какой-то момент я поймал себя на мысли:
А как вообще заранее понять, сколько ресурсов будет потреблять сервис под нагрузкой?
Сколько памяти съест приложение? Когда упрется в CPU? Как поведет себя БД при разном кол-ве запросов?
Чтобы ответить на эти вопросы, я написал небольшую библиотеку для локального нагрузочного тестирования на Java Virtual Threads. Она запускает большое количество задач, собирает метрики и формирует отчет — прямо в консоли или в CSV.
Сегодня я покажу сам подход, разберу код библиотеки и оставлю ссылку на GitHub-репозиторий, чтобы вы могли попробовать ее у себя или адаптировать под свои задачи.
Что тестируем сегодня
Я написал небольшой и очень простой код. Создаем пользователя в бд и отправляем запрос на регистрацию в смежную систему:
@Service@RequiredArgsConstructorpublic 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(); }}@Componentpublic 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: 10000Completed requests: 10000Total duration: 1630 msThroughput: 6134.97 requests/sec---------------- LATENCY ----------------Average latency: 1165.37 msP95 latency: 1482.79 msP99 latency: 1491.56 ms---------------- RESOURCES ----------------Peak CPU usage: 51.45 %Peak heap memory usage: 389 MBHeap limit (MB): —---------------- SNAPSHOTS ----------------Collected metrics snapshots: 17---------------- ERRORS ----------------Errors count: 9763====================================================
Какие метрики собираются
Идея простая — не перегружать отчет сотнями метрик, а дать набор ключевых:
-
насколько быстро система обрабатывает нагрузку;
-
где находится latency (avg / p95 / p99);
-
упирается ли она в CPU или память;
-
есть ли ошибки и в каком объеме.
@Builder@Getter@AllArgsConstructor@NoArgsConstructorpublic 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@AllArgsConstructorpublic 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: 1000Completed requests: 1000Total duration: 587 msThroughput: 1703.58 requests/sec---------------- LATENCY ----------------Average latency: 440.37 msP95 latency: 566.43 msP99 latency: 567.26 ms---------------- RESOURCES ----------------Peak CPU usage: 27.73 %Peak heap memory usage: 68 MBHeap 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
Всем спасибо за внимание!)
ссылка на оригинал статьи https://habr.com/ru/articles/1035238/