Reactive на JVM: Project Reactor, WebFlux и когда он больше не нужен

Reactive-стек на JVM после Loom: как работает Project Reactor и WebFlux, что даёт backpressure, и честный ответ на вопрос, когда virtual threads делают reactive ненужным, а когда нет

Ещё недавно reactive был единственным способом обслуживать десятки тысяч соединений на JVM без армии потоков. Loom изменил расклад: блокирующий код снова масштабируется, а на бенчмарке из предыдущей статьи virtual threads обходят Reactor по throughput примерно втрое. Значит ли это, что reactive умер? Нет — но список причин его выбирать заметно сократился, и важно понимать, какие именно причины остались.

Это не пересказ бенчмарка — он уже разобран в статье «Конкурентность на JVM: virtual threads, корутины и reactive» этой же серии, с методикой, полной таблицей и разбором того, откуда берётся разрыв в числах. Здесь — другой угол: как принимать решение «reactive или virtual threads» для конкретного сервиса, не оглядываясь на моду ни в одну, ни в другую сторону.

Reactive против virtual threads: когда что выбирать

В статье

Как устроен reactive: Mono/Flux и backpressure

Project Reactor строит конкурентность не через потоки (виртуальные или платформенные), а через ленивые декларативные цепочки операторов над Mono (0 или 1 элемент) и Flux (0..N элементов). Ничего не выполняется до подписки — Flux.range(...).map(...).filter(...) только описывает, что должно произойти, когда появится подписчик:

Flux.range(0, n)
        .flatMap(i -> {
            long submitTime = System.nanoTime();
            return Mono.delay(Duration.ofMillis(sleepMs))
                    .doOnNext(tick -> latenciesNanos[i] = System.nanoTime() - submitTime);
        }, n)
        .blockLast();

Mono.delay() здесь работает не на потоке на задачу, а через Schedulers.parallel() — таймер-колёса на небольшом пуле планировщиков; flatMap с concurrency=N держит все N подписок «в полёте» одновременно.

Ключевая причина, по которой reactive вообще появился как отдельная модель, а не просто «ещё один способ писать асинхронный код», — backpressure на уровне протокола Reactive Streams. Подписчик сигнализирует издателю через request(n), сколько элементов он готов принять прямо сейчас, а оператор физически не эмитит больше — не буферизует безгранично, не роняет данные молча, а тормозит производителя на уровне контракта между Publisher и Subscriber. Это не то же самое, что ограничение размера пула потоков или очереди: там производитель либо блокируется на переполненной очереди, либо роняет задачи, либо копит их в памяти — сигнал идёт снизу вверх постфактум. В Reactive Streams потребитель заранее объявляет свою пропускную способность, и это часть спецификации, а не соглашение между командами.

WebFlux строит на этом полностью неблокирующий HTTP-стек: от Netty-обработчика запроса до ответа клиенту — ни одного блокирующего вызова, ни одного потока, простаивающего в ожидании I/O. При достаточно медленном или недобросовестном клиенте backpressure не даёт серверу захлебнуться собственными буферами.

Цена reactive: сложность, которую покупаете вместе с backpressure

За декларативность и backpressure reactive платит той же монетой, что и любой async-стек с «раскрашенными» функциями — методом, который возвращает Mono/Flux, нельзя вызвать как обычный, весь путь вызова выше по стеку тоже становится реактивным. Смешать блокирующий JDBC-вызов внутри flatMap на неправильном шедулере — частая ошибка, которая тихо сводит преимущества модели на нет: реактивный код формально остаётся реактивным, но блокирует один из немногих потоков Schedulers.parallel(), а не один из тысяч виртуальных.

Вторая цена — отладка. Стектрейс исключения, брошенного внутри оператора flatMap, чаще всего указывает на внутренности Reactor — на вызов subscribe() где-то в недрах Netty, а не на место в бизнес-логике, где реально случилась ошибка. Hooks.onOperatorDebug() частично лечит проблему, добавляя точки сборки стектрейса на этапе построения цепочки, но платит за это заметным overhead и обычно годится только для локальной отладки, не для прода.

Третья цена — порог входа и мышление. Reactive-код пишется и читается иначе, чем последовательный: вместо for и try/catch — операторы map/flatMap/retry/onErrorResume, вместо стектрейса вызовов — граф подписок. Опытная команда, годами писавшая блокирующий код, тратит реальное время на то, чтобы начать думать в терминах Publisher/Subscriber, а не просто выучить синтаксис Mono.

После Loom: что закрывают virtual threads

Virtual threads (JEP 444, JDK 21+) убирают главную историческую причину выбирать reactive — необходимость не блокировать поток ОС ради масштаба. Обычный блокирующий JDBC-драйвер, HttpClient.send(), Thread.sleep() — всё это теперь масштабируется на десятки тысяч конкурентных задач без единой строчки реактивного кода, потому что блокировка виртуального потока не блокирует carrier-поток: JDK автоматически размонтирует его на большинстве блокирующих операций.

На бенчмарке из статьи #5 (методика: N=10 000 I/O-bound задач, sleep/delay=100 мс, тот же сценарий «всё сразу», без искусственного ограничения concurrency) это не абстракция, а измеренная разница:

Модель Throughput (задач/сек) Peak RSS
Virtual threads ~49 500 ~88–91 МБ
Reactor (flatMap, concurrency=N) ~16 500 ~98–101 МБ

VT впереди примерно в 3 раза по throughput при формально одинаковом сценарии — и это не повод считать Reactor «медленным ради медленности»: разрыв объясняется накладными расходами flatMap/Schedulers.parallel() на масштабе 10 000 одновременных таймеров, а не тем, что реактивная модель неэффективна в принципе. Reactor платит эту цену за возможность, которой у VT попросту нет, — backpressure на уровне протокола (подробный разбор методики, полная таблица по пяти моделям и объяснение разрыва — в статье «Конкурентность на JVM»).

Здесь же стоит закрыть частый источник путаницы: Kotlin-корутины из той же таблицы (~19 600 задач/сек) — это НЕ «почти VT». По throughput корутины оказались в том же среднем ярусе, что и Reactor, а не на втором месте следом за VT — разрыв с VT объясняется измеренной стоимостью fan-out: сам цикл регистрации 10 000 дочерних корутин одного coroutineScope занял 56–75% wall-clock времени прогона. Интуитивное ожидание «лёгкая единица конкурентности ≈ virtual thread по скорости» подтвердилось только по памяти, не по throughput — и то же верно для Reactor: обе модели проигрывают VT в этом сценарии не потому, что VT «правильнее», а потому что platform-примитив JVM без обвязки почти всегда обгонит любую пользовательскую надстройку над ним по чистому throughput.

Практическое следствие: для нового сервиса, где backpressure не критичен, а код проще писать в блокирующем стиле, — VT выигрывает почти всегда, и по throughput, и по читаемости, и по отладке (обычный стектрейс вместо графа подписок).

Где backpressure всё ещё нужен

Список того, что reactive даёт, а VT — нет напрямую, короче, чем был пять лет назад, но не пуст:

  • Backpressure на уровне протокола. Это единственное место в сравнении, где производитель реально тормозится потребителем через контракт request(n), а не через внешний механизм. У VT нет прямого эквивалента — только семафоры, ограничение размера пула или Channel с ограниченной ёмкостью (у Kotlin Flow). Если downstream-потребитель регулярно медленнее производителя и это нужно обрабатывать не как исключение, а как норму — HTTP-стриминг ответа медленному клиенту, экспорт большого датасета, консьюмер с непредсказуемой скоростью обработки, — альтернатив Reactor/WebFlux на JVM практически нет.
  • Композиция операторов над потоком событий. Цепочка retry → timeout → onErrorResume → buffer → window над потоком событий читается как единый декларативный конвейер. Переписать то же самое императивно поверх блокирующего кода на virtual threads возможно, но получившийся код обычно длиннее и менее очевиден — вы вручную реализуете то, что в Reactor уже есть готовым оператором.
  • Стриминг с явным временны́м измерением. Операторы вроде Flux.interval, window(Duration), sample, debounce завязаны на временную семантику потока, а не на дискретные запрос-ответ операции — для этого класса задач reactive-модель ближе к предметной области, чем последовательность блокирующих вызовов.
  • Существующий реактивный стек, который работает. Если сервис уже написан на WebFlux, r2dbc и реактивных клиентах, а бизнес-метрики в порядке — миграция на VT ради самого факта миграции не решает никакой реальной проблемы и добавляет только риск.

Ни один из этих пунктов не про «reactive быстрее» — на измеренном бенчмарке верно обратное. Они про возможности, которых блокирующая модель не даёт в принципе или даёт заметно менее прямым способом.

Как выбирать: дерево решений

flowchart TD A{"Новый код\nили миграция?"} -->|"Существующий WebFlux,\nработает штатно"| KEEP["Оставить как есть.\nМиграция ради моды — не причина"] A -->|"Новый сервис\nили явный рефакторинг"| B{"Нужен backpressure\nна уровне протокола?"} B -->|"Да: медленный потребитель,\nстриминг, защита от перегрузки"| REACTOR["Reactor / WebFlux\n(или Flow в Kotlin)"] B -->|"Нет"| C{"Много композиции\nоператоров над событиями\nво времени?"} C -->|"Да"| REACTOR C -->|"Нет: линейный\nблокирующий код"| D{"Блокирующие библиотеки\n(JDBC, старый HTTP-клиент)?"} D -->|"Да"| VT["Virtual threads.\nБлокирующий код без изменений"] D -->|"Нет, но throughput\nи читаемость важнее"| VT style KEEP fill:#f9f3e3,stroke:#8b7355 style REACTOR fill:#c67a4a,stroke:#3a3631 style VT fill:#6d8a99,stroke:#3a3631

flowchart TD
  A{"Новый код\nили миграция?"} -->|"Существующий WebFlux,\nработает штатно"| KEEP["Оставить как есть.\nМиграция ради моды — не причина"]
  A -->|"Новый сервис\nили явный рефакторинг"| B{"Нужен backpressure\nна уровне протокола?"}

  B -->|"Да: медленный потребитель,\nстриминг, защита от перегрузки"| REACTOR["Reactor / WebFlux\n(или Flow в Kotlin)"]
  B -->|"Нет"| C{"Много композиции\nоператоров над событиями\nво времени?"}

  C -->|"Да"| REACTOR
  C -->|"Нет: линейный\nблокирующий код"| D{"Блокирующие библиотеки\n(JDBC, старый HTTP-клиент)?"}

  D -->|"Да"| VT["Virtual threads.\nБлокирующий код без изменений"]
  D -->|"Нет, но throughput\nи читаемость важнее"| VT

  style KEEP fill:#f9f3e3,stroke:#8b7355
  style REACTOR fill:#c67a4a,stroke:#3a3631
  style VT fill:#6d8a99,stroke:#3a3631
Reactive или virtual threads: практическое дерево решений

Практические ориентиры к дереву:

  • Не переписывать работающий reactive «потому что Loom». Миграция WebFlux → VT — это переписывание реактивных драйверов на блокирующие, пересмотр обработки ошибок, замена операторов на try/catch — реальная стоимость ради выигрыша, который часто не критичен для уже стабильного сервиса.
  • Не начинать новый reactive-стек «потому что модно». Если backpressure не нужен и команда не пишет на reactive каждый день, VT почти всегда даёт тот же результат проще, быстрее и с обычным стектрейсом при падении.
  • Смешанный стек — нормальная позиция. Ядро сервиса на VT с блокирующими вызовами, а один компонент — стриминг медленному клиенту или Kafka-консьюмер с явным backpressure — на Reactor. Это не архитектурная неряшливость, а осознанное применение каждой модели там, где она даёт то, чего другая не даёт.
  • Реактивные драйверы — отдельная переменная. Если БД-драйвер уже реактивный (r2dbc) и вокруг нет причин для backpressure, чистый блокирующий JDBC + VT обычно проще эксплуатировать — на JDBC больше опыта у команд, больше tooling, привычнее диагностика через thread dump.

Итог

Virtual threads не убили reactive, но забрали у него ту причину существования, которая была самой массовой — «нужно масштабировать I/O-bound нагрузку без потока на задачу». На бенчмарке из статьи #5 это видно прямо в числах: VT примерно втрое быстрее Reactor на одном и том же сценарии, при сопоставимой памяти. Для нового сервиса без backpressure-требований это почти всегда закрывает вопрос в пользу VT — блокирующий код, обычный стектрейс, меньше барьер входа.

То, что осталось за reactive, — не инерция и не привычка, а одна конкретная возможность, которой у VT нет: производитель, которого тормозит потребитель на уровне протокола, а не через внешний семафор. Там, где это действительно нужно — стриминг медленному клиенту, защита от перегрузки при непредсказуемой нагрузке, — альтернатив на JVM по-прежнему немного. Выбор между reactive и virtual threads сегодня — это не вопрос моды в ту или другую сторону, а вопрос одной проверки: нужен ли backpressure на уровне протокола именно в этом месте системы. Если да — Reactor. Если нет — почти наверняка virtual threads.

Тема конкурентности этим не исчерпывается: «Kotlin для backend» разбирает Kotlin Flow как модель стриминга, более лёгкую в письме, чем Reactor, но с похожими компромиссами; Kafka на JVM показывает, как consumer сам управляет темпом через poll() — контроль потока на уровне протокола брокера, без reactive-абстракций; а очереди задач и фоновые джобы — соседний слой конкурентности, где вопрос не «reactive или блокирующий вызов», а «как распределить работу между воркерами», не сталкиваясь напрямую с backpressure Reactive Streams.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии