Если вы читали книгу Криса Ричардсона «Микросервисы: паттерны разработки и рефакторинга», то знаете, что существует больше 20 паттернов, использующихся в микросервисной архитектуре. Все они делятся на 5 больших групп: decomposition patterns, integration patterns, database patterns, observability patterns и cross-cutting concern patterns. В статье речь больше пойдет о паттерне из группы database pattern. 

Рис.1. Паттерны, использующиеся в микросервисной архитектуре
Рис.1. Паттерны, использующиеся в микросервисной архитектуре

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

В этом случае встаёт вопрос о том, как же обеспечить согласованность данных между всеми сервисами, которые отвечают за реализацию заказа товара. В случае с монолитом все предельно понятно — у приложения одна БД и откат её изменений организовать достаточно просто. А что делать с микросервисной архитектурой? Ответ будет такой — необходимо каждую бизнес-транзакцию реализовать как сагу. 

Что такое Сага?

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

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

Рис.2. Схема реализация саги
Рис.2. Схема реализация саги

Примечание. Транзакция — это логически атомарная единица работы, которая может охватывать несколько запросов к базе данных. Каждая транзакция должна удовлетворять ACID-требованиям, одно из которых как раз сonsistency (консистентность) — транзакция, достигшая своего нормального завершения, фиксирующая свои результаты, сохраняющая согласованность данных.

На примере заказа товара в интернет-магазине процесс может быть следующим:

  • Пользователь отправляет запрос на заказ товара. 

  • Происходит бронирование товара на складе.

  • С пользователя списываются деньги за покупку.

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

Если вам уже «понравилась» работа паттерна, то перейдём к способам координации саг — их два: хореография и оркестрация.

Как работает хореография?

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

Рассмотрим возможную реализацию саги, основанной на хореографии, на нашем примере с заказом товара в интернет-магазине. В нашей системе заказа существует 3 микросервиса:

  1. order-service — принимает заказ при обращении к его API по эндпоинту POST /order;

  2. inventory-service — проверяет наличие конкретного товара на складе;

  3. payment-service — проверяет количество денег у пользователя.

Рис.3. Сага, основанная на хореографии
Рис.3. Сага, основанная на хореографии

Алгоритм совершения заказа выглядит следующим образом:

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, который, исходя из названия, и выполняет роль оркестратора.

Рис.4. Сага, основанная на оркестрации
Рис.4. Сага, основанная на оркестрации

В этом случае алгоритм совершения заказа будет выглядеть так:

  1. При приеме заказа все также происходит обращение к эндпоинту POST /order микросервиса order-service. Сервис обновляет статус заказа на CREATED, добавляет заказ в базу данных. Вызывает orchestrator-service.

  2. orchestrator-service вызывает сначала inventory-service, который занимается проверкой наличия товара на складе.

  3. Если товар доступен на складе, то inventory-service по-прежнему обновляет статус заказа в статус RESERVED и возвращает соответствующий ответ оркестратору.

  4. Если inventory-service подтвердил доступность товара, то оркестратор вызывает payment-service.

  5. Если у пользователя хватает денег для оплаты товара, то payment-service обновляет статус заказа в статус RESERVED и возвращает соответствующий ответ оркестратору.

  6. Оркестратор возвращает финальный ответ сервису order-service, который обновляет статус заказа в COMPLETED и тем самым завершает сагу.

  7. В случае, если в какой-то момент бизнес-требования нарушились (в order-service вернулся заказ со статусом REJECTED), то оркестратор запускает откат всех уже сделанных изменений и финальный статус заказа будет обновлен в CANCELLED.

Оркестрация в деталях

А теперь посмотрим детальнее, как можно реализовать сагу, основанную на оркестрации (код приложения можно посмотреть здесь).

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

  1. В качестве оркестратора в примере выступает Zeebe (BPMN-движок), входящий в состав Camunda Platform 8. Использование коробочного решения, Zeebe, вместо написания собственного оркестратора экономит время.

  2. Сам кластер Zeebe содержит Gateway, который является точкой входа в кластер, который также содержит в себе Broker’ы.

  3. Также имеется ElasticSearch, который участвует в процессинге данных, а также поставляет данные в приложение для мониторинга (Camunda Operate).

  4. Camunda Operate позволяет отследить, успешно ли завершился бизнес-процесс или же нет, где возникла ошибка и почему.

  5. В качестве базы данных используется MongoDB.

  6. Чтобы инициировать начало заказа товара используется Order API.

  7. Также есть два приложения с воркерами — payment и inventory, но о них чуть позже.

Рис. 5. Архитектура кластера
Рис. 5. Архитектура кластера

Как уже было сказано ранее, Zeebe используется в качестве оркестратора, он не исполняет никакой бизнес-логики. Чтобы вся система в целом начала исполнять бизнес-логику, необходимо для начала нарисовать BPMN-схему в Camunda Modeler, который также, как и Zeebe, является частью Camunda Platform 8. В нашем случае с заказом товара будет достаточно двух service task’ов: Booking product, отвечающего за бронирование товара, и Retrieving payment, отвечающий за его оплату.

Рис. 6. BPMN-схема процесса заказа товара
Рис. 6. BPMN-схема процесса заказа товара

В случае нарушения бизнес-требований на каждый 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 пуста.

Рис. 7. Пустая коллекция order в MongoDB
Рис. 7. Пустая коллекция order в MongoDB

Коллекция product содержит продукты (их ID), доступные для заказа, и их количество.

Рис. 8. Коллекция product в MongoDB
Рис. 8. Коллекция product в MongoDB

User хранит в себе пользователей интернет-магазина (а точнее их ID) и баланс в y.е. каждого из них.

Рис. 9. Коллекция user в MongoDB
Рис. 9. Коллекция user в MongoDB

Теперь можно инициировать заказ товара. 

Заказ #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).

Рис. 10. Успешно завершенный процесс заказа товара
Рис. 10. Успешно завершенный процесс заказа товара

Заглянем в коллекции в MongoDB. В коллекции order можно увидеть первый успешно сформированный заказ.

Рис. 11. Коллекция order с первым успешным заказом
Рис. 11. Коллекция order с первым успешным заказом

Товара с ID 2 стало меньше на складе на 2 единицы (коллекция product).

Рис. 12. Коллекция product с измененным количеством второго продукта
Рис. 12. Коллекция product с измененным количеством второго продукта

У первого пользователя (c ID 1) денег стало меньше на 4 у.е. (коллекция user).

Рис. 13:  Коллекция user с измененным балансом первого пользователя
Рис. 13: Коллекция user с измененным балансом первого пользователя

Заказ #2. Рассмотрим другой пример. Первый пользователь хочет заказать 25 единиц первого товара по цене 2 у.e.

В этом случае заказ не будет успешно создан (статус заказа CANCELLED), так как нарушились бизнес-требования, не хватило нужного количества первого продукта на складе:

{"id":"64f4e899ddd8b56542800d71","userId":1,"productId":1,"price":2,"productCount":25,"orderStatus":"CANCELLED"}

Смотрим в Camunda Operate и видим, что сработала компенсирующая транзакция в виде service task’а, который называется Unbooking product, отвечающий за отмену бронирования товара.

Рис. 14.  Процесс отмены заказа из-за недостатка товара на складе
Рис. 14. Процесс отмены заказа из-за недостатка товара на складе

Заглянем в коллекции в MongoDB. В коллекции order присутствуют уже два заказа (один успешный, второй отмененный).

Рис. 15.  Коллекция order со вторым отмененным заказом
Рис. 15. Коллекция order со вторым отмененным заказом

Деньги с первого пользователя не списаны (коллекция user).

Рис. 16.  Коллекция user с неизменившимся балансом первого пользователя
Рис. 16. Коллекция user с неизменившимся балансом первого пользователя

При этом количество первого продукта осталось неизменно (коллекция product).

Рис. 17.  Коллекция product с неизменившимся количеством первого товара
Рис. 17. Коллекция 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, отвечающий за отмену бронирования товара. 

Рис. 18. Процесс отмены заказа по причине недостатка денег у пользователя
Рис. 18. Процесс отмены заказа по причине недостатка денег у пользователя

Идем смотреть коллекции MongoDB. Видим еще один неудачный заказ в коллекции order.

Рис. 19. Коллекция order с третьим отменным заказом
Рис. 19. Коллекция order с третьим отменным заказом

Так как попытка заказа товара завершилась неудачей,  со второго пользователя не были списаны деньги (коллекция users), и второй товар не был приобретен (коллекция product).

Рис. 20.  Коллекция user с неизменившимся балансом второго пользователя
Рис. 20. Коллекция user с неизменившимся балансом второго пользователя
Рис. 21:  Коллекция product с неизменившимся количеством второго товара
Рис. 21: Коллекция product с неизменившимся количеством второго товара

Вот так работает сага, основанная на оркестрации.

Как вам паттерн? Реализовали ли бы его у себя на проектах? Что  думаете?


Рекомендуем почитать.

Также подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.

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


  1. dididididi
    08.09.2023 06:23
    +10

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