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 практически нет.
  • Композиция операторов над потоком событий. Цепочка retrytimeoutonErrorResumebufferwindow над потоком событий читается как единый декларативный конвейер. Переписать то же самое императивно поверх блокирующего кода на 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-абстракций; а очереди задач и фоновые джобыготовится, с 17 сентября — соседний слой конкурентности, где вопрос не «reactive или блокирующий вызов», а «как распределить работу между воркерами», не сталкиваясь напрямую с backpressure Reactive Streams.

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

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

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

Комментарии