WebFlux — reactive-альтернатива Spring MVC поверх Project Reactor. С приходом виртуальных потоков в Java 21, аргумент «нужно много RPS на одном узле — берём WebFlux» сильно ослаб: обычный MVC + виртуальные потоки часто решает ту же задачу проще. Поэтому статья начинается с честной части — когда WebFlux реально оправдан, и заканчивается практикой использования.
Когда WebFlux оправдан
| Сценарий | WebFlux | Альтернатива |
|---|---|---|
| Много параллельных I/O-вызовов в одном запросе (5+ HTTP-клиентов) | да, естественный fit | MVC + CompletableFuture |
| Streaming-ответ (SSE, server push, чанковая отдача) | да | MVC поддерживает SSE, но менее идиоматично |
| Long polling, WebSocket с тысячами соединений | да | MVC + Tomcat NIO работает, но менее эффективно |
| Backpressure от downstream-сервиса | да, родная поддержка | вручную через семафоры |
| Команда уже пишет в Reactor-стиле в других сервисах | да | — |
| Просто «много RPS на CRUD» | нет | MVC + Java 21 virtual threads |
| Spring Data репозитории, JPA, blocking драйвера | нет (заблокирует event loop) | MVC |
| Команда не знает реактивность | нет | MVC |
Главное практическое правило: WebFlux требует, чтобы весь стек был non-blocking. Если в одном месте JdbcTemplate.queryForObject(...) — event loop встаёт, преимущество исчезает, а сложность остаётся.
Mono и Flux
Два основных типа Project Reactor:
Mono<T>— 0 или 1 элемент (асинхронный аналогOptional<T>+Future<T>).Flux<T>— 0..N элементов (асинхронный поток).
Оба холодные по умолчанию: вычисление начинается только когда кто-то подпишется (subscribe() или другой terminal-оператор). WebFlux подписывается сам, когда возвращаете Mono/Flux из контроллера.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderRepository repo;
private final PricingClient pricing;
@GetMapping("/{id}")
public Mono<OrderResponse> get(@PathVariable UUID id) {
return repo.findById(id)
.flatMap(order -> pricing.calculate(order) // I/O запрос к pricing-сервису
.map(price -> OrderResponse.from(order, price)))
.switchIfEmpty(Mono.error(new OrderNotFoundException(id)));
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderEvent> stream() {
return repo.streamEvents(); // SSE, бесконечный поток событий
}
}
Операторы — три категории
- Transformation:
map,flatMap,concatMap,zip. - Filtering / selection:
filter,take,skip,distinct. - Error handling:
onErrorReturn,onErrorResume,retry,timeout. - Schedulers:
subscribeOn,publishOn— на каком пуле выполнять.
flatMap vs concatMap — частая путаница: flatMap параллелизит, concatMap сохраняет порядок. Если порядок важен — concatMap.
R2DBC — реактивный аналог JDBC
JDBC — блокирующий, его использовать в WebFlux нельзя без подкручивания (через Schedulers.boundedElastic(), но это уже не то). Для PostgreSQL/MySQL/MSSQL есть R2DBC — non-blocking драйвер.
public interface OrderRepository extends R2dbcRepository<Order, UUID> {
@Query("SELECT * FROM orders WHERE customer_id = :customerId")
Flux<Order> findByCustomer(UUID customerId);
Mono<Order> findByOrderNumber(String orderNumber);
}
Минусы R2DBC:
- Нет полноценного JPA-эквивалента —
R2dbcEntityTemplateближе к JdbcTemplate, не к Hibernate. - Транзакции через
TransactionalOperator, не@Transactional(хотя последний тоже работает, но с reactor-context propagation). - Меньше features, чем у JPA: lazy loading, dirty checking, cascading — ничего этого нет.
В UCP-стеке (hexagonal-проекты): R2DBC на write-side не используется (там агрегаты, инварианты, dirty tracking важны), на read-side — иногда, когда нужен реактивный пайплайн чтения для streaming.
WebClient — реактивный HTTP-клиент
Замена RestTemplate (deprecated). Работает и в reactive, и в blocking-стеке.
@Configuration
public class PricingClientConfig {
@Bean
public WebClient pricingClient(WebClient.Builder builder) {
return builder
.baseUrl("https://pricing.internal")
.defaultHeader("X-Service", "orders")
.build();
}
}
@Component
@RequiredArgsConstructor
public class PricingClient {
private final WebClient client;
public Mono<Price> calculate(Order order) {
return client.post().uri("/quote")
.bodyValue(order)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, resp -> Mono.error(new BadRequest()))
.onStatus(HttpStatusCode::is5xxServerError, resp -> Mono.error(new PricingDownException()))
.bodyToMono(Price.class)
.timeout(Duration.ofSeconds(2))
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)));
}
}
Преимущества WebClient над RestTemplate:
- Non-blocking I/O (важно для WebFlux).
- Декларативные таймауты, retry, error mapping.
- Streaming response (SSE).
WebClient можно использовать и в обычном MVC-сервисе — просто блокировать результат через .block() или .toFuture().get(). Это нормально.
Главные ловушки
1. .block() в reactive-чейне
@GetMapping("/bad")
public Mono<String> bad() {
String result = someService.fetch().block(); // блокирует event-loop thread!
return Mono.just(result.toUpperCase());
}
Event loop в WebFlux небольшой (по числу CPU), один .block() останавливает всю обработку запросов. Никогда не вызывать в reactive-коде, кроме внутри Schedulers.boundedElastic().
2. ThreadLocal не работает
Reactor переключает потоки между операторами — ThreadLocal (MDC, SecurityContext) теряется. Решения:
- Reactor Context —
.contextWrite(ctx -> ctx.put("key", value))+Mono.deferContextual. - Micrometer Context Propagation (с 1.10+) — автоматически переносит MDC/SecurityContext.
@Bean
ContextRegistry contextRegistry() {
return ContextRegistry.getInstance()
.registerThreadLocalAccessor("traceId",
() -> MDC.get("traceId"),
v -> MDC.put("traceId", v),
() -> MDC.remove("traceId"));
}
3. Дебаг сложнее
Stack traces реактивных операций не показывают, где в коде была ошибка. Включить debug mode через Hooks.onOperatorDebug() (медленно, только в dev) или checkpoints:
return someService.fetch()
.checkpoint("after-fetch")
.map(...)
.checkpoint("after-map")
.flatMap(...);
4. Smart bytecode-instrumentation для tracing
Tracing через Micrometer + Brave/OpenTelemetry в WebFlux требует context propagation. С Spring Boot 3.2+ работает почти из коробки, но нужно проверить, что bagage переносится между операторами.
WebFlux vs Virtual Threads
Java 21 добавила виртуальные потоки. В Spring Boot 3.2+ их можно включить через:
spring.threads.virtual.enabled=true
После этого Tomcat будет обрабатывать каждый запрос в виртуальном потоке. Это даёт:
- Привычный MVC-код с
JdbcTemplate,@Transactional, blockingRestTemplate/WebClient.block(). - I/O-вызовы под капотом не блокируют физический поток — JVM использует park.
- Тысячи параллельных запросов на одной JVM без switch на реактивность.
Когда WebFlux всё ещё нужен после virtual threads:
- Streaming-эндпоинты (SSE, server push) — Reactor нативен для этого.
- Сложные I/O-композиции (5-10 параллельных вызовов с retry/timeout/zip) — реактивные операторы выразительнее.
- Backpressure от медленного consumer'а.
- Команда уже знает Reactor и хочет единый стиль.
Когда после virtual threads WebFlux не нужен:
- Высокий RPS на CRUD-эндпоинтах. Виртуальные потоки решают тот же кейс проще.
- Команда новая в реактивности.
Что почитать дальше
- Spring MVC — синхронная альтернатива, в большинстве случаев достаточная.
@Transactionalглубоко — в WebFlux транзакции черезTransactionalOperator.- Scheduled, Async, виртуальные потоки — детальнее про virtual threads.
- Project Reactor reference — официальная документация.