Saga контрмеры против аномалий

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

  1. повествование — последовательность локальных транзакций, координируемых путем обмена асинхронными сообщениями;
  2. компенсирующая транзакция — транзакция, которая отменяет обновление, сделанное локальной транзакцией;
  3. контрмера — методика проектирования, которая позволяет справиться с недостаточной изоляцией между повествованиями;
  4. семантическая блокировка — контрмера, которая устанавливает флаг в записи, обновляемой в ходе повествования;
  5. компенсируемая транзакция — транзакция, которую нужно компенсировать на случай, если одна из последующих транзакций в повествовании завершится неудачно;
  6. поворотная транзакция — транзакция, которая определяет успешность повествования. Если она пройдет удачно, повествование дойдет до конца;
  7. повторяемая транзакция — транзакция, которая идет за поворотной и гарантированно завершается успехом.

Представим, что у нас есть 3 сервиса: Trip Service, Flight Service, Payment Service:

Аномалия 1

Приложение имеет две отдельные саги:

  1. CREATE TRIP SAGA - пользователь создает новую поездку
  2. CANCEL TRIP SAGA - пользователь отменяет созданную поездку

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

Как видите, одна сага перезаписывает промежуточное состояние объекта Trip из другой саги, которая еще не закончена (CREATE TRIP SAGA игнорирует обновление, сделанное CANCEL TRIP SAGA).

Контрмера Semantic Lock

Мы можем избежать этой аномалии, применив контрмеру Semantic Lock .

Семантическая блокировка — любой объект, затронутый в рамках саги, должен иметь свой жизненный цикл состояния ( PENDING ). Этот флаг означает, что запись не зафиксирована и может быть изменена позже.

В нашем случае CREATE TRIP SAGA изначально создает Trip Entity с состоянием PENDING. Когда CANCEL TRIP SAGA начинает свою работу, она должна проверить, безопасно ли завершить ее, прежде чем изменить состояние в CANCEL.

В итоге у нас есть выбор из двух реализаций:

  1. CANCEL TRIP SAGA должна дождаться, пока сущность изменит свое состояние с PENDING на APPROVED, и только после этого начнет свою работу.
  2. CANCEL TRIP SAGA проверяет, была ли поездка APPROVED. Если APPROVED нет — то приложение сразу отвечает пользователю, что эту операцию нельзя выполнить прямо сейчас и предлагает сделать это позже (это проще реализовать, чем  вариант 1).

Тут важно помнить, что в Saga отсутствует свойство Isolation (см. ACID ), а значит все промежуточные фиксации видны в рамках других транзакций (даже в простых запросах, например от пользователя). Эту проблему можно преодолеть с помощью проектирования базы данных, в таблице которых, для одной сущности/агрегата будет по 2 записи:

  1. version a
  2. version b

Таким образом в разные моменты времени мы будем иметь следующие ситуации, начнем с запроса создание:

  1. PENDING version: 0

Возникает запрос на обновление:

  1. APPROVED version: 1
  2. PENDING version: 0

Запрос на обновление завершился:

  1. APPROVED version: 1
  2. APPROVED version: 2

Возникает запрос на обновление:

  1. PENDING version: 1
  2. APPROVED version: 2

Запрос на обновление завершился:

  1. APPROVED version: 3
  2. APPROVED version: 2

и т.д.

Аномалия 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 начинает свое завершение. Итак, как видите, у нас есть аномалия, довольно критическая для бизнеса.

Контрмера Communicative Updates

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

Это довольно легко сделать с 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} } 
)

Здесь обновление является атомарным, поскольку выбор и изменение происходят в одном и том же запросе:

Контрмера Reread Value (Optimistic Offline Lock)

Повторное чтение значения — предотвращение «грязного» чтения путем повторного считывания данных. Это позволяет убедиться в их неизменности перед тем, как их перезаписывать. 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(); // если другой процесс изменил эту сущность, мангуст выдает здесь ошибку

Вот схема того, как это работает:

Итог

Мы убедились, что сага это распределенная транзакция в которой:

  • отсутствует изоляция (в рамках саги целиком)
  • промежуточные коммиты видны другим сагам
  • запросы и компенсирующие запросы должны быть идемпотентными, поскольку одно и то же сообщение может быть доставлено более одного раза. Сколько бы раз ни посылался один и тот же идемпотентный запрос, результирующий результат должен быть одинаковым. Примером идемпотентной операции является операция UPDATE. Примером операции, которая НЕ является идемпотентной, является операция CREATE, которая каждый раз генерирует новый ID.
  • запросы могут прерываться, что вызывает компенсирующий запрос, компенсационные запросы НЕ МОГУТ прерваться, они должны выполняться до завершения, несмотря ни на что (субъективно, тут могут быть большие риски для бизнеса)

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

Источник: 1 - 2


11.09.2021 07:08

Комментарии

Комментариев пока нет.


Copyrights © 2007 - 2026 YaPro.Ru