Сага о консистентности данных

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Если вы читали книгу Криса Ричардсона «Микросервисы: паттерны разработки и рефакторинга», то знаете, что существует больше 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 с неизменившимся количеством второго товара

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

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


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

  • Монолог про отказоустойчивость микросервисных приложений, или Что может пойти не так?

  • Изучаю мок-сервера и пишу свой

  • Разбираемся в REST assured как новичок и не только

  • BDUI аналитика, или Почему нельзя просто взять и отправить значения динамических полей в трекер

  • Как в 3 раза снизить затраты на отказоустойчивую инфраструктуру, переехав с Hazelcast на Redis

  • Зачем нам Reactive и как его готовить

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

Источник: https://habr.com/ru/companies/alfa/articles/759224/


Интересные статьи

Интересные статьи

Появилась потребность собирать статистику постов из группы в контакте и затем проанализировать реакции подписчиков на конкретные посты. Если переформулировать на выходе стоит задача с заданной периоди...
Привет, Хабр! Не так давно прошла конференция, посвященная ситуации с СХД в России. За прошлый год продажи систем хранения данных упали в два раза. Более половины известных производителей ушло, новые ...
В этой статье рассматриваются преимущества такого редко используемого и, на мой взгляд, незаслуженно обойденного вниманием типа данных, как диапазон. Мы сначала спроектируем структуру базы для хранени...
ВведениеДля проведения проверки мне необходимо было установить адреса нескольких сотен объектов недвижимости. Проблема в том, что адреса были написаны в разных частях документов, документы имели разли...
Системы хранения данных, основанные на ДНК, могут стать выходом для человечества, которое генерирует все большие объемы информации. По сравнению со всеми прочими носителями у ДНК просто...