Если вы читали книгу Криса Ричардсона «Микросервисы: паттерны разработки и рефакторинга», то знаете, что существует больше 20 паттернов, использующихся в микросервисной архитектуре. Все они делятся на 5 больших групп: decomposition patterns, integration patterns, database patterns, observability patterns и cross-cutting concern patterns. В статье речь больше пойдет о паттерне из группы database pattern.
Итак, представим, что пользователь интернет-магазина желает сделать заказ. Архитектура системы магазина — микросервисная, у каждого микросервиса своя база данных. Пользователю удастся сделать заказ, если будут выполняться следующие бизнес-требования: товар будет доступен на складе в нужном количестве, пользователю будет хватать денег для его оплаты. Но что произойдет, если хотя бы одно из этих требований нарушится?
В этом случае встаёт вопрос о том, как же обеспечить согласованность данных между всеми сервисами, которые отвечают за реализацию заказа товара. В случае с монолитом все предельно понятно — у приложения одна БД и откат её изменений организовать достаточно просто. А что делать с микросервисной архитектурой? Ответ будет такой — необходимо каждую бизнес-транзакцию реализовать как сагу.
Что такое Сага?
Сага предназначена для управления распределенными транзакциями в микросервисной архитектуре. Представляет собой набор локальных транзакций, каждая из которых обновляет базу данных и публикует сообщение в очередь, тем самым запуская следующую локальную транзакцию.
Если в какой-то момент происходит нарушение бизнес-правил, то запускается ряд компенсирующих транзакций, каждая из которых откатывает уже сделанные на предыдущем шаге изменения.
Примечание. Транзакция — это логически атомарная единица работы, которая может охватывать несколько запросов к базе данных. Каждая транзакция должна удовлетворять ACID-требованиям, одно из которых как раз сonsistency (консистентность) — транзакция, достигшая своего нормального завершения, фиксирующая свои результаты, сохраняющая согласованность данных.
На примере заказа товара в интернет-магазине процесс может быть следующим:
Пользователь отправляет запрос на заказ товара.
Происходит бронирование товара на складе.
С пользователя списываются деньги за покупку.
Если денег для оплаты вдруг не хватило, то происходит полная отмена оплаты (с сохранением всех денег пользователя) и бронирования товара, с сохранением прежнего (до совершения заказа) количества товара на складе.
Если вам уже «понравилась» работа паттерна, то перейдём к способам координации саг — их два: хореография и оркестрация.
Как работает хореография?
Если говорить о саге, основанной на хореографии, то основная идея заключается в отсутствии центра координации — каждый сервис создаёт и слушает события другого сервиса и на их основе уже решает предпринимать какие-либо действия или нет.
Рассмотрим возможную реализацию саги, основанной на хореографии, на нашем примере с заказом товара в интернет-магазине. В нашей системе заказа существует 3 микросервиса:
order-service — принимает заказ при обращении к его API по эндпоинту POST /order;
inventory-service — проверяет наличие конкретного товара на складе;
payment-service — проверяет количество денег у пользователя.
Алгоритм совершения заказа выглядит следующим образом:
1. В момент принятия заказа происходит обращение к эндпоинту POST /order микросервиса order-service. Сервис изменяет статус заказа на CREATED, добавляет заказ в базу данных и публикует сообщение в очередь orders.
2. Из очереди orders читают сообщения сразу два сервиса: payment-service и inventory-service.
3. inventory-service проверяет доступность товара на складе.
Если товар, который заказывает пользователь, доступен на складе в необходимом количестве, то inventory-service обновляет базу данных, уменьшая количество доступного товара, а также изменяет статус заказа на RESERVED.
Если же товара на складе не хватает, то статус заказа меняется на REJECTED. После этого публикуется сообщение в очередь inventory.
4. payment-service, в свою очередь, проверяет, достаточно ли денег у пользователя для оплаты товара.
Если достаточно, то происходит обновление БД , эмулируя покупку товара (уменьшая количество денег у конкретного пользователя), обновляет статус заказа на на RESERVED.
Если денег нет, то статус заказа меняется на REJECTED. После этого происходит публикация сообщения в очередь payments.
5. order-service читает сообщения из очередей inventory и payments.
Если из каждой из этих очередей приходит сообщение со статусом заказа RESERVED, то order-service завершает сагу, обновляя статус заказа в COMPLETED, и процесс заказа считается успешно завершенным.
Если хотя бы из одной очереди пришло сообщение со статусом заказа REJECTED, то order-service изменяет cтатуc заказа на CANCELED и запускает набор компенсирующих транзакция, публикуя сообщение в очередь orders, тем самым запуская rollback всех сделанных ранее изменений.
Как работает оркестрация?
При оркестрации ответственность за принятие решений в саге и последовательность бизнес-логики берет на себя оркестратор. Если смотреть, как устроена оркестрация на основе выше изложенного примера с заказом товара, в систему добавляется дополнительный сервис, orchestrator-service, который, исходя из названия, и выполняет роль оркестратора.
В этом случае алгоритм совершения заказа будет выглядеть так:
При приеме заказа все также происходит обращение к эндпоинту POST /order микросервиса order-service. Сервис обновляет статус заказа на CREATED, добавляет заказ в базу данных. Вызывает orchestrator-service.
orchestrator-service вызывает сначала inventory-service, который занимается проверкой наличия товара на складе.
Если товар доступен на складе, то inventory-service по-прежнему обновляет статус заказа в статус RESERVED и возвращает соответствующий ответ оркестратору.
Если inventory-service подтвердил доступность товара, то оркестратор вызывает payment-service.
Если у пользователя хватает денег для оплаты товара, то payment-service обновляет статус заказа в статус RESERVED и возвращает соответствующий ответ оркестратору.
Оркестратор возвращает финальный ответ сервису order-service, который обновляет статус заказа в COMPLETED и тем самым завершает сагу.
В случае, если в какой-то момент бизнес-требования нарушились (в order-service вернулся заказ со статусом REJECTED), то оркестратор запускает откат всех уже сделанных изменений и финальный статус заказа будет обновлен в CANCELLED.
Оркестрация в деталях
А теперь посмотрим детальнее, как можно реализовать сагу, основанную на оркестрации (код приложения можно посмотреть здесь).
Прежде, чем рассматривать работу приложения, стоит немного поговорить о его архитектуре.
В качестве оркестратора в примере выступает Zeebe (BPMN-движок), входящий в состав Camunda Platform 8. Использование коробочного решения, Zeebe, вместо написания собственного оркестратора экономит время.
Сам кластер Zeebe содержит Gateway, который является точкой входа в кластер, который также содержит в себе Broker’ы.
Также имеется ElasticSearch, который участвует в процессинге данных, а также поставляет данные в приложение для мониторинга (Camunda Operate).
Camunda Operate позволяет отследить, успешно ли завершился бизнес-процесс или же нет, где возникла ошибка и почему.
В качестве базы данных используется MongoDB.
Чтобы инициировать начало заказа товара используется Order API.
Также есть два приложения с воркерами — payment и inventory, но о них чуть позже.
Как уже было сказано ранее, Zeebe используется в качестве оркестратора, он не исполняет никакой бизнес-логики. Чтобы вся система в целом начала исполнять бизнес-логику, необходимо для начала нарисовать BPMN-схему в Camunda Modeler, который также, как и Zeebe, является частью Camunda Platform 8. В нашем случае с заказом товара будет достаточно двух service task’ов: Booking product, отвечающего за бронирование товара, и Retrieving payment, отвечающий за его оплату.
В случае нарушения бизнес-требований на каждый service task навешан обработчик ошибок, запускающий компенсационные транзакции, которые отрабатываются в service task’ах: Cancel retrieving payment, отменяющей оплату товара, и Unbooking product, отменяющей бронирование товара.
Каждый service task представляет собой определенный worker в коде приложения. Именно код worker’ов дает возможность исполнить необходимую бизнес-логику. Приложение может состоять либо из одного, либо из нескольких воркеров. В случае примера с заказом товара, worker’а объединены в два приложения: payment и inventory, согласно логике функционала.
Worker’ы общаются с кластером Zeebe через Gateway посредством протокола GRPC. Происходит это следующим образом. Worker через определенные промежутки времени запрашивает через Zeebe Gateway новые job’ы, которые хранятся в очереди broker’а, исполняют бизнес-логику и возвращают результат исполнения job’ы назад в broker (это валидно и для успешно завершенной job’ы, и для job’ы, которая закончила свое исполнение ошибкой).
Как же понять, что очередной метод в коде приложения это worker? Да очень просто! Над методом-worker’ом стоит аннотация @ZeebeWorker, предоставляемая Spring Zeebe starter’ом. У этой аннотации есть такой параметр, как type. Именно по нему соотносятся worker’а и service task’и (у них этот параметр можно задать в Camunda Modeler).В методе-worker’е программируется вся нужная бизнес-логика.
@ZeebeWorker(type = "BookProduct")
public void bookProduct(JobClient client, ActivatedJob job) throws JsonProcessingException {
ZeebeRequest zeebeRequest = objectMapper.readValue(job.getVariables(), ZeebeRequest.class);
try {
zeebeRequest = inventoryService.bookProduct(zeebeRequest);
client.newCompleteCommand(job.getKey())
.variables(zeebeRequest)
.send()
.join();
} catch (BusinessException ex) {
client.newThrowErrorCommand(job.getKey())
.errorCode(NOT_ENOUGH_PRODUCT_ERROR)
.send()
.join();
}
}
Хватит теории, перейдем к практике.
Запускаем оркестрацию
Для начала запустим инфраструктуру кластера, собранную с помощью docker compose, запустив следующую команду:
docker compose up -d
Также необходимо запустить API и приложения с воркерами.
Проверим, что в базе данных у нас есть три коллекции: order, product, user (для просмотра коллекций используется Studio3T).
Order хранит в себе все заказы (в том числе и отменённые). Так как пока не было сформировано ни единого заказа, коллекция order пуста.
Коллекция product содержит продукты (их ID), доступные для заказа, и их количество.
User хранит в себе пользователей интернет-магазина (а точнее их ID) и баланс в y.е. каждого из них.
Теперь можно инициировать заказ товара.
Заказ #1. Допустим, пользователь c ID 1 хочет заказать 2 единицы товара с ID 2 по цене 2 у.e. Для этого из терминала достаточно сделать следующий запрос:
curl --location --request POST 'http://localhost:8080/order' \
--header 'Content-Type: application/json' \
--data '{
"userId": 1,
"productId": 2,
"price": 2,
"productCount": 2
}'
В результате данного запроса получаем ответ, который говорит об успешном завершении заказа товара (статус заказа COMPLETED).
{"id":"64ef21272ddee71d314261a1","userId":1,"productId":2,"price":2,"productCount":2,"orderStatus":"COMPLETED"}
Если посмотреть в Camunda Operate, перейдя по адресу https://127.0.0.1:8081, то также можно увидеть, что процесс заказа завершился успешно (отработали два service task’а: Booking product и Retrieving payment).
Заглянем в коллекции в MongoDB. В коллекции order можно увидеть первый успешно сформированный заказ.
Товара с ID 2 стало меньше на складе на 2 единицы (коллекция product).
У первого пользователя (c ID 1) денег стало меньше на 4 у.е. (коллекция user).
Заказ #2. Рассмотрим другой пример. Первый пользователь хочет заказать 25 единиц первого товара по цене 2 у.e.
В этом случае заказ не будет успешно создан (статус заказа CANCELLED), так как нарушились бизнес-требования, не хватило нужного количества первого продукта на складе:
{"id":"64f4e899ddd8b56542800d71","userId":1,"productId":1,"price":2,"productCount":25,"orderStatus":"CANCELLED"}
Смотрим в Camunda Operate и видим, что сработала компенсирующая транзакция в виде service task’а, который называется Unbooking product, отвечающий за отмену бронирования товара.
Заглянем в коллекции в MongoDB. В коллекции order присутствуют уже два заказа (один успешный, второй отмененный).
Деньги с первого пользователя не списаны (коллекция user).
При этом количество первого продукта осталось неизменно (коллекция product).
Заказ #3. И, наконец, третий пример. Второй пользователь решил заказать второй товар по цене 2 у. e. в количестве 20 штук.
curl --location --request POST 'http://localhost:8080/order' \
--header 'Content-Type: application/json' \
--data '{
"userId": 2,
"productId": 2,
"price": 2,
"productCount": 20
}'
И снова попытка заказа товара будет не успешной (статус заказа CANCELLED) по причине того, что второму пользователю не хватило денег для оплаты покупки.
{"id":"64f4ef34ddd8b56542800d72","userId":2,"productId":2,"price":2,"productCount":20,"orderStatus":"CANCELLED"}
В Camunda Operate в этот раз видим, что сработали две компенсирующий транзакции - это service task’и Cancel retrieving payment, отвечающий за отмену оплаты товара, и Unbooking product, отвечающий за отмену бронирования товара.
Идем смотреть коллекции MongoDB. Видим еще один неудачный заказ в коллекции order.
Так как попытка заказа товара завершилась неудачей, со второго пользователя не были списаны деньги (коллекция users), и второй товар не был приобретен (коллекция product).
Вот так работает сага, основанная на оркестрации.
Как вам паттерн? Реализовали ли бы его у себя на проектах? Что думаете?
Рекомендуем почитать.
Монолог про отказоустойчивость микросервисных приложений, или Что может пойти не так?
BDUI аналитика, или Почему нельзя просто взять и отправить значения динамических полей в трекер
Как в 3 раза снизить затраты на отказоустойчивую инфраструктуру, переехав с Hazelcast на Redis
Также подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.
dididididi
Надо давать читать всём, кто бьёт монолиты на микросервичсв. И в конце каждого абзаца писать, а в монолите это решалось бы одной аннотацией.