Пара аннотаций в контроллере, и сервис на 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, возвращающий заглушку, обязан писать в лог идентификатор запроса из контекста, иначе источник сбоя не найти вообще.
Набор инструментов контроля конвейера от локальной разведки до метрик
Рабочий набор диагностики реактивного приложения собирается из шести уровней, и у каждого своё место:
- оператор log() с меткой сегмента печатает события onNext и onComplete по шагам и за минуту локализует проблемный участок;
- checkpoint() с именем точки вставляет метку в стек-трейс и работает в продакшене без глобального замедления;
- Hooks.onOperatorDebug() даёт полную картину сборки, но настолько её удорожает, что место режиму в тестах;
- BlockHound в роли JVM-агента ловит блокирующие вызовы на event-loop нитях в момент исполнения и кидает исключение;
- дебаг-агент из артефакта reactor-tools восстанавливает строки сборки в стеке, почти не тратя скорость;
- оператор 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 выжимает из четырёх нитей то, что раньше требовало сервера втрое дороже. В руках команды, копирующей конвейеры из чужих примеров без понимания механики, тот же инструмент превращает каждый релиз в лотерею. Разница не в библиотеке, а в готовности платить за масштаб понятной ценой дисциплины.