Что такое Event-Driven Architecture?

Event-Driven Architecture (EDA) — это архитектурный шаблон, в котором система генерирует, обнаруживает, потребляет и реагирует на события. В отличие от традиционных запросно-ответных систем, EDA строится вокруг асинхронного потока событий. Логика работы сервиса строится не на синхронных запросах (запрос -> обработка -> ответ), а на асинхронной обработке события (событие -> обработка -> возможно, другое событие). С этим обычно бывают проблемы, поскольку асинхронная обработка требует особого понимания работы приложения — такое приложение не гарантирует, например, что если данные были получены, то они были обработаны.

Сюда же клеится Event Sourcing, насчёт которого я спорил с Лёхой Будниковым (Лёха был прав во всём, кроме сути — ES надо начинать строить с архитектуры, а не с отдельного сервиса).

Как EDA пересекается с программированием на реактивных потоках?

Также, как и EDA, программирование на реактивных потоках построено на подписке потребителей на события. То есть, EDA — это про продюсеров и консьюмеров (например, через Kafka), а реактивное программирование — про подписку на события через функции обработки событий (subscribe, map, filter и прочие). Иными словами, РП дополняет EDA уже на уровне сервиса.

Уровень интеграции.

Например, со стороны EDA мы используем Kafka, со стороны сервиса — подписку на Kafka-топик. Например:

// Реактивный потребитель событий из Kafka
Flux<Event> eventStream = KafkaReceiver.create(receiverOptions)
    .receive()
    .publishOn(Schedulers.elastic()); // Асинхронная обработка

eventStream.subscribe(event -> 
    processEvent(event).subscribe() // Неблокирующая обработка
);

Мы опубликовали подписку на события Kafka и сидим ждём, пока это событие упадёт. Со стороны EDA мы имеем асинхронную коммуникацию и слабое связывание компонентов, со стороны сервиса — эффективную подписку и backpressure.

Уровень обработки.

При композиции обработчиков мы можем использовать всю силу реактива — ограничение параллелизма, таймауты, а также, отказоустойчивость (retry, circuit breakers).

eventStream
    .filter(e -> e.getPriority() > 5)
    .window(Duration.ofSeconds(10))
    .flatMap(this::batchProcess)
    .subscribe();

То есть, мы как бы «продолжаем» асинхрон, перенося его из EDA в сервис и поддерживая его уже на уровне сервиса. Интеграция там полная.

Уровень данных.

В EDA события часто приходят пакетами или вообще идут непрерывным потоком. Мы можем получить 3 сообщения, потом ещё 3, потом 10 (особенно это хорошо заметно в интеграции с Kafka, когда настроен batch-size и сообщения приходят батчами. Или, например, в R2DBC, когда мы запрашиваем из базы flow и получаем сообщения по частям, пока не выгребем из базы всё запрошенное. Так вот. Система должна уметь обрабатывать такой flow «из коробки», и это не умеют виртуальные потоки и корутины, у них просто нет такой функциональности. Необходимо настроить систему таким образом, чтобы она могла корректно обрабатывать эти батчи.

Паттерн EDA Реактивный эквивалент
Обработка одиночных событий
Mono<Event>

Поток событий
Flux<Event>

Агрегация событий
.window(); .groupBy();
Троттлинг
.sample(Duration); .trottle();
Уровень производительности. Неблокирующий стек.

Мы получаем минимальные Therad-Blocking операции, высокую конкуренцию при обработке событий и эффективную утилизацию ресурсов, поскольку один поток может обрабатывать тысячи событий.

Архитектурный пример:

[Event Producer] → (Kafka) → [Reactive Service] 
  ↑ asynchronously    ↓ via backpressure
[Event Consumer] ← (WebFlux) ← 
Уровень отказоустойчивости.

EDA обеспечивает персистентность событий и повторную отправку. Реактив добавляет восстановление сообщений на локальном уровне, например, через onErrorResume, retryWhen. Например:

eventStream
    .flatMap(event -> 
        process(event)
            .onErrorResume(e -> fallback(event))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
    )

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

Итого. Почему EDA и Reactive хорошо дополняют друг друга?

Реактив и EDA — мощная комбинация, благодаря следующим критериям:

а) Эффективность: реактивные потоки идеально ложатся на модель EDA.

б) Масштабируемость: оба подхода проектировались для распределённых систем.

в) Резилентность (способность системы к быстрому восстановлению): persistence в EDA (например, в Kafka / сообщение не удаляется из топика и может быть прочитано заново) и resilence patterns в reactive (Circuit Breaker, Retry, Timeouts, Fallback, BulkHead, Event-message based communications — посмотреть).

г) Гибкость: можно простроить сложную цепочку, продолжающуюся на уровне сервиса, без блокирующих операций.

Итого. Идеальные комбинации.

EDA: организует макропоток между сервисами.

Reactive: реализует микропоток данных внутри конкретного сервиса.

Обе модели поддерживают backpressure.

Реальный кейс:

Сервис вычитывает данные из Kafka. Если он будет делать это быстрее, чем база данных успевает их сохранять, данные будут накапливаться в оперативке JVM, в итоге, переполнят её и сервис отвалится с OOM. Остальные реплики приложения смогут взять на себя нагрузку, но они так же будут ппереполняться и падать с OOM, при этом, теряя данные. Конечно, приложение будет работать, но поды будут периодически отваливаться, теряя данные, потом подниматься заново.

Если же правильно работать с backpressure, поды, возможно, будут не так быстро читать данные. При этом, ни не будут падать и терять данные.

Главный вопрос: что не так с виртуальными потоками? Почему они не подходят для EDA?

Главная претензия к виртуальным потокам — в том, что они не поддерживают всего этого великолепия и работают в синхронной парадигме. EDA предлагает взаимодействие на основе событий и подписок на них, в то время, как виртуальные потоки не работают через подписки и не контролируют Backpressure, например.

Виртуальные потоки не меняют парадигму — они просто делают блокирующие операции «дешёвыми». В EDA же важна именно реактивность, то есть, реакция на события, а не их опрос.

При этом, в виртуальных потоках происходит пассивный опрос, например:

var queue = new LinkedBlockingQueue<Event>();

// Виртуальный поток блокируется, пока не получит сообщение
Thread.startVirtualThread(() -> );

Проблемы:

во-первых, каждое сообщение обрабатывается в отдельном потоке, а во-вторых, такая очередь может бесконечно расти.

КАК ПРОИСХОДИТ РЕАКЦИЯ НА СОБЫТИЯ В EDA?

Также, в EDA критически важен контроль скорости обработки, чтобы потребитель не был перегружен и не было отказов (что ещё больше перегрузит оставшиеся реплики и пошло-поехало). Виртуальные же потоки не представляют встроенных механизмов Backpressure (как, например, request(n) в реактиве). Если, например, начнёт приходить в 10 раз больше событий, и Kafka будет успевать их считывать, то виртуальные потоки создадут миллионы параллельных задач, что приведёт:

а) к OOM

б) каскадным отказам (OOM на поде -> нагрузка на остальные поды -> OOM на остальных подах)

Также, существуют ограничения в интеграции виртуальных потоков и EDA. Большинство EDA фреймворков и реактивных библиотек построены на неблокирующих API (NIO). При этом, виртуальные потоки не работают с Selector-ами (основа NIO) и могут снизить производительность из-за накладных расходов на переключение контекста.

ПОДРОБНЕЕ О NIO! ЧТО ЭТО? ПРО SELECTOR!

Например, при 10k сообщений в секунду реактив будет работать на 10 потоках (физически), а виртуал наплодит 10k виртуальных потоков (плюс посчитаем накладные расходы).

Таким образом, преимущества EDA теряются при использовании виртуальных потоков: нет аналогов для композиции (flatMap, zip), сложно строить цепочки операций и нет отложенного выполнения. Виртуальные потоки проектировались как не альтернатива реактивным потокам, а как инструмент оптимизации синхронного кода и упрощение работы с блокирующим API.

Почему виртуальные потоки не подходят для решения канонических задач EDA, с точки зрения Computer Science?

Виртуальные потоки не соответствуют фундаментальным требованиям EDA с точки зрения Computer Science.

Причины:

Несоответствие модели вычислений.

Проблема: Pull VS Push.

Каноническая EDA основана на PUSH-модели — события асинхронно доставляются подписчикам. Виртуальные же потоки используют PULL-модель, периодически запрашивая данные, что противоречит природе EDA, где обработчик должен реагировать на события, а не опрашивать их.

Пример:

// Антипаттерн для EDA: активный опрос в виртуальном потоке
Thread.startVirtualThread(() -> );

В данном случае, создаётся искусственная конкуренция за ресурсы, что не подходит для высоконагруженных систем.

Итого. Приговор виртуалкам.

Виртуальные потоки были придуманы для решения других задач, таких как:

а) оптимизация синхронного кода

б) упрощение работы с блокирующим API

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

Корутины как компромис виртуальными и реактивными потоками в части применения в EDA.

Корутины в Kotlin можно считать оптимальным компромиссом между сложностью и неповоротливостью реактивных потоков и ограничениями виртуальных потоков в сфере применения EDA.

Почему?

Потому что они сохраняют преимущества реактивного подхода, но не имеют многих его недостатков (Callback Hell, сложная отладка, deadlock, неоптимальная утилизация памяти и так далее).

Корутины бьют реактив.

Корутины не имеют многих проблем реактивных потоков, например:

а) Callback Hell / Pyramid Of Doom: императивный стиль, отсутствие вложенных лямбд

б) Deadlock: структурная конкурентность (coroutineScope, supervisorScope)

в) сложная отладка: чёткие стектрейсы, интуитивная отмена через Job.cancel()

г) неоптимальная утилизация памяти: вместо множества объектов-подписок и обёрток — легковесность (100B против 1MB) и минимум обёрток (Flow — это интерфейс)

Корутины бьют виртуальные потоки.

Асинхронная модель.

Виртуальные потоки оптимизируют блокирующий код, но не меняют модель на событийно-ориентированную. Корутины работают в suspend-режиме, идеально подходя для EDA. Например:

// Подписка на события Kafka
kafkaConsumer
    .consumeAsFlow() // Асинхронный push-поток
    .collect 

Встроенный backpressure.

Виртуальные потоки не контролируют скорость обработки, что повышает риск OOM и каскадных отказов. Корутины имеют возможность буферизации событий, ограничивая их обработку. Например:

events
    .buffer(100) // Очередь из 100 событий
    .collect 

Композиция обработчиков.

Виртуальные потоки не предоставляют аналогов flatMapMerge, zip, window. Корутины поддерживеют реактивные паттерны (Flow). Например:

flow1
    .zip(flow2) 
    .flatMapMerge 

Когда реактивные потоки всё же лучше?

Корутины — не серебряная пуля. В некоторых аспектах они всё же уступают реактивным потокам. А именно:

а) распределённая обработка: Kotlin Flow пока требует ручной интеграции со Spring Cloud Stream, Kafka Streams.

б) сложные операторы: в Kotlin Flow их мало.

Выводы: корутины однозначно бьют виртуальные потоки, но слегка не дотягивают до реактивных потоков в плане функциональности и интеграции

Берём корутины, если:

а) нужна асинхронность без сложностей реактивного подхода.

б) требуется интеграция с блокирующим кодом (базы данных, например).

в) важна легковесность (миллионы событий в секунду).

Думаем в сторону реактивных потоков, если нужна распределённая обработка (Flink, Kafka Streams).

Итого, корутины гораздо лучше подходят для EDA, чем реактивные потоки. Однако, для сложных сценариев (например, потоковая аналитика) реактивные потоки остаются безальтернативными.

Заключение. Сравнительная таблица.

Критерий Реактив Виртуал Корутины
Сложность кода Высокая (цепочки операторов) Низкая (как обычные потоки) Низкая (императив)
Поддержка EDA Отличная (встроенный backpressure) Плохая (нет push-модели) Хорошая (Flow + Channels)
Работа с блокирующим кодом Сложная (Mono.fromCallable) Идеальная (прозрачная) Простая (withContext)
Производительность Высокая, но оверхед на обёртки Средняя Очень высокая (легковесные suspend-функции)
Распределённые сценарии Отличная (Kafka Streams, RSocket) Ограниченные Средняя (требует доработок)

Коллеги, привет.
Вырвался из отпуска, накидываю канву.

История первая. Как виртуальные потоки делают людей счастливыми.

Хорошо, у нас есть виртуальные потоки, которые каждый желающий уже добавил в свои сервисы. Потоками активно пользуются, все счастливы. И счастливы заслуженно, поскольку у виртуальных потоков есть очевидные преимущества, например:

1. Масштабируемость. Мы можем запустить миллионы параллельных задач на виртуальных потоках без ощутимой нагрузки на систему. Это хорошо.
2. Существенное упрощение работы с блокирующим кодом. При выполнении блокирующего запроса виртуальный поток приостанавливается и основной поток освобождается для других задач. Это очень хорошо.
3. Низкие накладные расходы. Виртуальные потоки маленькие. Отлично.
4. При этом, код остаётся понятным. Великолепно.

Хотите цифры? Вот цифры.

Виртуальные потоки значительно уменьшают утилизацию ресурсов, поскольку позволяют писать простой блокирующий код и получать при этом производительность как у асинхронного. И всё здорово, пока мы не коснулись Event-Driven Architecture.

История вторая. На сцену выходит EDA.

Что такое Event-Driven Architecture? Это известный архитектурный шаблон, в котором всё строится вокруг асинхронного потока событий. Логика строится не вокруг запроса-ответа, а вокруг События и его обработки. Ключевыми сущностями EDA являются Producers, Consumers, Event Channels, Event Processing. Продюсеры создают события и передают их по Каналам, Потребители читают События из этих Каналов и Обрабатывают их. Совсем другая парадигма, заметьте. Асинхронная.

Как следствие, EDA имеет очевидные преимущества для высоконагруженных систем, такие как:

1. Слабая связанность. Компоненты не знают друг о друге, а знают только про Каналы Событий. Мы разрабатываем такие компоненты независимо друг от друга, обеспечивая таким образом гибкость разработки. Писать, развивать и поддерживать такие системы — мечта разработчика, и это не сарказм.
2. Масштабируемость. Мы можем наплодить любое количество реплик Потребителей, например, не изменяя при этом код.
3. Гибкость. Добавляем новые функции как подписчики на существующие события.
4. Отказоустойчивость. Потребители обрабатывают события в своём темпе, по мере возможностей, а если что-то пошло не так, они делают ретрай.

Как ни крути, EDA является мощнейшим паттерном для реализации высоконагруженных, сложных и распределённых систем. Если у вас именно такая система — велком. Вы просто обязаны её спроектировать.

История третья. EDA против.

Окей. Мы взяли лучшие практики и спроектировали систему. Лучшие практики умы определили ранее: EDA на уровне архитектуры сервисов, виртуальные потоки на уровне отдельных сервисов. Мы собрали все известные нам лучшие практики, но начинаем замечать, что хоть и хороши каждая сама по себе, но они не матчатся на идеологическом уровне. А именно:

1. Виртуальные потоки попросту не поддерживают парадигму реактивной обработки события. Они просто делают блокирующие операции «дешёвыми». А для EDA важна именно реактивность, то есть, реакция на события, а не их опрос. А в виртуальных потоках происходит или пассивный опрос, или активный. Когда происходит пассивный опрос, виртуальный поток блокируется, пока не получит сообщение (например, queue.take()). Да, Carrier Thread при этом высвобождается для других задач, но поскольку при каждом таком запросе создаётся новый виртуальный поток, их количество может расти до бесконечности, особенно в свете отсутствия встроенных инструментов backpressure (об этом ниже). При активном опросе виртуальный поток всё равно блокируется на .join(), так что, суть не меняется.
2. Отсутствие встроенного механизма backpressure. Продюсеры может генерировать сообщения с какой угодно скоростью, и Подписчики могут не успевать обрабатывать растущий поток сообщений. Это может привести к OOM на конкретной реплике, повышению нагрузки на других и, как следствие, к каскадным отказам системы. Именно поэтому при реализации EDA необходимо предусмотреть механизм backpressure на уровне приложения. В виртуальных потоках такого механизма нет. И это проблема.
3. Асинхронность. Kafka, RabbitMQ и другие инструменты для работы в шаблоне EDA уже и так асинхронны. Виртуальные потоки такие операции не ускоряют.

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

Как быть?

История четвёртая. Реактивные потоки, куда же без них.

А так хорошо всё начиналось. Виртуальные потоки. Дешевизна выполнения. EDA. И, как всегда, всё разбивается о реализацию.

Так, хорошо. Убираем виртуальные потоки, достаём реактивные. Да, они поддерживают EDA как нельзя лучше:

1. Общие концепции. EDA фокусируется на проектировании системы вокруг событий. Реактивное программирование призвано реагировать на изменения (data-flow подход).
2. Дополнение EDA на уровне приложения. EDA задаёт архитектурный стиль всей системы через проектирование Продюсеров, Потребителей, События и Каналы Событий. Реактивное программирование подхватывает концепцию EDA на уровне приложения, реализуя эти самые Продюсеры, Консьюмеры, Сообщения и Подписки на Каналы.
3. Встроенные механизмы поддержки EDA. Реактивное программирование отлично реализует концепции EDA на инструментальном уровне:

а) Нативная PUSH-модель в Reactor / RxJava.
б) Реализация подписок через интерфейс Subscribe.
в) Поддержка Backpressure.
г) Композиция обработчиков. Преобразование. Агрегация. Роутинг.
д) Zero-Wait интеграция с неблокирующим I/O при помощи неблокирующих драйверов.
е) Обработка ошибок при помощи резилентных паттернов.
…и прочая, прочая, прочая.

Виртуальное программирование было создано для того, чтобы служить EDA верой и правдой. Как мы можем отказаться от таких милашек?

История пятая. Реактивные потоки не идеальны. Кто бы мог подумать.

Несмотря на критическую значимость реактивного программирования для реализация EDA на уровне приложений, скелет в шкафу всё-таки есть. И он всем известен. Реактивное программирование крайне неудобно для того, чтобы на нём, собственно, программировать. Сама концепция подписок настолько отвратительна, а код насколько неуклюж, что заставить разработчиков что-то писать на реактиве довольно непросто. И их можно понять. Вот самые частые кейсы, от которых у разработчиков случается кринж, выгорание и депрешн:

1. Callback Hell / Pyramid of Doom. Вы вообще видели эти цепочки вложенных запросов, с подписками и обработкой ошибок? Да такой лапше позавидует Джан Сяньчэн (известный китайский повар). Цепочки запросов невероятно громоздки и сложны в поддержке. Вы пробовали их поддерживать? Я — да. И Callback там был именно Hell.
2. Работа на ограниченном количестве потоков. Количество потоков завязано на количество ядер. Сколько ядер на вашей реплике приложения? Одно? То-то.
3. Риск Deadlock. При вызове блокирующего кода внутри реактивной цепочки возможны взаимоблокировка. Например, функция Mono.fromCallable вызывает внутри себя блокирующий JDBC-запрос, и если нет свободных соединений, запрос попадает в очередь в Schedulers.boundedElastic(). В итоге, потоки из boundedElastic ждут свободных соединений. Соединения не освобождаются, поскольку не хватает потоков.
4. Неоптимальная утилизация памяти. Так, цепочка Flux.map().filter().flatMap() создаст 3 объекта-подписки. Да, и дочерние операции при ошибке в родительском операторе отменены не будут.

Реактивные потоки не оптимальны. Их тяжело поддерживают. Они порождают оверхед. Они заставляют страдать разработчиков.

Кто, в итоге, должен страдать? Разработчики или приложение? Или, возможно, есть компромисс?

История шестая. Корутины как компромисс.

В пыле дискуссии, у кого потоки больше, мы совсем забыли про стильный и модный Котлин и его корутины, которые, вообще-то, известны с версии 1.1, в отличие от виртуальных потоков, которые окончательно заехали только в Java 21. И есть мнение, что именно корутины могут стать тем самым компромиссом в реализации EDA на уровне приложения.

Присмотримся к корутинам. Что они могут нам дать? Соберём преимущества.

Что ж, давайте посмотрим, могут ли корутины заменить реактив в части имплементацию EDA на уровне приложений.

0. Никакого Callback Hell. Забудьте про пирамиды. Мы пишем самый обычный код.
1. Deadlock. Корутины конкурируют за потоки и приостанавливаются в ожидаемых местах. Риск дедлоков значительно снижается.
2. Асинхронная обработка событий, но без цепочек операторов, при помощи Flow. Да, код всё так же работает асинхронно, но читается последовательно. И всё благодаря suspend-режиму.
3. Backpressure. Flow реализует backpressure аналогично Reactive Streams. Мы можем настроить буферизацию, пропуск промежуточных событий и многое другое. Flow автоматически приостанавливает генератор событий, если потребитель не успевает.
4. Композиция обработчиков. Аналоги присутствуют во Flow.
5. Интеграция с блокирующим кодом реализуется без сложных обёрток.
6. Контроль жизненного цикла обработчиков событий позволяет отменять все дочерние корутины при выходе из родительской. Чем, увы, не может похвастаться реактив. А ещё есть автоматическая отмена при таймаутах. Как тебе такое, RxJava?
7. Каналы для коммуникации. Котлин может похвастаться аналогом Topic в реактивных стримах, и это Channel.
8. Обработка ошибок происходит не в цепочках, а в блоках try / catch.
9. В отличие от виртуальных потоков, которые просто оптимизируют блокирующий код, но не меняют модель на событийно-ориентированную, корутины работают в suspend-режиме, и об этом можно поговорить отдельно.

Про производительность поговорим отдельно. 100 байт на корутину позволит генерировать миллионы корутин и никто не заметит. Да и обёртки отсутствуют: Flow — это интерфейс, а не цепочки объектов.

Корутины не просто заменяют реактивный подход, а предлагают более человекочитаемую абстракцию для EDA на уровне приложения, сохраняя при этом асинхронность, backpressure и композицию.

История седьмая. Выводы.

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

Выручают корутины. Они устраняют большинство недостатков реактивных потоков, сохраняя их преимущество, и при этом гораздо лучше подходят для EDA, чем виртуальные потоки Java. Надо брать!