← назад к разделу

WebFlux — reactive-альтернатива Spring MVC поверх Project Reactor. С приходом виртуальных потоков в Java 21, аргумент «нужно много RPS на одном узле — берём WebFlux» сильно ослаб: обычный MVC + виртуальные потоки часто решает ту же задачу проще. Поэтому статья начинается с честной части — когда WebFlux реально оправдан, и заканчивается практикой использования.

Когда WebFlux оправдан

СценарийWebFluxАльтернатива
Много параллельных I/O-вызовов в одном запросе (5+ HTTP-клиентов)да, естественный fitMVC + 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, blocking RestTemplate/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 — официальная документация.