Пара аннотаций в контроллере, и сервис на Spring WebFlux держит десятки тысяч открытых соединений, не заводя ни одного лишнего потока. Именно так обещание Project Reactor выглядит на слайдах, и оно не враньё. Библиотека честно вытягивает нагрузку, недоступную стеку поток-на-запрос. Обратная сторона проявляется ночью: ошибка в конвейере оборачивается стек-трейсом из тридцати внутренних классов без единой строки бизнес-кода, и дежурный инженер вслепую ищет, какой из пяти flatMap в цепочке бросил исключение. Ниже разбор того, где реактивность приносит измеримый выигрыш, чем она берёт плату и какой набор инструментов превращает поиск ошибок в обычную процедуру.

История проекта короткая. Третья версия ядра вышла в 2016 году на фундаменте спецификации Reactive Streams и через год легла в основу WebFlux в Spring Framework 5. Ветка 3.5 добралась до интеграции с Micrometer Observation в конце 2022 года, ветка 3.6 в конце 2023 подняла планку требований до Java 17. К осени 2026 года актуальна линия 3.8 с выпуском 3.8.7 в августе, параллельно в режиме поддержки живёт линия 3.7 с выпуском 3.7.19.

Реальный выигрыш реактивной модели на тысячах одновременных соединений

Поток-на-запрос упирается в арифметику операционной системы. Каждому потоку ядро выделяет стек, для 64-битных сборок HotSpot по умолчанию это около мегабайта. Десять тысяч соединений, ждущих ответа внешнего сервиса, означают десять гигабайт только под стеки, плюс лавину переключений контекста, плюс лимиты на число нитей в ядре. Машина ради часового пика раз в неделю выходит непропорционально дорогой.

Реактивная схема переворачивает модель. Сетевые события разбирают несколько event-loop нитей Netty, по одной на ядро процессора. Состояние соединения живёт не в стеке, а в куче: подписка на Flux это цепочка объектов на несколько килобайт. Четыре нити обслуживают десятки тысяч соединений, память растёт линейно и предсказуемо, планировщик не мечется между тысячами спящих потоков.

Второй козырь, отсутствующий у асинхронного кода на голых колбэках, это backpressure. Подписчик сообщает вверх по цепочке, сколько элементов готов принять, и источник не отправляет следующую порцию без запроса. Быстрый генератор физически не способен затопить медленного потребителя. В блокирующем мире для этого вручную строят ограниченные очереди и семафоры, здесь требование спроса встроено в сам протокол.

Выигрыш концентрируется в конкретных сценариях: шлюз, агрегирующий ответы десятков смежных сервисов; потоки событий и серверный push; чаты с десятками тысяч длинных соединений; пересылка тяжёлых тел через шлюз. Стек с пулом на двести потоков в этих задачах упирается в очередь и таймауты, реактивный просто ждёт всех ответов, не занимая потоков ожиданием.

Модель Flux и Mono на примере конвейера обогащения заказа

Конвейер читается сверху вниз, а исполняется в момент подписки:

webClient.get()
    .uri("/orders/{id}", id)
    .retrieve()
    .bodyToMono(Order.class)
    .timeout(Duration.ofMillis(500))
    .retryWhen(Retry.backoff(3, Duration.ofMillis(100)))
    .flatMap(this::enrich)
    .subscribe(this::render);

Сборка и исполнение разведены во времени. Пока никто не вызвал subscribe, по сети не отправлено ни байта: Mono холодный, описание лишь строит план. Отсюда растут типовые ошибки новичков. Вызов subscribe дважды запускает HTTP-запрос дважды. Попытка присвоить результат переменной до подписки даёт пустоту вместо значения. Забытый subscribe не отправляет ничего вообще, и сервис молча не работает.

flatMap и concatMap решают одну задачу по-разному. Первый запускает внутренние конвейеры параллельно, по умолчанию до 64 одновременно, второй выдерживает строгий порядок. Список из тысячи идентификаторов заказов укладывается в 32 одновременных запроса одной строкой:

Flux.fromIterable(ids)
    .flatMap(client::fetch, 32)
    .collectList();

Размер порции спроса тоже под контролем. Оператор limitRate задаёт, за сколько элементов подписчик рассчитывается с источником за один раз, и медленный потребитель с порцией 256 элементов не даёт быстрому источнику разогнать очередь до миллионов записей:

Flux.fromIterable(ids)
    .limitRate(256)
    .concatMap(client::fetch);

Планировщики publishOn и subscribeOn и где конвейер меняет нити

По умолчанию конвейер исполняется на той нити, с которой пришла подписка. Два оператора меняют это правило, и их путают чаще всего. subscribeOn двигает саму точку подписки: где бы оператор ни стоял в цепочке, источник заработает на указанном планировщике, ровно один раз. publishOn переключает исполнение всех нижележащих операторов с того места, где он появился, и таких переключений в конвейере может быть несколько.

Классическая ловушка выглядит так:

webClient.get()
    .uri("/orders/{id}", id)
    .retrieve()
    .bodyToMono(Order.class)
    .publishOn(Schedulers.boundedElastic())
    .map(this::heavyEnrichment);

Тяжёлое обогащение выполнится на boundedElastic, но сетевой вызов останется на нити event-loop. Автор же часто уверен, что переключил сам источник. Обратный оператор, subscribeOn(Schedulers.boundedElastic()), двигает подписку и полезен при обёртывании блокирующего клиента, но последующие операторы он не переносит.

Набор планировщиков небольшой. boundedElastic держит пул с потолком по нитям и очередью, единственный законный дом для блокирующих вызовов. parallel даёт по нити на ядро и рассчитан на математику без ввода-вывода. single обслуживает служебные фоновые задачи. immediate означает текущую нить. Железное правило одно: тяжёлый расчёт, оставшийся на event-loop, замораживает соединения этой нити.

Цена неблокирующего стека в стек-трейсах без номеров строк

Днём всё работает, ночью падает, и в логе вот это:

reactor.core.publisher.FluxMap$MapSubscriber
  .onError(FluxMap.java:126)
reactor.core.publisher.FluxFlatMap$FlatMapMain
  .checkError(FluxFlatMap.java:430)
reactor.core.publisher.FluxRetryWhen$RetryWhenOtherSubscriber
  .onNext(FluxRetryWhen.java:161)

Строки есть, только это строки библиотеки, а не бизнес-кода. Причина в устройстве: конвейер собирается один раз при старте, исполняется позже и на других нитях, а каждая лямбда после компиляции без отладочной информации остаётся безымянной. Стек-трейс честно описывает путь значения через операторы и не говорит, где этот путь проложил разработчик.

Глобальный режим отладки включается одной строкой:

Hooks.onOperatorDebug();

После этого стек обогащается строками сборки, но каждая сборка конвейера резко дорожает, вплоть до заметного замедления старта приложения. Режим годится для тестов и разовой диагностики, в продакшене его держат редко. Точечная альтернатива:

.flatMap(this::enrich)
.checkpoint("обогащение заказа после ответа клиента")

Метка checkpoint появляется прямо в стек-трейсе, а её стоимость ограничена одним оператором.

Отдельная глава боли это обработка ошибок. Операторы onErrorResume и onErrorMap дают реактивные аналоги привычных try и catch, но привычка глотать исключения в лямбдах без журналирования стирает контекст целиком: у ошибки в конвейере нет истории вызовов. Рабочее правило одно: каждый onErrorResume, возвращающий заглушку, обязан писать в лог идентификатор запроса из контекста, иначе источник сбоя не найти вообще.

Набор инструментов контроля конвейера от локальной разведки до метрик

Рабочий набор диагностики реактивного приложения собирается из шести уровней, и у каждого своё место:

  1. оператор log() с меткой сегмента печатает события onNext и onComplete по шагам и за минуту локализует проблемный участок;
  2. checkpoint() с именем точки вставляет метку в стек-трейс и работает в продакшене без глобального замедления;
  3. Hooks.onOperatorDebug() даёт полную картину сборки, но настолько её удорожает, что место режиму в тестах;
  4. BlockHound в роли JVM-агента ловит блокирующие вызовы на event-loop нитях в момент исполнения и кидает исключение;
  5. дебаг-агент из артефакта reactor-tools восстанавливает строки сборки в стеке, почти не тратя скорость;
  6. оператор metrics() и Observation API выводят счётчики событий конвейера в Micrometer и на графики мониторинга.

BlockHound заслуживает отдельного абзаца. Реактивная библиотека бессильна, если кто-то из коллег спрятал внутри лямбды вызов JDBC или Thread.sleep: нить event-loop замирает, и замерзают все соединения, назначенные этой нити. Агент встраивается в JVM, перехватывает блокирующие операции на неразрешённых нитях и превращает тихую деградацию в громкую ошибку с точным местом. Для легальных блокирующих участков есть белый список через allowBlockingCallsInside.

Дебаг-агент из артефакта reactor-tools решает ту же задачу иначе: преобразования вносятся в классы при их загрузке, стек-трейсы получают строки сборки без удорожания во время работы. Подключение занимает пару строк конфигурации сборки, дальше инструмент просто живёт в приложении и не требует внимания.

Тесты конвейеров пишутся через StepVerifier, который проверяет не только итог, но и всю последовательность событий с контролем времени:

StepVerifier.create(service.enrich(42L))
    .expectNextMatches(order -> order.total() > 0)
    .expectComplete()
    .verify(Duration.ofSeconds(1));

Контекст и перенос идентификатора трассировки сквозь конвейер

Простая задача, записать идентификатор трассировки в каждый исходящий заголовок, в реактивном мире перестаёт быть простой. ThreadLocal рассчитан на нитку, которая живёт от начала до конца запроса, а конвейер прыгает между нитями свободно. На место ThreadLocal приходит контекст Reactor, неизменяемая карта, которая путешествует вместе с подпиской:

Mono.deferContextual(ctx -> {
    String traceId = ctx.getOrDefault("traceId", "none");
    return webClient.get()
        .header("x-trace-id", traceId)
        .uri("/orders/{id}", id)
        .retrieve()
        .bodyToMono(Order.class);
})
.contextWrite(ctx -> ctx.put("traceId", currentTraceId()));

Контекст пишется через contextWrite снизу и читается выше по конвейеру, направление противоположно потоку значений. Для логов через MDC работает мост из библиотеки context-propagation, переносящий значения между ThreadLocal и реактивным контекстом. С ветки 3.6 мост включён по умолчанию для инструментов, поддерживающих соответствующий API. Честная оговорка: чужая библиотека с внутренним ThreadLocal без поддержки моста остаётся глухой, и трассировка внутри неё обрывается.

Границы выгоды и где реактивность только мешает работе

JDBC блокирующий по своей природе. Обёртка вида subscribeOn(Schedulers.boundedElastic()) не делает приложение реактивным: потоки по-прежнему паркуются в ожидании базы, просто в другом месте. Реакция WebFlux на попытку блокировать нить event-loop прямая:

java.lang.IllegalStateException: block()/blockFirst()/blockLast()
are blocking, which is not supported in thread reactor-http-nio-2

Типичное CRUD-приложение на JPA с сотнями запросов в секунду на инстанс поток-на-запрос стек тянет без усилий. Реактивность в такой задаче не добавляет скорости, зато добавляет порог входа для всей команды, отдельную дисциплину тестирования и чужую модель отладки. Выигрыш собирается там, где соединений много, они долгие и в основном ждут сеть: шлюзы, агрегаторы, стриминг, серверный push. Для остального Java 21 с виртуальными потоками закрыла вопрос дешёвого блокирующего параллелизма, и городить реактор ради CRUD стало незачем.

Реактивная библиотека это точный инструмент, а не универсальный ключ. В руках команды, разобравшейся в модели и собравшей набор диагностики, Project Reactor выжимает из четырёх нитей то, что раньше требовало сервера втрое дороже. В руках команды, копирующей конвейеры из чужих примеров без понимания механики, тот же инструмент превращает каждый релиз в лотерею. Разница не в библиотеке, а в готовности платить за масштаб понятной ценой дисциплины.