В своё время мне пришлось реализовать многофазную транзакцию, и я подумал что всем будет интересно послушать как это можно сделать. Может новички научиться чему. Может бывалые сравнят с собой. А сеньёры просто побалдеют.
Пусть нам необходимо перевести 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 |
В результате мы добились искомого состояния с возможностью восстановления при любых сбоях, с соблюдением полной констистенции.
