Разработка транзакционных микросервисов с помощью Агрегатов, Event Sourcing и CQRS (Часть 2)

7a2e797887c746849ef5091e2fd7c15f.jpg

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

В первой части статьи мы говорили, что основным препятствием при использовании микросервисной архитектуры является то, что модели предметной области (domain model), транзакции и запросы удивительно устойчивы к разделению по функциональному признаку. Было показано, что решение заключается в реализации бизнес-логики каждого сервиса в виде набора DDD-агрегатов. Каждая транзакция обновляет или создает один единственный агрегат. События используются для поддержания целостности данных между агрегатами (и сервисами).

Во второй части статьи мы увидим, что ключевой задачей при использовании событий является атомарное изменение состояния агрегата и одновременная публикация события. Посмотрим, как решить эту проблему с помощью Event Sourcing — используя событийно-ориентированный подход к проектированию бизнес-логики и системы сохранения состояния. После этого опишем, как микросервисная архитектура затрудняет реализацию запросов к базе данных, и как подход, называемый Command Query Responsibility Segregation (CQRS), помогает реализовывать масштабируемые и производительные запросы.

Основные тезисы:

  • Event Sourcing — это механизм надежного изменения состояния и публикации событий, позволяющий преодолеть ограничения других решений.
  • Событийно-ориентированный подход, использующий Event Sourcing, хорошо согласуется с микросервисной архитектурой.
  • Снапшоты могут повысить производительность запросов состояния агрегатов за счёт сочетания в себе всех событий, произошедших до определенного момента времени.
  • Event sourcing может создавать проблемы для запросов, но они преодолеваются с помощью CQRS и материализованных представлений.
  • Event sourcing и CQRS не требуют каких-либо специальных инструментов или программного обеспечения, многие существующие фреймворки могут взять на себя часть необходимой низкоуровневой функциональности.

Надежное обновление состояния и публикация событий


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

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

114bb8124ed2411886e7ab2574608976.png

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

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

c7c50040a1434cb5ad5ca2501a026a27.png

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

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

abad463a6fec40f087bfdcef4e0d6b96.png

Все три варианта имеют существенные недостатки. Публикация событий через брокер сообщений с отложенным обновлением базы данных не обеспечивает условия модели согласованности Read-your-writes. Публикация событий на основе журнала транзакций обеспечивает согласованность при чтении данных, но не всегда может публиковать бизнес-события высокого уровня. Использование таблицы базы данных в качестве очереди сообщений обеспечивает согласованность при чтении и публикацию бизнес-событий высокого уровня, но подразумевает, что разработчик должен не забыть опубликовать событие при изменении состояния.

К счастью, есть еще один вариант решения. Это событийно-ориентированный подход к сохранению состояния и бизнес-логике, известный как Event Sourcing.

Разработка микросервисов с помощью Event Sourcing


Event Sourcing представляет собой событийно-ориентированный подход к сохранению состояния. Это не новая идея. Впервые я узнал об Event Sourcing более пяти лет назад, но он оставался диковинкой, пока я не начал разрабатывать микросервисы. Event Sourcing оказался отличным способом реализации событийно-ориентированной микросервисной архитектуры.

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

Чтобы получить текущее состояние агрегата, производится загрузка событий и их воспроизведение. В терминах функционального программирования, сервис реконструирует текущее состояние агрегата, выполняя функционал fold/reduce над событиями. Поскольку события теперь и есть состояние, мы больше не имеем проблем с атомарностью обновлений состояний и публикацией событий.

Рассмотрим, например, сервис Заказ. Вместо того, чтобы хранить каждый заказ как строки в таблице ORDERS, он хранит каждый агрегат Заказ в виде последовательности событий, таких как ЗаказСоздан, ЗаказОдобрен, ЗаказОтправлен и т.д. Вот как это могло бы быть сохранено в интернет-магазине на SQL базе данных.

ea447d1afb604711809df68e579d1ff3.png

Колонки entity_type и entity_id columns — идентификаторы агрегата.
event_id — идентификатор события.
event_type — тип события.
event_data — сериализованные атрибуты события в формате JSON.

Некоторые события содержат много данных. Например, событие ЗаказСоздан содержит данные о составе заказа, платежную информацию и информацию о доставке. Событие ЗаказОтправлен содержит минимум информации и представляет собой просто переход между состояниями.

Event Sourcing и публикация событий


Строго говоря, Event Sourcing просто хранит состояние агрегатов как события. Его очень просто использовать в качестве надежного механизма публикации событий. Сохранение события по своей природе является атомарной операцией, что гарантирует, что хранилище событий будет предоставлять доступ к событиям всем заинтересованным сервисам. Например, если события сохраняются в таблице EVENTS, упомянутой выше, то подписчики могут просто периодически опрашивать таблицу для получения новых событий. Более сложные хранилища событий будут использовать другой подход, который даёт аналогичные гарантии, но является более производительным и масштабируемым. Например, Eventuate Local использует паттерн Transaction log tailing. Он читает события, вставленные в таблицу EVENTS из потока репликации MySQL, и публикует их с помощью Apache Kafka.

Использование снапшотов состояния для повышения производительности


Агрегат Заказ характеризуется относительно небольшим количеством переходов между состояниями, и поэтому он имеет лишь небольшое количество событий. В этом случае будут эффективными запрос из хранилища событий и реконструкция текущего состояния агрегата Заказ. Однако некоторые агрегаты имеют большое количество событий. Например, агрегат Клиент может потенциально иметь множество событий Credit Reserved. Со временем их загрузка и обработка стала бы неэффективной.

Общим решением проблемы является периодическое сохранение снапшота состояния агрегата. Приложение восстанавливает состояние агрегата путем загрузки последнего снапшота и только тех событий, которые произошли с момента его создания. В терминах функционального программирования, снимок представляет собой первоначальное значение для fold/reduce. Если агрегат имеет простую, легко сериализуемую структуру, то снимок может быть, например, в формате JSON. Снимки более сложных агрегатов могут быть сделаны с помощью паттерна Memento.

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

6431b784ebe2488882dbbb92e5da2d0f.png

Сервис клиентов воссоздает состояние клиента, десериализуя JSON моментального снимка, а затем загружая и обрабатывая события со 104 по 106.

Реализация Event Sourcing


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

Есть несколько различных способов реализации хранилища событий. Одним из них является создание своего собственного event sourcing-фреймворка. Вы можете, например, сохранять события в РСУБД. Это простой, хотя и низко-производительный способ публикации событий. Подписчики просто периодически опрашивают таблицу EVENTS для получения новых событий.

Другой вариант: использовать специальное хранилище событий, которое, как правило, предоставляет богатый набор функций, более высокую производительность и масштабируемость. Грег Янг (Greg Young), пионер в event sourcing, создал на основе .NET хранилище событий с открытым исходным кодом под названием Event Store (https://geteventstore.com). Компания Lightbend, ранее известная как Typesafe, разработала микросервисный фреймворк Lagom, основанный на event sourcing. Можно отметить и стартап Eventuate, имеющий event sourcing-фреймворк, который доступен в качестве облачного сервиса, является проектом с открытым исходным кодом и использует Kafka и РСУБД.

Преимущества и недостатки Event Sourcing


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

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

Благодаря сохранению события, а не самого агрегата, Event sourcing обычно позволяет избегать проблемы «потери соответствия» (impedance mismatch). События, как правило, имеют простую, легко-сериализуемую структуру. Посредством сериализации сервис может сделать снимок состояния сложного агрегата. Паттерн Memento добавляет уровень косвенности между агрегатом и его сериализованным представлением.

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

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

Еще одной проблемой event sourcing является то, что схема событий (и снапшотов!) будет развиваться с течением времени. Поскольку события сохраняются навсегда, то для реконструкции состояния агрегата сервису может понадобиться обработать события, соответствующие нескольким различным версиям схемы. Один из способов упростить сервис — заставить event sourcing-фреймворк приводить все события к последней версии схемы, когда он загружает их из хранилища событий. В результате сервису нужно будет только обработать только последнюю версию событий.

Другим недостатком event sourcing является то, что запрос в хранилище событий может быть сам по себе сложной задачей. Представим, что вам нужно найти клиентов, достойных выдачи кредита, имеющих низкий кредитный лимит. Вы не можете просто написать SELECT * FROM CUSTOMER WHERE CREDIT_LIMIT ?… Столбца, содержащего кредитный лимит, не существует. Вместо этого вы должны использовать более сложный и потенциально неэффективный запрос, содержащий вложенные SELECT для вычисления кредитного лимита путем обработки события, устанавливающие начальный кредитный лимит и затем меняющие его. Что еще хуже, NoSQL-хранилища событий, как правило, поддерживают поиск только по первичному ключу. Поэтому вы должны реализовывать запросы с помощью подхода Command Query Responsibility Segregation (CQRS).

Реализация запросов с помощью CQRS


Event sourcing является одним из основных препятствий для реализации эффективных запросов в микросервисной архитектуре. Однако это не единственная проблема. Рассмотрим, например, SQL-запрос, который находит новых клиентов, сделавших дорогие заказы.
SELECT *
FROM CUSTOMER c, ORDER o
WHERE
  c.id = o.ID
   AND o.ORDER_TOTAL > 100000
   AND o.STATE = 'SHIPPED'
   AND c.CREATION_DATE > ?

В микросервисной архитектуре вы не можете соединить в одном запросе таблицы CUSTOMER и ORDER. Каждая таблица принадлежит своему сервису и доступна только через API этого сервиса. Вы не можете писать традиционные запросы, которые соединяют (join) таблицы, принадлежащие различным сервисам. Event sourcing усугубляет ситуацию, мешая писать простые прямые запросы. Давайте посмотрим на способ реализации запросов в микросервисной архитектуре.

Использование CQRS


Хорошим способом реализации запросов является использование архитектурного паттерна, известного как Command Query Responsibility Segregation (CQRS). Приложение разбивается на две части:
  1. командная часть обрабатывает команды (например, HTTP POST, PUT, DELETE) для создания, обновления и удаления агрегатов. Эти агрегаты, конечно же, реализованы с использованием Event sourcing.
  2. запросная часть приложения обрабатывает запросы (например, HTTP GET), запрашивая один или несколько материализованных представлений (materialized views) агрегатов. Запросная часть поддерживает представления синхронизированными с агрегатами, подписавшись на события, публикуемые командной частью.

В зависимости от требований, запросная часть приложения может использовать одну или несколько следующих баз данных:
Если вам нужно Тогда используйте Например
Поиск JSON-объектов по первичному ключу Документоориентированную базу данных, например, MongoGB, или хранилище данных типа «ключ — значение», например, Redis. Реализация истории заказов с помощью MongoDB документа клиента, содержащего все его заказы.
Обычный поиск JSON-объектов Документоориентированную базу данных, например, MongoGB. Реализация представления для клиентов с помощью MongoDB.
Полнотекстовый поиск Движок для полнотекстового поиска, например, Elasticsearch. Реализация полнотекстового поиска в заказах с помощью Elasticsearch документов для каждого заказа.
Графовый запрос Графовая система управления базами данных, например, Neo4j. Реализация системы обнаружения мошенничества с помощью графа клиентов, заказов и других данных.
Традиционные SQL-запросы РСУБД Стандартные бизнес-отчеты и аналитика.

Во многих отношениях, CQRS — это более общий событийно-ориентированный вариант широко применяемого подхода использования РСУБД в качестве хранилища данных и поискового движка для полнотекстового поиска (вроде Elasticsearch). CQRS использует более широкий диапазон типов баз данных, а не полнотекстовые поисковые движки. Кроме того, за счет подписки на события он обновляет представления запросной части приложения почти в реальном времени.

На следующей иллюстрации показана схема CQRS применительно к интернет-магазину.
Сервисы Customer Service и Order Service входят в командную часть приложения. Они предоставляют API-интерфейсы для создания и обновления клиентов и заказов. Сервис Customer View Service входит в запросную часть. Он предоставляет API для получения данных о клиентах с помощью запросов.

e1154f161ad642efa63b58314391c7b3.png

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

Преимущества и недостатки CQRS


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

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

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

Резюме


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

Еще одной проблемой в микросервисной архитектуре представляют собой запросы. В них часто необходимо объединять данные, принадлежащие нескольким сервисам. Однако больше нельзя так просто использовать соединения (joins), так как данные являются приватными для каждого сервиса. Еvent sourcing еще более затрудняет эффективную реализацию запросов, так как текущее состояние не хранится само по себе. Решение заключается в использовании CQRS и поддержании в актуальном состоянии одного или более материализованного представления, к которому легко можно делать запросы.

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

© Habrahabr.ru