Когда читаешь про Spring, быстро натыкаешься на развилку: MVC или WebFlux? И статей много, и советы противоречат друг другу. Разберём с нуля: что такое каждый вариант, в чём реальная разница и как понять, что подходит вашему случаю.
Почему вообще возникла эта развилка
Представьте кофейню. На каждый заказ выходит один официант и стоит рядом, пока кофе не готов. Пришло 200 человек одновременно — нужно 200 официантов. Официанты стоят и ждут, хотя баристе нужна секунда.
Классический Spring MVC работает так же: на каждый HTTP-запрос уходит один поток. Поток обрабатывает запрос от начала до конца — включая ожидание базы данных или внешнего сервиса. Пока ждёт — занимает память. Стандартный пул — несколько сотен потоков. При большой нагрузке потоки заканчиваются раньше, чем успевают завершить работу.
Именно эту проблему и решали разными способами.
Три варианта сегодня
MVC на обычных потоках (классика)
Пул из нескольких сотен потоков. Каждый запрос занимает поток целиком — от приёма до ответа. Простой, предсказуемый, понятный любому разработчику. Если нагрузка невысокая — работает отлично.
MVC с виртуальными потоками (Java 21+)
Java 21 добавила виртуальные потоки (Project Loom). Это как если бы каждый официант был бумажным — занимает килобайты памяти вместо мегабайт, и их можно завести миллион. Пока виртуальный поток ждёт ответа от базы, он «отпускает» реальное ядро процессора — оно идёт обрабатывать другой запрос.
Самое важное: код выглядит точно так же, как обычный MVC. Никаких Mono, никаких Flux, никаких реактивных операторов. Просто добавляете одну строчку в конфигурацию:
spring:
threads:
virtual:
enabled: true
И всё — Spring Boot 3.2+ сам переключается на виртуальные потоки. Стектрейсы читаются как обычно, отладка как обычно, ThreadLocal работает как обычно.
WebFlux (реактивный стек)
Совсем другой подход. Вместо «один запрос — один поток» работает событийный цикл: один поток обслуживает множество соединений, переключаясь между ними в момент, когда нечего делать.
Код выглядит иначе:
// Обычный MVC
@GetMapping("/order/{id}")
Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id); // блокирует поток
}
// WebFlux
@GetMapping("/order/{id}")
Mono<Order> getOrder(@PathVariable Long id) {
return orderRepository.findById(id); // возвращает «обещание» результата
}
Mono — это «обещание одного результата», Flux — «обещание потока результатов». Вся обработка описывается цепочками операторов. Это мощно, но требует отдельного обучения.
Что изменили виртуальные потоки
До Java 21 главный аргумент за WebFlux звучал так: «MVC не масштабируется, потоки дорогие, под нагрузкой кончаются».
Виртуальные потоки закрыли именно этот аргумент. Теперь MVC с виртуальными потоками спокойно держит десятки тысяч одновременных запросов с блокирующим I/O — то, для чего раньше нужен был WebFlux.
Это не значит, что WebFlux стал бесполезным. Но область, где он оправдан, сузилась.
Когда MVC с виртуальными потоками достаточно
Это хороший выбор для подавляющего большинства сервисов:
- Обычные REST API, которые ходят в базу данных и возвращают ответ.
- Сервисы с JDBC, jOOQ или другими блокирующими драйверами — они работают как есть.
- Команды, которые не имеют опыта с реактивным программированием.
- Существующие MVC-приложения, которые нужно сделать более масштабируемыми — достаточно одного флага.
Важно понимать: виртуальные потоки не делают ваш код быстрее. Они делают ожидание дешевле. Запрос в базу данных занимает столько же времени — просто теперь пока он ждёт, поток не занимает память системы.
Когда стоит смотреть в сторону WebFlux
WebFlux сохраняет преимущество в нескольких конкретных сценариях.
Сотни тысяч одновременных соединений. API-шлюзы, прокси, edge-сервисы, где счёт соединений идёт на сотни тысяч — WebFlux эффективнее расходует ресурсы на таких масштабах.
Потоки данных с управлением скоростью. WebFlux поддерживает backpressure: если клиент читает медленно, продюсер притормаживает. Это важно для стриминга больших объёмов данных, где нельзя допустить переполнения буферов. MVC + виртуальные потоки такого не умеют.
Команда уже умеет реактивное программирование. Если команда работала с Reactor или RxJava в продакшене и хочет продолжать — это нормальный выбор.
Весь стек неблокирующий. WebFlux раскрывается только если неблокирующий драйвер есть везде: R2DBC вместо JDBC, реактивные клиенты к внешним сервисам. Один блокирующий вызов в цепочке — и событийный цикл встаёт, преимущество теряется.
Главные ошибки при выборе
WebFlux «ради производительности». Сервис на 200 запросов в секунду с обычной базой данных переписывают на реактивный стек «чтобы стало быстрее». Скорость не меняется — она упирается в базу, а не в потоки. Зато сложность утраивается.
block() внутри реактивной цепочки. Это самая частая ошибка. Блокирующий вызов на событийном цикле останавливает обработку для всех соединений, а не только для одного. Если без блокирующих вызовов не обойтись — это сигнал, что WebFlux не подходит.
Смешение двух моделей в одном сервисе. Часть контроллеров реактивные, часть — обычные. Команда поддерживает обе модели и обе плохо. Один сервис — одна модель.
Виртуальные потоки как магия. Включили флаг и ждут, что CPU-нагрузка упадёт. Виртуальные потоки помогают только там, где потоки ждут I/O. Если код считает — потоки как занимали ядра, так и занимают.
Pinning в старых библиотеках. Некоторые старые библиотеки используют synchronized вокруг I/O-операций. С виртуальными потоками это называется pinning: виртуальный поток не может «отпустить» реальный поток-носитель и держит его заблокированным. Перед включением виртуальных потоков в продакшене полезно прогнать нагрузочный тест с флагом -Djdk.tracePinnedThreads.
Коротко
- Классический Spring MVC: один запрос — один поток, простой и понятный, но потоки дорогие.
- MVC с виртуальными потоками (Java 21): тот же код, но потоки весят килобайты и их можно миллион — один флаг в конфигурации.
- WebFlux: событийный цикл,
Mono/Flux, другая модель программирования — мощно, но требует обучения. - Виртуальные потоки закрыли главный аргумент за WebFlux: «MVC не держит нагрузку».
- Для большинства сервисов — MVC с виртуальными потоками, это дефолт.
- WebFlux оправдан при сотнях тысяч соединений, потоках данных с backpressure и неблокирующем стеке насквозь.
- Блокирующий вызов внутри WebFlux-цепочки — деградация всего сервиса, не одного запроса.
Что почитать дальше
- Spring WebFlux: когда брать, Mono/Flux, R2DBC — устройство реактивного стека и его ловушки.
- Scheduled, Async, виртуальные потоки — что виртуальные потоки меняют за пределами веб-слоя.
- Spring MVC — стек, который выбирается по умолчанию.
- Монолит или микросервисы — развилка уровнем выше.