Реактивная Java: когда она ускоряет систему, а когда просто усложняет жизнь (часть 1)

Привет, Хабр! Меня зовут Вадим Белый, я Middle Java-разработчик в компании «Синимекс». Часть моей работы — помогать джунам разбираться с архитектурными решениями.
Иногда у нас возникают обсуждения на тему: «Зачем заморачиваться с WebFlux, если можно продолжать писать на Spring MVC?». Чтобы предметно обсуждать эту тему, мы с командой провели внутренний бенчмарк, написали методичку и решили поделиться некоторыми соображениями в этом цикле статей.
В этой — первой — статье я расскажу, как устроены три основные модели обработки запросов в Java, покажу их сильные и слабые стороны и дам критерии, которые помогут выбрать подходящий вариант для вашего проекта.
Дисклеймер: для Java-сеньоров в этой статье скорее всего ничего нового не будет.
Почему классический Spring MVC может не справиться с нагрузкой
Большинство Java-веб-приложений используют классическую модель обработки запросов, реализованную в Spring MVC.
Если вы только знакомитесь со стеком, то Spring MVC — это:
модуль фреймворка Spring для создания веб‑приложений и
REST API, работающий поверх Servlet API, — стандарта, по которому Java‑классы (сервлеты) принимают HTTP‑запросы и формируют ответы, а запускает их контейнер, например Tomcat.
Итак, в этой модели каждый входящий HTTP-запрос обрабатывается отдельным потоком из пула потоков сервера. Поток закрепляется за запросом на всё время обработки, включая ожидание операций ввода-вывода — обращение к базе данных, внешним API, файловой системе. Подход не требует дополнительных абстракций и основан на привычной модели «один поток на запрос».
У этой модели есть важная особенность: во время ожидания ответа от базы данных или внешнего сервиса поток фактически ничего не делает, но остается занятым (рис. 1). Такая операция называется блокирующей (blocking).
При реализации типового сценария:
Клиент отправляет HTTP-запрос.
Сервер выделяет поток для обработки запроса.
Приложение обращается к базе данных.
Поток ожидает ответа от базы данных.
После получения ответа формируется HTTP-ответ.

Отмечу, что сама по себе проблема не нова: блокирующий ввод-вывод существовал и до появления Spring MVC. Эту модель фреймворк унаследовал от сервлетных контейнеров (где, как я уже говорил выше, Tomcat выделяет поток, а Spring MVC лишь встраивается в этот механизм).
Итак, если система обрабатывает небольшое количество запросов, это обычно не вызывает проблем. Но при увеличении количества запросов ситуация меняется.
Когда приложение выполняет операции обращения к базам данных, файловым системам или внешним API, часть времени обработки запроса уходит на ожидание завершения этих операций.
Это приводит к нескольким последствиям:
требуется всё больше потоков для обслуживания запросов;
растет потребление памяти — каждый поток имеет свой стек, обычно около 1 МБ;
увеличиваются затраты на переключение контекста между потоками;
забивается пул потоков, новые запросы встают в очередь и ждут;
общая пропускная способность падает;
ухудшается масштабируемость системы.
Иными словами, сервер не обрабатывает новые запросы, а тратит ресурсы на ожидание завершения операций ввода-вывода.
Три модели выполнения: Thread Pool, Virtual Threads и Reactor
Чтобы понять, какой подход выбрать, нужно разобраться, как работают три основные модели выполнения в современной Java (рис. 2).

Модель 1: Thread Pool + Очередь (классический Spring MVC)
В коде она выглядит как обычное последовательное выполнение: приняли запрос, обратились к репозиторию, вернули результат. Вся специфика модели умещается в одну деталь: в момент обращения к репозиторию поток, который принял запрос, останавливается и ждет ответа БД (листинг 1).
Листинг 1. GET-эндпоинт на Spring MVC: поток блокируется на вызове репозитория и остается занят до ответа БД
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// Поток блокируется здесь, пока БД не ответит
return userRepository.findById(id).orElseThrow();
}Преимуществом модели можно считать то, что она достаточно проста и понятна благодаря линейной последовательности выполнения, которая не требует знания асинхронных абстракций.
Однако если количество одновременных запросов превышает число потоков в пуле, новые запросы помещаются в очередь. Пропускная способность системы в этом случае ограничена размером пула потоков.
Модель 2: виртуальные потоки (Project Loom, Java 21+)
Виртуальные потоки появились в Java 21. Это легковесные потоки, управляемые JVM, а не ОС. Они потребляют меньше памяти, чем системные потоки (порядка нескольких килобайт против ~1 МБ), и снижают накладные расходы на переключение контекста на уровне ОС.
Ключевая идея в том, что при использовании виртуальных потоков на каждый запрос по-прежнему выделяется отдельный поток, но этот поток не привязан к потоку ОС (т.е. системному) постоянно. Во время блокирующих операций виртуальный поток паркуется, а системный поток освобождается для выполнения других задач.
Это дает поведение, близкое к реактивной модели (о ней как раз ниже), но без необходимости писать неблокирующий код.
Вот как выглядит тот же эндпоинт с виртуальными потоками (листинг 2). Обратите внимание: синтаксис контроллера не меняется. Мы используем те же аннотации Spring, а вызов метода репозитория выглядит как стандартный синхронный запрос. Вся разница скрыта на уровне JVM: в момент ожидания ответа от БД виртуальный поток паркуется, а системный поток тут же подхватывает другой запрос.
Листинг 2. Обработка запроса на виртуальных потоках: императивный стиль кода сохраняется, а управление потоками берет на себя JVM
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// Код остаётся блокирующим синтаксически,
// но внутри выполняется неблокирующим образом
return userRepository.findById(id).orElseThrow();
}
}А в файле настроек приложения, обычно application.yml или application.properties, в Spring Boot достаточно включить виртуальные потоки, добавив следующее свойство:
yaml
spring:
threads:
virtual:
enabled: trueПреимущество модели — возможность создавать десятки и сотни тысяч потоков без пропорционального увеличения потребления памяти. При этом код остается императивным, потому что нет реактивных цепочек и операторов, в отличие от реактивной модели. Но у виртуальных потоков есть ограничения, которых лишена реактивная модель (о ней поговорим прямо сейчас), — их разберем в разделе 3.
Модель 3: реактивная модель (Spring WebFlux + Project Reactor)
Реактивная модель построена на неблокирующих операциях ввода-вывода и событийно-ориентированном выполнении. Здесь речь идет только о системных потоках, но их пул жестко ограничен (обычно по числу ядер CPU) — это так называемый Event Loop.
В отличие от предыдущих моделей, здесь нет концепции «поток на запрос». Поток не привязывается к запросу, а освобождается сразу после отправки асинхронной операции. Состояние запроса не хранится в стеке потока, а остается в памяти в виде цепочки операторов (pipeline). Когда приходит ответ, обработка продолжается в одном из доступных системных потоков.
Если в процессе обработки возникает операция ввода-вывода, то:
выполнение не блокируется;
поток освобождается;
обработка продолжается позже, когда данные становятся доступны.
Вместо блокирующего ожидания используется механизм событий. Это позволяет небольшому числу потоков обслуживать большое количество запросов.
Вопрос: в каком виде «состояние запроса» существует в коде?
Ответ: для описания таких цепочек существует спецификация Reactive Streams, а одна из ее реализаций на Java — Project Reactor. Spring WebFlux, в свою очередь, — модуль Spring, который строит обработку HTTP-запросов поверх Project Reactor. То есть Project Reactor дает типы и операторы для построения цепочек, а WebFlux связывает их с веб-сервером.
Базовые типы Project Reactor, реализующие интерфейс Publisher (источник данных) из спецификации Reactive Streams:
Mono<T>— асинхронная последовательность из0или1элемента. Можно сравнитьMonoс конвертом: в момент сборки цепочки значения внутри еще нет, есть только план его получения. Значение — «письмо» — попадает в конверт позже, когда асинхронная операция завершается.Flux<T>— асинхронная последовательность из0..Nэлементов. Продолжая метафору:Flux— это конвейер не конвертов, а писем. Адресат получает сами письма, одно за другим, по мере того как каждое из них готово, — а не запечатанные конверты-Mono.
Эти типы описывают будущий результат. Цепочка операторов (pipeline) задает преобразования, но не выполняется до тех пор, пока на нее кто-то не подпишется — метод subscribe(). В WebFlux-приложении вызывать его в контроллере самому не нужно: на Mono или Flux, которые вернул метод контроллера, подписывается сервер (по умолчанию Reactor Netty) в момент получения HTTP-запроса. С этого момента pipeline начинает выполняться, а его результат уходит в ответ.
Вот как выглядит тот же эндпоинт в реактивном стиле (листинг 3). Снова посмотрим на код: аннотации роутинга и извлечения параметров (@GetMapping, @PathVariable) остаются теми же, что и в Spring MVC — Spring WebFlux переиспользует знакомые контракты. Главное отличие — в возвращаемом типе: метод контроллера возвращает не самого пользователя, а Mono<User>, то есть обещание получить пользователя в будущем.
Вызов userRepository.findById(id) в этот момент не делает запроса к БД — он только возвращает Mono, к которому можно добавлять операторы. Оператор .switchIfEmpty(...) описывает, что произойдет, если результат окажется пустым: мы подменяем пустой Mono на Mono с ошибкой NotFoundException. Метод контроллера завершается почти мгновенно, возвращая собранную цепочку, а фактическое обращение к БД произойдет только после того, как сервер подпишется на этот Mono.
Листинг 3. GET-эндпоинт на Spring WebFlux: метод контроллера возвращает Mono, ни один поток не блокируется на время ожидания БД
@GetMapping("/users/{id}")
public Mono getUser(@PathVariable Long id) {
// Ни один поток не блокируется — callback-цепочка
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new NotFoundException()));
}Преимущество модели — масштабируемость за счет неблокирующего ввода-вывода: небольшое число потоков обслуживает тысячи соединений. Модель поддерживает нативный стриминг и обеспечивает меньшее потребление памяти по сравнению с thread pool.
При этом для применения модели требуется освоить новые концепции (Reactive Streams, операторы, подписка). Отладка усложняется из-за асинхронного выполнения и отсутствия линейного стека вызовов.
Но главная сложность даже не в этом. Реактивная модель требует неблокирующих драйверов и библиотек по всему стеку — то есть придется менять не только подход к написанию кода, но и сами технологии:
База данных: классический JDBC и JPA/Hibernate не подойдут, потому что они блокируют поток на время запроса. Нужны R2DBC-драйверы (для PostgreSQL, MySQL, MariaDB) и Spring Data R2DBC вместо Hibernate.
Веб-сервер: вместо Tomcat с его пулом потоков используется Reactor Netty (или другой неблокирующий сервер), который работает на event loop.
HTTP-клиенты: если ваш сервис обращается к другим API, синхронные клиенты вроде RestTemplate или Apache HttpClient не подойдут — нужен WebClient или другие реактивные HTTP-клиенты.
NoSQL базы: для MongoDB, Redis, Cassandra нужны их реактивные драйверы (например, ReactiveMongoRepository, ReactiveRedisTemplate).
Получается, что переход на WebFlux — это вам не просто переписать контроллеры с Mono/Flux. Это смена всего стека: драйвер БД, ORM, HTTP-клиенты, иногда даже сама база данных, если для неё нет реактивного драйвера.
Сравнительная таблица подходов
Критерий | Thread pool + очередь | Виртуальные потоки | Реактивный подход |
Модель выполнения | 1 поток на запрос | 1 виртуальный поток на запрос | Небольшое число потоков на множество запросов |
Работа при I/O | Поток блокируется | Виртуальный поток паркуется, системный освобождается | Поток освобождается |
Масштабируемость | Ограничена числом потоков | Выше, но зависит от реализации I/O | Ограничена CPU и I/O |
Потребление памяти | Каждый поток занимает ~1 МБ стека | Меньше, чем у системных потоков | Наименьшее среди трех |
Переключение контекста | Частое | Реже на уровне ОС: переключения происходят внутри JVM | Реже |
Сложность разработки | Низкая (императивный код) | Низкая (императивный код) | Высокая (требует асинхронного мышления) |
Отладка | Простая (линейный стек) | Простая (линейный стек) | Сложная (стек не отражает порядок выполнения) |
Поддержка библиотек | Широкая | Растущая | Ограниченная |
3. Темная сторона виртуальных потоков
И вот мы подобрались к вышеобещанному 3-му разделу, про нюансы.
Когда в Java 21 появились виртуальные потоки, многие разработчики надеялись, что это станет полной заменой WebFlux и универсальным решением — пишешь обычный блокирующий код, а масштабируемость получаешь, как в реактивном подходе. И вроде как получилось похоже, но с оговорками.
Ограничение 1: Pinning (прикрепление к системному потоку)
Одно из ограничений виртуальных потоков — pinning. Этот эффект связан с механизмом Java для защиты общих данных: блок, помеченный как synchronized, может выполнять только один поток одновременно, остальные ждут, пока блокировка не освободится. Так потоки не могут одновременно изменять один и тот же объект.
Если виртуальный поток входит в блок synchronized, он может «прикрепиться» (pin) к системному потоку. В этом случае во время блокирующей операции (например, I/O внутри синхронизированного блока) системный поток не освобождается.
В этом случае теряется ключевое преимущество виртуальных потоков — эффективное использование системных потоков — и поведение начинает напоминать классическую блокирующую модель.
На практике это означает: если ваш код использует synchronized (что характерно для многих легаси-проектов и некоторых библиотек), виртуальные потоки могут не дать прироста производительности, ожидаемого от неблокирующего выполнения. Кстати, в Java 24 это поведение изменили: согласно JEP 491 блокировка внутри synchronized больше не прикрепляет виртуальный поток. Но наш бенчмарк и эта статья опираются на Java 21 LTS, где ограничение еще актуально.
Ограничение 2: Отсутствие встроенного backpressure
Виртуальные потоки не имеют встроенных механизмов обратного давления (backpressure), как в реактивных стримах.
Backpressure — это механизм, который позволяет потребителю данных контролировать скорость их получения от производителя. Если производитель генерирует данные быстрее, чем потребитель их обрабатывает, без backpressure может произойти переполнение памяти (OutOfMemoryError).
В реактивных стримах backpressure встроен на уровне спецификации Reactive Streams. В виртуальных потоках эту проблему приходится решать вручную.
Ограничение 3: Зависимость от драйверов
Эффективность виртуальных потоков напрямую зависит от того, как реализован ввод-вывод в используемых библиотеках. При использовании блокирующих драйверов (например, JDBC — стандартного блокирующего API Java для работы с реляционными БД) системный поток все равно будет парковаться на время сетевого ожидания, но драйвер может удерживать ресурсы (например, соединение с БД) дольше, чем при использовании неблокирующего драйвера.
Другими словами, виртуальные потоки не превращают блокирующий код в неблокирующий — они лишь снижают стоимость блокировки на уровне JVM.
4. Бенчмарк «Синимекс»: сравнение MVC и WebFlux
У себя в «Синимекс» мы решили проверить на практике, как ведут себя классический MVC и WebFlux под нагрузкой. Мы реализовали тестовый сервис в двух вариантах: на Spring MVC и на Spring WebFlux.
Отдельного тестового стенда мы не поднимали — бенчмарк запускался на рабочем компьютере:
процессор Intel Core i5-7500 (4 ядра), 16 ГБ оперативной памяти;
Java 21 и Spring Boot 3.2.3 (версии библиотек и драйверов подтягиваются из менеджера зависимостей Spring Boot);
в mvc-service для Tomcat было настроено максимум 300 потоков;
в reactive-service
boundedElasticиevent loopработали на дефолтных настройках: с учетом 4 ядер это давало до 40 потоков дляboundedElastic(10 × CPU) и 4 потока дляevent loop.
Исходный код всех трех модулей (mvc-service, reactive-service, benchmark) выложен на GitHub.
Сценарий тестирования
Наш простой сервис после получения запроса:
Обращается к БД за продуктом.
Получает значение скидки для продукта.
Рассчитывает финальную цену и возвращает данные.
Было использовано три модуля:
- mvc-service — реализация в классическом подходе (Spring MVC).
- reactive-service — реализация в реактивном подходе (Spring WebFlux).
- benchmark — сервис бенчмарка, который автоматически направляет запросы двум предыдущим и фиксирует результаты, т. е. собирает метрики обоих сервисов (число потоков, задержки, пропускную способность) и выводит их на веб-страницу с графиками. Скриншот этой страницы (рис. 3) сделан во время прогона в режиме высокой нагрузки, к моменту завершения 16-го раунда. Итоговые значения по каждому режиму я разберу ниже по отдельным графикам.

Запросы отправлялись параллельно раундами (сериями параллельных запросов с последующей паузой). Количество запросов в раунде варьировалось от 200 до 400 для тестирования режима с большим числом одновременных запросов и от 10 до 20 для тестирования режима с малым числом одновременных запросов. При малом числе запросов пауза между раундами была увеличена до 30 секунд.
Результаты при большом числе одновременных запросов (200–400+ параллельных запросов)
График «Потоки в ожидании I/O» — это ключевой показатель. Он наглядно показывает количество заблокированных потоков (рис. 4):

Для mvc-service в ожидании I/O заблокированными держатся 300 потоков (максимальное количество потоков, настроенное для Tomcat).
Для reactive-service в ожидании I/O оставался 1 поток.
График «Средняя задержка запроса» показывает среднее время выполнения запроса (рис. 5):

Реактивный сервис показал меньшую среднюю задержку: 321 мс против 7724 мс у MVC.
Причина: в MVC-сервисе во время выполнения запроса включается время ожидания освобождения потоков из пула.
Вывод: на большом количестве параллельных запросов реактивный подход требует меньше ресурсов и обеспечивает меньшее среднее время ответа.
Результаты при малом числе одновременных запросов (10–20 параллельных запросов)
При малом числе запросов картина меняется:
- График «Потоки в ожидании I/O»: в среднем количество занятых потоков при классическом подходе не превышает 20 и соответствует количеству запросов в раунде (рис. 6); реактивный подход также блокирует 1 поток.

График «Пиковое количество потоков JVM»: разница в количестве занимаемых потоков меньше, чем при большом числе запросов (рис. 7).

График «Средняя задержка запроса»: среднее время ответов практически одинаковое для обоих подходов (рис. 8).

Вывод: при небольшой нагрузке оба подхода имеют сопоставимую производительность. Использование реактивного подхода добавляет сложность в кодовую базу, не давая измеримого прироста ни в производительности, ни в потреблении ресурсов.
5. Чек-лист: WebFlux или Spring MVC?
На основе нашего бенчмарка и практического опыта можно сформулировать критерии, которые помогут принять решение.
Когда WebFlux оправдан:
1. Большое количество одновременных запросов. Один поток может обслуживать тысячи одновременных соединений, поэтому число потоков не растет вместе с нагрузкой.
2. Требуется поддержка backpressure. Автоматическое управление потоком данных при разной скорости производителя и потребителя.
3. Наличие сценариев стриминга и real-time. Модель подходит для работы с WebSocket и SSE (Server-Sent Events — передача событий от сервера к клиенту по одному HTTP-соединению).
4. Сервис выполняет роль gateway — принимает запросы и маршрутизирует их по другим сервисам — или агрегирует данные из множества источников.
Когда WebFlux не нужен:
1. Простые CRUD-приложения. Дополнительная сложность кода повысит порог входа и усложнит поддержку, при этом не давая измеримого прироста в производительности.
2. CPU-bound задачи (сложные вычисления). Процесс вычислений будет блокировать цикл событий, что не приведёт к каким-либо приростам производительности относительно классических подходов.
3. Отсутствие реактивных драйверов БД. Использование реактивного подхода при использовании стека технологий, не подразумевающего асинхронную работу, не дает преимуществ.
Отмечу, что помимо критериев, которые помогут вам ответить на вопрос «решит ли WebFlux вашу задачу», стоит учитывать и цену владения решением. Например:
вам придется освоить Reactive Streams, подписки, ленивое выполнение и backpressure;
стек вызовов в реактивных цепочках не отражает реальный порядок выполнения, что усложняет отладку;
по всему стеку понадобятся неблокирующие драйверы и библиотеки.
Эти минусы входят в смету миграции, и помимо нагрузки и драйверов, вам придется учитывать и время команды на переобучение.
Наблюдение из практики
Для проектов с умеренной нагрузкой виртуальные потоки могут стать компромиссом: они позволяют создавать сотни тысяч потоков при сохранении привычного синхронного кода. Однако при нагрузке уровня нашего бенчмарка (200–400+ одновременных запросов) реактивный сервис показал меньшую среднюю задержку, чем MVC.
Перед выбором модели стоит оценить ожидаемое количество одновременных запросов, доступные драйверы БД и наличие потоковых сценариев, а не руководствоваться только интересом к технологии.
Мой совет джунам: не бросайтесь переписывать все на WebFlux только потому, что это интересно. Сначала ответьте на вопросы: какая у вас нагрузка? какие драйверы БД? есть ли потоковые сценарии? А потом выбирайте модель.
Что дальше: «Под капотом и главные грабли»
В следующей статье я расскажу о внутреннем устройстве Spring WebFlux: event loop, backpressure, проблемы ThreadLocal и способы изоляции блокирующих операций. Покажу, как избежать ошибок, которые превращают реактивное приложение в блокирующий кошмар.
Подписывайтесь на блог компании «Синимекс», чтобы не пропустить продолжение! А если у вас есть опыт миграции на виртуальные потоки или WebFlux — поделитесь в комментариях, будет полезно обсудить.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.