В последние несколько лет в крупных компаниях наблюдается значительный рост внедрения event-driven (событийно-ориентированных) систем. Каковы основные причины этой тенденции? Это чистой воды хайп или есть веские причины, побуждающие к внедрению этой архитектуры? С нашей точки зрения, основными причинами, по которым многие компании выбирают этот путь, являются:

Слабая связанность между компонентами

Наличие отдельных компонентов, взаимодействующих асинхронно через события, обеспечивает слабую связанность (loose coupling). Мы можем без проблем модифицировать, развертывать и эксплуатировать эти компоненты в разное время независимо друг от друга; это огромное преимущество с точки зрения затрат на обслуживание и производительности.

Концепция “Fire and Forget”

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

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

При традиционном синхронном HTTP-обмене данными между компонентами продюсеру пришлось бы вызывать каждого потребителя независимо. Учитывая, что каждый вызов каждого потребителя потенциально может завершиться неудачей, стоимость реализации и поддержки такого подхода гораздо выше, чем при использовании event-driven подхода.

Отсутствие зависимости во времени

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

Ряд бизнес-моделей очень легко вписывается в event-driven системы

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

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

При этом не стоит забывать, что создание event-driven систем — задача не из легких. Есть несколько распространенных ошибок, которые часто допускают новички, работающие с такими системами. Давайте же рассмотрим некоторые из них.

4 распространенные ошибки

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

Отсутствие гарантии порядка

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

Допустим, у нас есть система платежей, в которой каждый платеж проходит через различные состояния на основе определенных событий. У нас может быть событие ProcessPayment, событие CompletePayment и событие RefundPayment; каждое из этих событий (или команд в данном случае) переводит платеж в соответствующее состояние.

Что произойдет, если мы не будем гарантировать порядок выполнения? Мы можем оказаться в ситуации, когда, например, в каком-нибудь платеже событие RefundPayment может быть обработано до события CompletePayment. Это будет означать, что платеж останется завершенным, несмотря на то, что мы намеревались получить за него возврат.

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

Изображение принадлежит автору
Изображение принадлежит автору

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

Pub/Sub системы для обмена сообщениями, такие как Kafka или Pulsar, предоставляют механизм, позволяющий легко реализовать это. Например, в Apache Pulsar можно использовать подписки с общим ключом, чтобы гарантировать, что события для конкретного ID платежа всегда обрабатываются одним и тем же потребителем по порядку. В Kafka, вероятно, придется использовать партиции и следить за тем, чтобы все события для конкретного ID платежа назначались одной и той же партиции.

В нашем новом сценарии все события, относящиеся к существующему платежу, будут обрабатываться одним и тем же потребителем.

Неатомарные множественные операции

Еще одна распространенная ошибка — делать сразу несколько действий в критически важной секции и полагать, что каждая операция всегда будет отработана успешно. Всегда помните: если что-то может не сработать, то это рано или поздно случится.

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

class UserService(private val userRepository: UserRepository, private val eventsEngine: UserEventsEngine) {
    fun create(user: User): User {
        val savedUser = userRepository.save(user)
        eventsEngine.send(UserCreated(UUID.randomUUID().toString(), user))
        return User.create(savedUser)
    }
}

UserService.kt на GitHub

Что произойдет, если отправка события UserCreated завершится неудачей? Пользователь-то все-равно будет сохранен, поэтому возникнет несогласованность между нашей системой и системами наших потребителей. Кто-то из вас может предложить тогда сначала отправлять событие UserCreated. Но что же произойдет, если после отправки события по какой-нибудь причине не произойдет сохранение пользователя?

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

Использование транзакций

Один из простых способов решить проблему несогласованности — воспользоваться преимуществами нашей системы баз данных и использовать транзакции, если они поддерживаются. Предположим, что нам предоставлен метод withinTransaction, который запустит новую транзакцию и откатится назад, если что-то, что было в нее заключено, не сработает.

fun create(user: User): User {
        return withinTransaction {
            val savedUser = userRepository.save(user)
            eventsEngine.send(UserCreated(UUID.randomUUID().toString(), user))
            User.create(savedUser)
        }
    }

UserService.kt на GitHub

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

Паттерн Transactional Outbox

Еще один способ решить эту проблему — отправлять событие в фоновом режиме после того, как пользователь был сохранен, используя паттерн transactional outbox. Как это можно реализовать, вы можете посмотреть здесь.

Отправка нескольких событий

Аналогичная проблема возникает, когда мы пытаемся отправить сразу несколько событий в критически важной секции. Что произойдет, если отправка события не удастся после того, как другие события были успешно отправлены? И снова разные системы могут оказаться в несогласованном состоянии.

Есть несколько способов избежать этой проблемы. Давайте посмотрим, как.

Цепочки событий

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

Например, рассмотрим такой сценарий. Когда создан пользователь, нам нужно отправить электронное письмо, о том что регистрация прошла успешно.

fun create(user: User): User {
        return withinTransaction {
            val savedUser = userRepository.save(user)
            eventsEngine.send(UserCreated(UUID.randomUUID().toString(), user))
            eventsEngine.send(SendRegistrationEmailEvent(UUID.randomUUID().toString(), user))
            User.create(savedUser)
        }
    }

UserService.kt на GitHub

Если отправка SendRegistrationEmailEvent завершится неудачей, мы не сможем исправить эту ошибку даже при повторной попытке. Поэтому регистрационное письмо так и не будет отправлено. Что если вместо этого сделать вот так?

Разделив события, мы позволяем другим потребителям продолжать работу после отправки события UserCreated, и в то же время выделяем отправку регистрационного письма в отдельную функцию, которую можно повторять сколько угодно раз независимо друг от друга.

Поддержка транзакций

Если ваша система обмена сообщениями поддерживает транзакции, вы также можете использовать их, чтобы иметь возможность откатить все события, если что-то пошло не так. Например, Apache Pulsar поддерживает транзакции, если это необходимо.

Изменения, ломающие обратную совместимость

Последняя распространенная проблема, которую мы рассмотрим, — это пренебрежение тем фактом, что события должны быть обратно совместимы, когда мы что-нибудь в них меняем.

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

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

В первом случае мы сразу удаляем поле middleName и добавляем новое поле dateOfBirth. Почему же это плохая практика?

Это первое изменение обязательно вызовет проблемы, оставив некоторые существующие события в заблокированном состоянии. Почему?

Представьте, что когда мы запускаем развертывание нашей новой версии, в нашем топике Users есть несколько событий UserCreated. Наиболее распространенным способом развертывания приложений без простоев является так называемое плавное обновление, поэтому в дальнейшем мы будем считать, что наше приложение развертывается именно таким образом.

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

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

Заключение

Мы убедились, насколько полезными могут быть event-driven архитектуры. Однако реализовать их должным образом — задача не из легких, и для этого требуется определенный опыт. В этом случае было особенно полезно заниматься парным программированием с кем-то, кто имеет опыт работы с подобной архитектурой, так как это может сэкономить нам очень драгоценное время.

Если вы ищете хорошую книгу, чтобы лучше понять Event-Driven системы, мы настоятельно рекомендуем "Building Event-Driven Microservices".


В завершение приглашаем на открытый урок «Способы разделения микросервисов на компоненты», который пройдёт уже сегодня в 20:00. На этом уроке:

  • Узнаем, как эффективно разделять приложения на микросервисы, уделяя особое внимание функциональной декомпозиции.

  • Погрузимся в методику EventStorming, которая поможет нам лучше понять бизнес-процессы и взаимодействие различных компонентов приложения.

  • Изучим подход API First Design, который ставит акцент на описание API и контрактов перед началом разработки микросервисов.

Записаться на этот урок можно на странице курса «Software Architect».

Комментарии (5)


  1. IsKaropk
    19.03.2024 15:15
    +4

    В последние несколько лет в крупных компаниях наблюдается значительный рост внедрения event-driven (событийно-ориентированных) систем. Каковы основные причины этой тенденции? Это чистой воды хайп или есть веские причины, побуждающие к внедрению этой архитектуры?

    "Хайп"? "В последнее время?". Вы никогда не слышали (например) о приложениях для Windows с графическим интерфейсом?


    1. DmitryOgn
      19.03.2024 15:15

      Словить многократный цикл перевызовов было легче легкого. Например, многократную прорисовку.


    1. Gapon65
      19.03.2024 15:15

      "Хайп"? "В последнее время?". Вы никогда не слышали (например) о приложениях для Windows с графическим интерфейсом?

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