В своё время мне пришлось реализовать многофазную транзакцию, и я подумал что всем будет интересно послушать как это можно сделать. Может новички научиться чему. Может бывалые сравнят с собой. А сеньёры просто побалдеют.

Пусть нам необходимо перевести 250 тенге со счёта «Ассанжа» на счёт «Агента Ы».

У нас имеются в БД следующие записи в таблице счетов:

Таблица ACCOUNTS

ID:num

FIO:str

BALANCE, ₸

TR_ID:str

34

Ассанж

300

(null)

78

Агент Ы

7

(null)

Мы использовали БД MongoDB, но подобный подход подойдёт для любого СУБД. В MongoDB таблиц нет - там коллекции, но я буду говорить “таблицы” - потому что так привычнее.

Мы не можем просто взять…

...и в строке 34 баланс уменьшить на 250, а в строке 78 - увеличить на 250.

Потому что в любой момент, может выключиться свет, и это приведёт ко всякой пакости. Например, с одного счёта деньги спишуться, а на другой не запишутся. Деньги просто пропадут. Это непозволительно, от слова вообще.

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

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

Пусть это будет таблица:

Таблица TRANSACTIONS (или TR)

ID

FROM_ID

TO_ID

AMOUNT, ₸

FROM

BALANCE

TO

BALANCE

STATUS

172

34

78

250

(null)

(null)

blocking

Тут показано, что мы ещё вставили в эту таблицу новую запись для нашей операции. Тут в колонке STATUS храниться информация о том что сделано, и что ещё нужно сделать. Начальное состояние blocking - т.е. нам нужно всё заблокировать. Конечным состоянием будет finished - что операция закончена успешно. Может быть ещё какой-нибудь cancelled в случае если операция отменена, например, потому что недостаточно средств на счёте. Но это мы рассматривать не будем. Ну и соответственно если STATUS не finished и не cancelled то операция ещё не завершена, и её нужно завершить.

1. STATUS=’blocking’

Этот статус означает, что нам нужно заблокировать, счета с которыми мы будем сейчас работать.

Нужно придумать какую-нибудь логику блокировки, и заставить её всем соблюдать. Пусть будет так, что с записью в ACCOUNTS может работать только та транзакция, которая transactionId == ACCOUNTS.TR_ID. В нашем случае transactionId == 172.

Нам нужно заблокировать две записи ACCOUNTS.ID=[34, 78].

1.1. Отсортируем эти идентификаторы по алфавиту. Это нужно чтобы у нас было меньше попыток, или избежать мёртвых блокировок (читайте про lock ordering).

1.2. Блокируем одну запись. Для этого выполняем атомарно команду:

Присвоить ACCOUNTS.TR_ID:=172, если ID==34 и TR_ID==(null)

Эта команда будет работать атомарно — монга изначально всегда гарантировала атомарность в рамках одного документа. Также монга позволяет узнать, сколько записей изменила запущенная команда. И если эта команда ничего не изменила, то это значит, что заблокировать счёт не удалось. В этом случае либо отменяем транзакцию, либо ждём некоторое время и повторяем операцию заново — это зависит от требований бизнеса.

1.3. Если заблокировать удалось (монга сказала, что одна запись изменена), то пытаемся заблокировать второй счёт, командой:

Присвоить ACCOUNTS.TR_ID:=172, если ID==78 и TR_ID==(null)

Если эта команда ничего не поменяла, то заблокировать не удалось. В этом случае нужно разблокировать первую запись, командой:

Присвоить ACCOUNTS.TR_ID:=(null), если ID==34 и TR_ID==172

В этой операции важным является условие (TR_ID==172), которое имеет значение, когда транзакция внезапно прервалась и мы её возобновили позже, и неизвестно снялась блокировка или нет. В этом случае может быть блокировка снялась, и кто-то уже успел заблокировать запись, и тогда её нельзя снимать с блокировки. Её нужно снимать с блокировки только в том случае, если она заблокирована нашей транзакцией.

Дальше, в зависимости от требований бизнеса: либо отменяем операцию, либо выдерживаем паузу и повторяем всё снова.

1.4. Если заблокировать удалось, то обновляем статус нашей транзакции:

TRANSACTIONS.STATUS := ‘save_balance’

2. STATUS=’save_balance’

И даже сейчас мы не можем «просто взять и в строке 34 значение 300 уменьшить на 250...».

Во время транзакции можно делать только идемпотентные операции.

Операция является идемпотентной если её результат не зависит от количества повторений этой операции.

Например операция:

A := B + 1

Является идемпотентной. Почему. Пусть изначально B == 41, тогда после применения этой операции значение A будет 42. Если мы применим эту операцию ещё раз, то A всё равно будет 42. Даже если мы её применим ещё сто раз, что всё равно А будет 42, чтобы это число не означало.

С другой стороны операция:

А := А + 1

НЕ является идемпотентной. Потому что первый раз значение А увеличиться на единицу, а второй раз оно уже будет отличать на два, что не одно и тоже.

А теперь важная информация:

Любую НЕ идемпотентную операцию можно разложить на последовательность идемпотентных операций.

Например операцию:

А := А + 1

Можно разложить на две идемпотентные операции:

1. В := А + 1

2. А := В

А потом можно где-то сохранять какие идемпотентные операции выполнены, а какие ещё не выполнены. И если всё сломается, а потом восстановиться, то мы можем прочитать какие операции уже применялись, а какие нет, и продолжить выполнять только те, которые ещё не выполнялись. В этом случае одна операция возможно будет применена несколько раз. Но так как она идемпотентная, то это нисколько не повлияет на логику работы.

 И так, после блокировки у нас имеется следующая картина:

Таблица ACCOUNTS

ID

FIO

TR_ID

BALANCE, ₸

34

Ассанж

172

300

78

Агент "Ы"

172

7

Таблица TRANSACTIONS (или TR)

ID

FROM_ID

TO_ID

AMOUNT, ₸

FROM

BALANCE

TO

BALANCE

STATUS

172

34

78

250

(null)

(null)

save_balance

STATUS=’save_balance’ означает, что нужно заполнить поля 

TRANSACTIONS.FROM_BALANCE и TRANSACTIONS.TO_BALANCE. 

Заполняем.

Получаем следующую картину:

Таблица ACCOUNTS

ID

FIO

TR_ID

BALANCE, ₸

34

Ассанж

172

300

78

Агент Ы

172

7

Таблица TRANSACTIONS (или TR)

ID

FROM_ID

TO_ID

AMOUNT, ₸

FROM

BALANCE

TO

BALANCE

STATUS

172

34

78

250

300

7

save_balance

Копирование — это идемпотентное действие, поэтому, если после восстановления при сбое мы её повторим, то логика нарушена не будет.

Ну и нужно перевести статус к новому значению:

TRANSACTIONS.STATUS := ‘main’

3. STATUS=’main’

Теперь нужно провести саму операцию перевода денег. Но исходные значения нужно брать из полей FROM_BALANCE и TO_BALANCE.

ACCONTS[34].BALANCE := TR[172].FROM_BALANCE - TR[172].AMOUNT

ACCONTS[78].BALANCE := TR[172].FROM_BALANCE + TR[172].AMOUNT

Где TR = TRANSACTIONS

Если эти присваивания применить несколько раз, то будут получаться одинаковые результаты.

Ну и теперь нужно присвоить

TRANSACTIONS.STATUS := ’unlock’.

ВНИМАНИЕ ВАЖНО: изменять статус нужно только ПОСЛЕ применения промежуточной идемпотентной операции.

И так, вот мы добились состояния:

Таблица ACCOUNTS

ID

FIO

TR_ID

BALANCE, ₸

34

Ассанж

172

300

78

Агент Ы

172

7

Таблица TRANSACTIONS (или TR)

ID

FROM_ID

TO_ID

AMOUNT, ₸

FROM

BALANCE

TO

BALANCE

STATUS

172

34

78

250

50

257

unlock

4. STATUS=’unlock’

Мы запускаем последовательно следующие команды:

Присвоить ACCOUNTS.TR_ID:=(null), если ID==34 и TR_ID==172

Присвоить ACCOUNTS.TR_ID:=(null), если ID==78 и TR_ID==172

Каждая из этих команд атомарные, но они выполняются НЕ атомарно. Каждая из них идемпотентная.

Тут очень важно условие (TR_ID==172). Иначе мы можем разблокировать не нашу транзакцию и всё сломать.

Ну а теперь со спокойной душой можно сделать STATUS := ‘finished’.

Таблица ACCOUNTS

ID

FIO

TR_ID

BALANCE, ₸

34

Ассанж

(null)

50

78

Агент Ы

(null)

257

Таблица TRANSACTIONS (или TR)

ID

FROM_ID

TO_ID

AMOUNT, ₸

FROM

BALANCE

TO

BALANCE

STATUS

172

34

78

250

50

257

finished

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