Проектирование Event-Driven приложения
Процесс проектирования приложения с использованием EDA начинается с глубокого анализа домена, процессов и событий, происходящих в нём. Без понимания бизнес-домена сложно правильно декомпозировать приложение на компоненты, которые были бы слабо связаны между собой. При плохой декомпозиции и неправильно выделенных событиях использование EDA не принесёт тех выгод, что были описаны в предыдущем уроке, а только усложнит развитие и поддержку приложения.
В этом уроке разберём, как эффективнее спроектировать Event-Driven приложение.
Типовые шаги проектирования
- Анализ доменаНачальный этап включает в себя анализ бизнес домена, в рамках которого требуется определить основные бизнес-процессы, выявить сущности данных и все типы событий, которые с ними могут происходить. Одним из популярных способов это сделать является применение практики Event storming.
- Описание доменной моделиНа основе результатов предыдущего шага опишите доменную модель, выделив сущности данных с описанием их логического атрибутивного состава.
- Декомпозиция функциональностиДалее на основании описанной модели выделите компоненты системы, разделите функциональность между ними.
- Моделирование потоков событийОпределите последовательность событий и взаимодействия между компонентами на их основе.
- Моделирование событийТеперь выделите требуемые для публикации события. Детально опишите структуру каждого события, включая данные, которые будут передаваться между компонентами.
- Выбор брокера событийВыберите подходящую систему для обмена событиями в зависимости от требований к надёжности, производительности и стоимости.
- Реализация и мониторингПри разработке предусмотрите внедрение системы мониторинга для отслеживания состояния обработки событий и производительности системы в целом.
- Тестирование и валидацияРазработайте стратегии тестирования для проверки реакции системы на события, её масштабируемости и производительности.
Типовые ошибки при проектировании
- Недостаточность данных в событияхВ большинстве случаев рекомендуется придерживаться подхода, когда данных в событии достаточно всем потребителям для реакции на него. Если же потребитель может использовать событие лишь как уведомление, но ему впоследствии потребуется обратиться к поставщику события для получения расширенной информации, то это скорее просто триггер. Такой подход снижает выгоду от использования EDA, так как оставляет высокую зависимость между компонентами системы.Пример Сервис досок приложения «ОнлиСхемы» публиковал событие о размещении комментария, но не включил туда данные о том, какой пользователь был отмечен. Сервис нотификаций вынужден отдельно обращаться к сервису досок для получения информации о комментарии. Такая реализация приведёт к усложнению архитектуры в целом и сохранению зависимости сервисов. Однако иногда такой подход может быть оправдан, когда объём данных слишком велик, а количество событий и, следовательно, нагрузка невелики.
- Проблемы повторной обработки событийВ реальной жизни практически невозможно получить гарантию доставки “exactly-once”. Большинство брокеров событий, включая Kafka, гарантируют доставку “at-least-once”, то есть событие будет точно доставлено, но при этом одно и то же событие может быть доставлено больше одного раза. Зачастую при разработке команды игнорируют такую проблему. Это приводит к нарушению целостности данных (например, из-за дублирования записей при повторной обработке событий) или к дублированию выполнения операций вроде списания комиссий со счёта клиента. Чтобы не допускать таких проблем, рекомендуется моделировать события и проектировать потребителей так, чтобы обработка могла выполняться идемпотентно. Одна из типовых реализаций — публикация каждого события с уникальным идентификатором. Далее потребитель, в рамĸах транзаĸции ĸ БД, ĸоторая была порождена этим событием, сможет использовать идентифиĸатор при INSERT-операции в ĸачестве униĸального идентифиĸатора в отдельную техничесĸую таблицу. И в случае, если таĸое событие уже ранее было обработано, БД вернёт соответствующую ошибĸу, а приложению останется тольĸо её правильно обработать: просто проигнорировать.Пример Если сервис нотифиĸаций при обработĸе событий о создании новых объеĸтов из сервиса досоĸ не применит идемпотентность, то при повторном получении одного события он снова отправит нотифиĸацию пользователю. Потенциально дубли могут приходить при неĸоторых техничесĸих ошибĸах бесĸонечно. Это приведёт ĸ негативному опыту пользователей.
- Проблемы параллельной обработкиПри проектировании обмена событиями в условиях распределённой системы важно учитывать возможность параллельной обработки событий, которые относятся к одной сущности данных.Пример Действия пользователя в личном кабинете сервиса «ОнлиСхемы» привели к публикации двух подряд событий об изменении его карточки. Так как сервис нотификаций развёрнут в нескольких экземплярах, эти события могут быть вычитаны двумя экземплярами и обработаны почти одновременно. Это, возможно, приведёт к состоянию гонки, потому что одновременно два экземпляра сервиса будут пытаться изменить одну и ту же строку в БД. Если возникают ошибки, то рекомендуется использовать механизм брокера для управления порядком обработки событий. Как это сделать с использованием Kafka, мы разбирали ранее.
- Проблема считывания команды как событияГлавное отличие события от команды заключается в том, что в случае события поставщику неважен результат обработки сообщения потребителями. И неважно, будет ли оно обработано благодаря разделению границ функциональной ответственности между компонентами. Если же один компонент ожидает результата обработки сообщения другим компонентом и этот результат влияет на продолжение/завершение процесса, это может быть признаком команды, а не события. Ещё это может говорить о неверной декомпозиции компонентов системы. Это не точный показатель, а скорее триггер для верификации своего архитектурного решения. Во многом хараĸтер взаимодействия между ĸомпонентами определяется границами их фунĸциональности, выбранными на этапе деĸомпозиции.Пример Рассмотрим взаимодействие сервиса пользователей и сервиса нотифиĸаций. В случае обмена событием о создании нового пользователя мы говорим именно о событии: сервис пользователей не ожидает его обработĸи сервисом нотифиĸаций, и процесс создания пользователей ниĸаĸ не зависит от обработĸи этого события получателем.Теперь представим, что в процессе регистрации пользователя необходимо отправлять проверочный ĸод, ĸоторый пользователь вводит в UI. Обсудим возможную реализацию с использованием событий.Сервис пользователей может, например, публиĸовать событие «Клиент перешёл на этап верифиĸации», в рамĸах ĸоторого передавать проверочный ĸод, чтобы сверить его с тем, что введёт пользователь. В таĸом виде сервис нотифиĸации должен понять, что на этапе верифиĸации ему требуется отправить сообщение пользователю с этим ĸодом.Таĸая реализация может работать, но у неё есть ряд минусов: смещение границ ответственности между сервисами, при ĸоторой сервис пользователей, хотя и публиĸует «событие», зависит от результата обработĸи этого события сервисом нотифиĸаций. Более того, он ожидает ĸонĸретное действие, ĸоторое сервис нотифиĸации должен выполнить за определённое время, чтобы пользователь не ждал ĸод слишком долго.Такое взаимодействие сĸорее хараĸтеризуется ĸомандой. Схожий подход реализован при нотифиĸации пользователей на основании событий об объеĸтах. Однаĸо тут есть важное отличие: сервис досоĸ не ожидает ĸонĸретного действия от сервиса нотифиĸаций. Он не зависит от того, ĸаĸ, ĸогда и будет ли вообще обработано событие и отправлена нотифиĸация. Поэтому выделение логиĸи нотифиĸации на основании различных событий может быть вполне оправдано, если необходимо развивать и модифицировать эту фунĸциональность, независимо и без влияния на ядровую фунĸциональность платформы, заложенную в сервис досоĸ. И при этом таĸая реализация не противоречит хараĸтеристиĸе EDA и событий в частности.Нет необходимости всё сводить к событиям. Важно лишь прозрачно трактовать обмен сообщениями между компонентами, чтобы была возможность использовать подходящие инструменты и паттерны. Неверная траĸтовĸа может привести ĸ целой череде неĸорреĸтных решений из-за исĸусственно наложенных ограничений за счёт обмена «событиями-ĸомандами».
Связанные с EDA паттерны проектирования
- Хореография в SagaНапомним, что Saga — это паттерн управления транзакциями между сервисами, в котором действия разбиваются на последовательность локальных транзакций. Хореография предполагает отсутствие единого управляющего компонента, оркестратора, когда каждый из компонентов, который участвует в Saga, самостоятельно осуществляет действия на основании поступающих событий. Основной плюс этого подхода — лучшая масштабируемость.
- Event SourcingПаттерн, при котором изменения состояний объектов сохраняются как последовательность событий. Вместо прямого обновления состояния данных система хранит историю изменений в формате событий. Это даёт возможность восстановления состояния объекта на любой момент времени. Такой подход гарантирует целостность и прослеживаемость изменений, предоставляет дополнительные преимущества, включая возможности более глубокого аудита и эффективной диагностики. Этот паттерн вы подробнее изучите в следующем уроке.
- CQRS (Command Query Responsibility Segregation)Напомним, что CQRS — это разделение модели на команды, которые изменяют состояние системы, и запросы, которые возвращают её состояние. Это упрощает архитектуру и улучшает производительность. Позднее в этом уроке вы познакомитесь с CQRS поближе.
- Transactional outboxЭто паттерн, предназначенный решать проблему двойной операции записи, которая возникает в распределённых системах, когда одна операция включает как операцию записи в базу данных, так и сообщение или уведомление о событии в брокер. Сбой в одной из этих операций может привести к несогласованности данных.Паттерн заключается в том, что компонент, отправляющий сообщение, сначала сохраняет сообщение в базе данных как часть той же транзакции, что обновляет сущность данных. Затем отдельный процесс отправляет сообщения брокеру сообщений.
- Transaction log tailingПаттерн предполагает отслеживание лога транзакций БД для последующей отправки в брокер каждой транзакции в виде отдельного события. Для реализации этого паттерна на рынке существует отдельный класс систем CDC (Change Data Capture). Примером может служить Debezium.
Задание 1
Сопоставьте шаги проектирования EDA с ожидаемым результатом.
При моделировании событий происходит детальный анализ и проектирование логической модели данных каждого типа события. При моделировании потоков событий важно определить зависимости между событиями, их поставщиков и потребителей. Анализ домена — это первостепенный шаг для проектирования, в рамках которого команда должна сформировать общее понимание домена, описать бизнес-процессы и сущности данных. Декомпозиция функциональности предполагает закрепление функциональных границ между компонентами проектируемой системы.
Применение CQRS в EDA
В современных highload-системах часто возникают проблемы с производительностью и масштабируемостью из-за высокой нагрузки на базу данных и сложных операций чтения и/или записи. Паттерн CQRS (Command Query Responsibility Segregation) предлагает разделить операции чтения и записи данных, используя разные модели для команд (изменение состояния) и запросов (чтение состояния). Этот подход позволяет оптимизировать производительность, масштабируемость и безопасность системы, что особенно актуально для систем с высокой нагрузкой и сложной бизнес-логикой.
Эволюция архитектуры в CQRS
Большинство приложений построены по принципу многослойной архитектуры, где стандартно выделяется три слоя: пользовательский интерфейс, бизнес-логика и данные. Чаще всего системы начинают с простой архитектуры, где одна модель данных используется как для чтения, так и для записи. Однако по мере роста нагрузки и сложности системы возникают следующие проблемы:
- Конкуренция за ресурсыКогда одна и та же модель данных используется для чтения и записи, эти операции начинают конкурировать за ресурсы (например, процессорное время, память, дисковые операции). Это может привести к снижению производительности системы, особенно под высокой нагрузкой.
- Сложность оптимизацииОптимизация одной модели данных для эффективного выполнения операций чтения и записи одновременно может быть сложной задачей. Требования к производительности для чтения и записи данных могут быть различными, что затрудняет настройку базы данных для оптимальной работы.
- Проблемы с масштабируемостьюИспользование одной модели данных затрудняет масштабирование системы. Масштабирование чтения и записи данных требует разных подходов, и объединение этих операций в одной модели усложняет процесс масштабирования.
- БезопасностьВ традиционной архитектуре операции чтения и записи данных могут быть уязвимы для атак, таких как SQL-инъекции, из-за общего доступа к одной и той же модели данных.
Чтобы решить эти проблемы, архитектура системы может эволюционировать в сторону использования CQRS.
Реализация CQRS в Event-Driven системе
Для реализации CQRS необходимо выполнить несколько шагов:
Шаг 1. Определение команд и запросов
В первую очередь необходимо, исходя из текущей реализации и поставленных требований, сгруппировать все операции на команды и запросы.
Команды (Commands) представляют собой операции, которые изменяют состояние системы. Каждая команда описывает конкретное действие, которое должно быть выполнено.
Запросы (Queries) представляют собой операции, которые извлекают данные из системы. Запросы не изменяют состояние системы.
Шаг 2. Реализация моделей данных
Следующим шагом, исходя из функциональных и нефункциональных требований к командам и запросам, необходимо спроектировать отдельные модели данных для команд и для запросов.
💡 Одно из главных заблуждений о CQRS — то, что для команд и запросов должны использоваться разные базы данных.
Паттерн CQRS предполагает разделение ответственности за обработку команд и запросов и использование раздельных моделей для них. Однако это может быть выполнено исключительно на уровне кода приложения, в то время как на уровне БД могут использоваться даже одни и те же таблицы.
Для достижения дополнительных эффектов можно использовать разные схемы данных для записи и чтения. В рамках такого разделения возможно создать витрину с оптимальной для чтения схемой, где при чтении количество необходимых JOIN-ов было бы сведено к минимуму.
Для обеспечения большей изоляции можно использовать для команд и запросов физически разные БД разного типа. Это даёт возможность обеспечить НФТ для запросов и команд с минимальной зависимостью друг от друга. Например, для команд может быть оптимальным решением использовать реляционную БД, которая обеспечит ACID-транзакции. А для запросов с целью достижения минимального времени отклика использовать key-value БД.
Для случаев, когда необходимо реализовать несовместимые друг с другом требования к запросам, можно использовать несколько разных моделей запросов — либо в виде отдельных схем, либо в других хранилищах.
Шаг 3. Реализация обработчиков команд и запросов
Реализуем отдельные компоненты приложения, отвечающие за обработку команд и запросов:
- Обработчики командОтвечают за обработку запросов, приводящих к изменению состояния сущности данных. По факту изменения состояния обработчик должен будет опубликовать событие в брокер сообщений (например, Kafka).
- Обработчики запросовОтвечают за предоставление данных, обращаясь к хранилищу данных. Это может быть как и та же схема данных, что используется для команд, так и другая схема или даже база данных. Необходимо реплицировать нужные данные, опираясь на интересующие потоки событий, чтобы далее отдавать их при запросах на чтение данных.
- При этом в силу склонности EDA-приложений к достаточно гранулярной декомпозиции зачастую на уровне обработчиков запросов решаются задачи агрегирования данных из нескольких компонентов системы на основании разных потоков событий.
- При реализации обработчика важно выбрать оптимальное хранилище, которое обеспечит нужную гибкость модели для чтения и достаточную производительность. Часто выбор падает на документоориентированные БД (например, Mongo) или key-value хранилища вроде Redis.
- Для разных сценариев использования данных (например, для сложных аналитических запросов и для запросов данных для отображения пользователю на главном экране) допускается создание нескольких обработчиков запросов, подписанных на смежные потоки событий.
Шаг 4. Доработка систем-потребителей для возможности интеграции с различными компонентами для отправки запросов и команд
Сложности при реализации
- Повышение общей сложности архитектурыCQRS может привести к более сложному дизайну приложения, особенно если при реализации используются разные схемы или хранилища для команд и запросов.
- Обмен сообщениямиХотя для реализации CQRS не требуется асинхронный обмен сообщениями, обычно он применяется в случаях, когда необходима репликация данных в отдельную схему или БД для запросов. А использование асинхронного взаимодействия накладывает дополнительные вызовы в части обработки дублей сообщений, соблюдения их порядка, обеспечения нужных гарантий доставки и обработки ситуаций потери сообщений.
- Конечная согласованностьЕсли разделять базы данных для команд и запросов, то необходимо мириться с конечной согласованностью, то есть данные в хранилище для запросов могут быть устаревшими.
Когда стоит физически разделять запросы и команды (отдельные схемы или базы данных)
- Высокие требования к производительностиЕсли приложение подвергается высоким нагрузкам на чтение или запись данных, использование отдельной схемы или базы данных может помочь оптимизировать производительность. На чтение можно использовать реплику, оптимизированную для быстрого доступа и большого объёма запросов, в то время как запись будет обрабатываться отдельной базой данных, оптимизированной под транзакции.
- Различные требования к масштабируемостиЕсли системе требуется масштабирование чтения и записи по-разному, разделение на две разные базы данных позволит масштабировать их независимо друг от друга, обеспечивая более гибкую настройку ресурсов под растущие нагрузки.
- Требования к безопасности и изоляции данныхВ случаях, когда нужно строгое разделение доступа к данным между операциями чтения и записи в целях безопасности или соблюдения нормативных требований, разные схемы или базы данных могут предложить дополнительный уровень изоляции.
- Сложные отчёты и аналитикаЕсли системе требуется выполнение сложных аналитических запросов или генерации отчётов, которые могут замедлять основную базу данных, стоит подумать о выделении отдельного хранилища под эти цели. Это позволит исключить влияние на производительность основных операций системы.
Когда разделение неоправданно
- Небольшие приложения с ограниченной нагрузкойДля малых или средних приложений с умеренным объёмом данных и нагрузкой разделение только увеличит сложность системы и затраты на сопровождение и ресурсы. При этом оно вряд ли обеспечит заметный эффект, который мог бы оправдать дополнительные затраты.
- Ограниченный бюджет и ресурсыРазделение на разные базы данных требует дополнительных ресурсов как для разработки, так и для поддержки. Если на вашем проекте сильно ограничен бюджет, стоит проанализировать и убедиться, что дополнительные затраты «по карману» и не повлияют негативно на общий бюджет проекта.
Команда «ОнлиСхем» обнаружила, что при запросе текущего состояния доски увеличивается время отклика, а также быстро растёт нагрузка на приложение. Решили использовать паттерн CQRS.
В первую очередь решение нацелено на минимизацию времени отклика. Для этого команда может использовать отдельное хранилище, оптимизированное под эту операцию. На текущий момент лучшим решением видится использование БД Mongo.
Команда разработает отдельный компонент, который будет отвечать за запросы информации о доске, объектах и связях между ними. Он будет отвечать за содержание и актуализацию реплики данных на основании событий, которые порождаются командами к сервису досок. Данные будут собираться в один документ. Таким образом по запросу на основании одного идентификатора можно будет быстро получить текущее состояние доски.
Однако команде важно помнить о потенциальных проблемах отставания реплики данных. Это может быть существенной проблемой, ведь у пользователей должна быть возможность совместно работать на досках в режиме онлайн. Этот вопрос потребует анализа и отладки в рамках эксплуатации и развития системы.
Дополнительные материалы
Итоги
- Перед проектированием приложения с использованием EDA нужно проанализировать домен, процессы и события, происходящие в нём. Без понимания бизнес-домена сложно правильно декомпозировать приложение на компоненты, которые были бы слабо связаны между собой.
- Основные паттерны проектирования, связанные с EDA: хореография в Saga, Event Sourcing, CQRS, Transactional outbox, Transaction log tailing.
- Паттерн CQRS предполагает разделение операций чтения и записи данных, чтобы оптимизировать производительность, масштабируемость и безопасность системы. Это особенно актуально для систем с высокой нагрузкой и сложной бизнес-логикой.