Локальное нагрузочное тестирование в Java с использованием Virtual Threads

от автора

Всем привет! Меня зовут Михаил, я работаю главным экспертом в ОТП Банке.

Я люблю тестировать свои решения и почти всегда пишу 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/