Давайте узнаем о том, как предотвратить аномалии в сагах. Например, одна сага может быть перезаписана другой сагой без чтения изменений, или одна сага читает частичные обновления, сделанные другой сагой. Но, прежде чем начать, освежим знания о терминах:
Представим, что у нас есть 3 сервиса: Trip Service, Flight Service, Payment Service:

Приложение имеет две отдельные саги:
Однако, по какой-то причине CREATE TRIP SAGA еще не завершена, но пользователь инициирует CANCEL TRIP SAGA.

Как видите, одна сага перезаписывает промежуточное состояние объекта Trip из другой саги, которая еще не закончена (CREATE TRIP SAGA игнорирует обновление, сделанное CANCEL TRIP SAGA).
Мы можем избежать этой аномалии, применив контрмеру Semantic Lock .
Семантическая блокировка — любой объект, затронутый в рамках саги, должен иметь свой жизненный цикл состояния ( PENDING ). Этот флаг означает, что запись не зафиксирована и может быть изменена позже.
В нашем случае CREATE TRIP SAGA изначально создает Trip Entity с состоянием PENDING. Когда CANCEL TRIP SAGA начинает свою работу, она должна проверить, безопасно ли завершить ее, прежде чем изменить состояние в CANCEL.

В итоге у нас есть выбор из двух реализаций:
Тут важно помнить, что в Saga отсутствует свойство Isolation (см. ACID ), а значит все промежуточные фиксации видны в рамках других транзакций (даже в простых запросах, например от пользователя). Эту проблему можно преодолеть с помощью проектирования базы данных, в таблице которых, для одной сущности/агрегата будет по 2 записи:
Таким образом в разные моменты времени мы будем иметь следующие ситуации, начнем с запроса создание:
Возникает запрос на обновление:
Запрос на обновление завершился:
Возникает запрос на обновление:
Запрос на обновление завершился:
и т.д.
Давайте посмотрим на Payment Service и предположим, что мы используем MongoDB. Этот сервис имеет коллекцию пользователей и их баланса. Вот как мы могли бы уменьшить этот баланс, необходимый для CREATE TRIP SAGA (псевдокод):
const user = await UserModel.findById(userId);
user.balance = user.balance - стоимость билета;
await UserModel.findByIdAndUpdate(userId, user);Пока все хорошо, но что может произойти, когда CREATE TRIP SAGA и CANCEL TRIP SAGA пересекаются? Опять же, давайте предположим, что пользователь просит создать новую поездку, но позже отменяет эту поездку.

CREATE TRIP SAGA извлекает элемент пользователя из БД, но не успевает прошить новое состояние баланса пользователя с уменьшенным значением, в то время как CANCEL TRIP SAGA начинает свое завершение. Итак, как видите, у нас есть аномалия, довольно критическая для бизнеса.
Здесь мы можем применить контрмеру, называемую коммуникативное обновление. Данная контрмера говорит о том, что операций обновления проектируется таким образом, чтобы параллельные операции можно было выполнить в любом порядке.
Это довольно легко сделать с MongoDB:
// CREATE TRIP SAGA - списываем деньги со счета пользователя
const user = await UserModel.findByIdAndUpdate(
userId,
{ $inc: {balance: ticketCost} }
)
// CANCEL TRIP SAGA - зачисляем деньги на счет пользователя
const user = await UserModel.findByIdAndUpdate(
userId,
{ $inc: {balance: -ticketCost} }
)Здесь обновление является атомарным, поскольку выбор и изменение происходят в одном и том же запросе:

Повторное чтение значения — предотвращение «грязного» чтения путем повторного считывания данных. Это позволяет убедиться в их неизменности перед тем, как их перезаписывать. Saga перечитывает запись перед ее обновлением и проверяет, что она не была изменена, и только после этого обновляет запись. Если запись была изменена, сага прерывает соревнование, и пользователь должен повторить его позже.
Например, если вы разработчик JS и используете mongoose библиотеку, эта библиотека создает дополнительное __v поле, связанное с каждым документом, и по умолчанию содержит внутреннюю версию документа. Это поле помогает гарантировать, что документ, который вы обновляете, не изменился между моментом его загрузки с помощью find() или findOne() И когда вы обновляете его с помощью save()(подробнее см. здесь )
const User = mongoose.model('User', Schema({
firstName: String,
lastName: String,
balance: Number
}, {optimisticConcurrency: true} ));
const user = await User.findOne({ _id });
user.balance -= стоимость билета;
await user.save(); // если другой процесс изменил эту сущность, мангуст выдает здесь ошибкуВот схема того, как это работает:

Мы убедились, что сага это распределенная транзакция в которой:
Вывод: семантическая блокировка + идемпотентный подход должны решать все известные мне проблемы, за исключением проблем с логикой (верной логикой запроектированной саги), а добавление или замещение этих двух контрмер, может породить проблемы.
Комментарии
Комментариев пока нет.