Как предотвратить аномалии в распределенных транзакциях с помощью шаблона Saga.

Одной из важных проблем построения микросервисной архитектуры является обеспечение механизма распределенных транзакций, включающего несколько независимых сервисов. Одно из решений - использовать Saga Pattern, и вы можете прочитать эту статью, чтобы лучше понять, как это работает; ссылка здесь.

Когда мы создаем монолитное приложение с реляционной базой данных в качестве уровня сохраняемости, мы полагаемся на механизм транзакций на уровне БД со свойствами ACID. В случае использования Saga Pattern мы не могли обеспечить изоляцию для всех сервисов, и все локальные изменения, сделанные одной сагой, видны для другой, несмотря на то, что она не еще не закончено. По этой причине мы можем столкнуться с разными аномалиями. Например, одна сага может быть перезаписана без чтения изменений другой саги; или одна сага читает частичные обновления, сделанные другой сагой.

Давайте рассмотрим некоторые из этих аномалий на конкретном примере. Представим, что у нас есть 3 службы: служба поездок, служба полетов, служба оплаты (рисунок 1).

Нам необходимо реализовать транзакцию, которая позволяет пользователям создавать / отменять поездку, которая включает в себя бронирование авиабилетов и онлайн-платежи с помощью карты. Все действия должны происходить в рамках одной транзакции. Мы решили использовать здесь паттерн «Сага».

Сценарий 1

Давайте представим, как в этом приложении может появиться первая аномалия. В приложении есть две отдельные саги; один - СОЗДАТЬ ПОЕЗДКУ, другой - ОТМЕНА ПОЕЗДКИ.

Представим, что пользователь запрашивает создание новой поездки, а приложение запускает СОЗДАТЬ САГУ ПОЕЗДКИ. Чуть позже пользователь решил отменить поездку, но по какой-то причине CREATE TRIP SAGA еще не завершила свою работу. Таким образом, приложение инициирует CANCEL TRIP SAGA соответственно.

Давайте сосредоточимся только на службе Trip Service, где CREATE TRIP SAGA отвечает за создание объекта поездки с состоянием PENDING на шаге 1 и измените состояние на APPROVED на шаге 4 (см. рисунок 2). Аналогичным образом, CANCEL TRIP SAGA изменяет состояние Trip Entity на CANCEL на шаге 1. Как вы, возможно, уже заметили, мы можем реализовать эти две саги таким образом, чтобы эта saga перезаписывает промежуточное состояние объекта Trip из другой еще не завершенной саги.

Здесь CREATE TRIP SAGA игнорирует обновление, сделанное CANCEL TRIP SAGA.

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

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

В нашем случае CREATE TRIP SAGA изначально создает объект поездки с состоянием PENDING. Когда CANCEL TRIP SAGA начинает свою работу, он должен проверить, безопасно ли его завершить, прежде чем изменять состояние в CANCEL. Если объект находится в состоянии PENDING, в этом случае у нас есть несколько вариантов, чтобы избежать аномалий:

  1. CANCEL TRIP SAGA должен ждать, пока объект не изменит свое состояние с PENDING на APPROVED, и только после этого начинает свою работу.
  2. CANCEL TRIP SAGA проверяет, имеет ли объект поездки УТВЕРЖДЕНИЕ. А если нет - сразу ответил пользователю, что эту операцию нельзя завершить прямо сейчас, и предложить пользователю сделать это позже - это проще реализовать, чем первый предложенный вариант (рисунок 3).

Сценарий 2

Давайте посмотрим на Платежную службу и предположим, что мы используем MongoDB. У этого сервиса есть коллекция пользователей и их балансы. Вот как мы могли бы уменьшить этот баланс, необходимый для CREATE TRIP SAGA (псевдокода):

const user = await UserModel.findById(userId);
user.balance = user.balance - ticketCost;
await UserModel.findByIdAndUpdate(userId, user);

Пока все хорошо, но что может произойти, если CREATE TRIP SAGA и CANCEL TRIP SAGA пересекаются? Опять же, давайте предположим, что пользователь просит создать новую поездку, но позже отменяет эту поездку (рисунок 4).

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 -= ticketCost;
await user.save(); // if another process changed this user entity     
                   // earlier, mongoose throws error here

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

Вывод:

Когда мы реализуем распределенную транзакцию, мы должны учитывать, что это отсутствие свойства Isolation. Это когда промежуточные фиксации видны для других транзакций, пока транзакция еще не завершена. Несмотря на это, мы можем смягчить различные аномалии, которые могут возникнуть, применив следующие контрмеры:

  • Семантический замок
  • Коммуникативные обновления
  • Повышенные значения (Оптимистическая блокировка в автономном режиме)

Учить больше