Всем привет! Меня зовут Михаил, я работаю главным экспертом в ОТП Банке.
Я люблю тестировать свои решения и почти всегда пишу 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
Всем спасибо за внимание!)-Источник