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

В этом посте я покажу примеры решения упрощенной задачи, сосредоточив внимание на проблеме обновления нескольких строк БД, уровнях изоляции и особенностях каждого подхода. Мы будем использовать SQL и Java фрагменты с комментариями.

В решениях нет явных приемов против дедлоков (кроме исключений на уровне БД), что можно взять на заметку как одно из дальнейших улучшений. Не рассмотрена обработка исключений БД и ретраи - обязательная часть работы с БД. Также, в решениях не учтены многие обязательные свойства и функции полноценной банковской системы, например идемпотентнсть при создании перевода клиентом ( здесь также необходимо хранить операции )

Все решения выполняются внутри общей транзакции, для оптимистических стратегий - в последем блоке обычно принимается решение об откате или фиксации.

План

  • Оптимистическая блокировка (с использованием поля version)

  • Оптимистическая стратегия с пост-проверкой на овердрафт

  • Пессимистическая блокировка (с использованием явных блокировок строк)

  • Использование уровня изоляции Repeatable Read

  • Итоги

  • Заключения


Оптимистическая блокировка (с использованием поля version)

  • Уровень изоляции: READ COMMITTED

  • Особености: Подойдет для счетов с низкой вероятностью одновременных операций

Метод опирается на оптимистическую блокировку с использованием поля version для обнаружения конфликтов.

Пошагово:

-- Шаг 1: Чтение балансов и версий счетов
SELECT id, amount, version FROM accounts WHERE id in (:debitAccountId, :creditAccountId);
// Шаг 2: Проверка наличия средств на списываемом счёте
if (debit.getAmount() < transferAmount) {
    rollback();
    throw new OverdraftException();
}
-- Шаг 3: Обновление обоих счетов с использованием подхода типа CAS (Compare-And-Set)
UPDATE accounts
SET amount = amount - :transferAmount, version = :debitAccountIdVersionAfter
WHERE id = :debitAccountId AND version = :debitAccountIdVersionBefore;

UPDATE accounts
SET amount = amount + :transferAmount, version = :creditAccountIdVersionAfter
WHERE id = :creditAccountId AND version = :creditAccountIdVersionBefore;
// Шаг 4: Проверка, были ли обновления успешными
if (affectedUpdates < 2) {
    rollback();
    throw new ConcurrencyException();
} else {
    commit();
}

Этот подход гарантирует, что ни одно конкурентное обновление не изменило те же строки. Если одно из обновлений не прошло (несовпадение версий), транзакция откатывается.


Оптимистическая стратегия с пост-проверкой на овердрафт

  • Уровень изоляции: READ COMMITTED

  • Особенности: Подойдет для высоко конкурентных операций с низкой вероятностью овердрафта

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

Пошагово:

-- Шаг 1: Применение обновлений
UPDATE accounts SET amount = amount - :transferAmount WHERE id = :debitAccountId;
UPDATE accounts SET amount = amount + :transferAmount WHERE id = :creditAccountId;

-- Шаг 2: Повторная проверка баланса
SELECT amount FROM accounts WHERE id = :debitAccountId;
// Шаг 3: Проверка перерасхода. Исключение при отрицательном балансе
if (debit.getAmount() < 0) {
    rollback();
    throw new OverdraftException();
} else {
    commit();
}

Пессимистическая блокировка (с использованием явных блокировок строк)

  • Уровень изоляции: READ COMMITTED

  • Особенности: Оптимально для счетов с высокой вероятностью параллельных операций

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

Пошагово:

-- Шаг 1: Блокировка строк счетов
SELECT * FROM accounts WHERE id IN (:debitAccountId, :creditAccountId) FOR UPDATE;
// Шаг 2: После захвата блокировок
if (debit.getAmount() < transferAmount) {
    rollback();
    throw new OverdraftException();
}
-- Шаг 3: Применение перевода
UPDATE accounts SET amount = amount - :transferAmount WHERE id = :debitAccountId;
UPDATE accounts SET amount = amount + :transferAmount WHERE id = :creditAccountId;

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


Использование уровня изоляции Repeatable Read

  • Уровень изоляции: REPEATABLE READ

  • Особенности: зависит от реализации: при Snapshot Isolation (e.g. PostgreSQL (Материалы #3) https://www.postgresql.org/docs/current/transaction-iso.html#XACT-REPEATABLE-READ) — высокая вероятность ошибок сериализации (похоже на проблемы при оптимистических методах) + блокировки как при пессимистическом способе; при Locks Implementation — аналог пессимистической блокировки

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

Пошагово:

-- Шаг 1: Чтение балансов (гарантированная повторяемость в рамках транзакции)
SELECT amount FROM accounts WHERE id = :debitAccountId;
SELECT amount FROM accounts WHERE id = :creditAccountId;
// Шаг 2: Проверка наличия достаточных средств
if (debit.getAmount() < transferAmount) {
    rollback();
    throw new OverdraftException();
}
-- Шаг 3: Применение перевода
UPDATE accounts SET amount = amount - :transferAmount WHERE id = :debitAccountId;
UPDATE accounts SET amount = amount + :transferAmount WHERE id = :creditAccountId;

Полагание на механизм изоляции базы данных без явной блокировки строк.


Итоги

Подход

Уровень изоляции

Особенности

Оптимистический с версией

READ COMMITTED

Быстрый, но уязвим при высокой вероятности параллельных операций

Оптимистический без проверки версии

READ COMMITTED

Быстрый, но уязвим на рубеже перехода в овердравт

Пессимистический с блокировками

READ COMMITTED

Разумен при наличии параллельных операций на счетах

Repeatable Read

REPEATABLE READ

Простой, но поведение зависит от конкретной СУБД


Заключение

Решения имеют свои особенности.

Независимо от выбранной стратегии, всегда тестируйте её под нагрузкой и изучайте особенности реализации изоляции в вашей СУБД.

Материалы:

  1. Известные мне популярные задачи на собеседованиях.

  2. Designing Data-Intensive Applications, Martin Kleppmann, Глава 7. Транзакции

  3. PostgreSQL: Transaction Isolation

Комментарии (74)


  1. askv
    10.05.2025 08:16

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


    1. skymal4ik
      10.05.2025 08:16

      Как в такой системе показываются баланс и сделки за последние n дней например?

      Понятно что при создании счета его баланс устанавливается в 0, а потом сверху накидывают транзакции.

      Но как оптимизировать показ последних операций, например за сутки, если счету десятки лет и сотни тысяч транзакций? Не пересчитывать же с первой транзакции каждый раз?


      1. agratoth
        10.05.2025 08:16

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


      1. askv
        10.05.2025 08:16

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


      1. SpiderEkb
        10.05.2025 08:16

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

        Плюс платежные документы, связанные с этим счетом


      1. PrinceKorwin
        10.05.2025 08:16

        Но как оптимизировать показ последних операций, например за сутки

        Как уже ответили - иметь снепшот/подсчитанный актуальный баланс на определенную дату / с определенной транзакции. Вы наверняка слышали термин опердень (операционный день).


      1. Sanchous98
        10.05.2025 08:16

        Вы слышали про такой подход, как event sourcing? Вот тут такая же логика: ваш баланс - это результат всех операций. При совершении транзакции, восстанавливается баланс исходя из всех событий. Естественно когда событий много, это может быть проблемой в производительности. Для таких случаев делаются снапшоты каждые n-событий или периодически. Таким образом оптимизированная версия будет восстанавливать баланс из снапшота и событий после снапшота. При грамотном подходе к блокировкам можно добиться хорошего уровня параллелизма


    1. PrinceKorwin
      10.05.2025 08:16

      Верно. Также есть 2 механизма (на самом деле больше) механизма отката транзакции:

      1. проведение поверх корректирующей

      2. полностью удаление информации о транзакции

      Последняя весьма раздражающая, но используется банками в случае быстрого возврата денежных средств. Например при возврате на кассе в течение короткого (до дня) времени.


      1. askv
        10.05.2025 08:16

        Например при возврате на кассе в течение короткого (до дня) времени.

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


        1. PrinceKorwin
          10.05.2025 08:16

          Насколько я понимаю, речь идёт о не до конца проведенной транзакции.

          Не обязательно. Это может быть зафиксированная транзакция. Например провели на кассе картой. И через 10 минут передумали и решили сделать возврат. Банк имеет право такую транзакцию полностью удалить и вы ее нигде не увидите. Это зависит от многих факторов, но такое имеет место быть.


          1. askv
            10.05.2025 08:16

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


            1. PrinceKorwin
              10.05.2025 08:16

              Попадает :)

              Вы сначала ее видете у себя в банк онлайн, а потом она исчезает.

              И эта операция также не светится в отчётах в ЦБ


    1. SpiderEkb
      10.05.2025 08:16

      Не транзакции, а платежные документы. Платкльщик, счет плательщика, получатель, счет получателя, сумма платежа...


      1. askv
        10.05.2025 08:16

        Вот понятие платёжных документов, оно довольно странное. Их форма и содержание — это полная свобода творчества заинтересованных сторон. Например, если я, находясь в РФ, прошу своего заграничного друга оплатить мне за границей какой-то сервис (например, Zoom), то наша переписка в чате является платёжным документом или нет? Ведь эта переписка потом служит основанием для подсчёта наших взаимных обязательств друг перед другом, кто кому сколько должен, по аналогии с тем, как банки ведут учёт денег на счетах клиентов...


        1. agratoth
          10.05.2025 08:16

          Платежным поручением может считаться)


    1. zebin Автор
      10.05.2025 08:16

      Замечание справедливое. Исправил вступление. Туториал c фокусом на понимание изоляций и свойств стэйтментов. В этой статье рассматриваю только проблему обновления двух счетов. Бухгалтерские системы гораздо сложнее.


    1. Mapar
      10.05.2025 08:16

      А вот тут еще и фантомные чтения надо рассматривать в тему статьи.


      1. zebin Автор
        10.05.2025 08:16

        Спасибо. В этих примерах фантомные чтения не помешают. Но их можно будет рассмотреть на другой подобной задаче


        1. SpiderEkb
          10.05.2025 08:16

          Вообще, это основа. Прочитал запись, что-то в ней сделал, перед тем как записывать - проверь. Прочитай еще раз - не изменилось ли что-то пока ты работал с записью


        1. Mapar
          10.05.2025 08:16

          Комментарий по фантомным чтениям относился к варианту досчета остатка по транзакциям.


          1. SpiderEkb
            10.05.2025 08:16

            И все равно вопрос - вы начинате транзакцию на списание 300р со счета. И вэтот же момент начинается транзакция на списание 400р. А на счете вмего 500р. Каждая транзакция по отдельности валидна. Вместе - нет. Что делать?


            1. Mapar
              10.05.2025 08:16

              Вопрос наверное не ко мне, а к автору статьи.

              Собственно он статьей на Ваш вопрос и отвечает, что делать и какие патерны серилизации применять.


              1. SpiderEkb
                10.05.2025 08:16

                К сожалению, там нет ответа на поставленный вопрос.

                Я уже писал как это происходит в реальной жизни.

                1. Формируется платежный документ где указывается плательщик, получатель, счет плательщика, счет получателя. Причем, эти счета на обязательно в одном банке. Скорее всего, даже в разных. И сложности возникают только со списанием денег со счета плательщика. С зачислением на счет получателя все проще.

                2. Если счет плательщика в нашем банке, Сумма по платежному документу переводится со счета на холд - резервируется (если на счете достаточно денег). Это быстрая операция, она делается с блокировкой записи.

                3. Платежный документ отправляется на контроль. Результатом может быть безусловное "разрешить" (все автоматические проверки пройдены) - тогда документ передается на исполнение. Или документ может быть отправлен на ручной контроль в службу комплаенса. Оттуда может прийти решение "разрешить" - тогда на исполнение. Или "запретить" - тогда формируется отказ в операции и сумма с холда возвращается обратно на счет (опять с полной блокировкой записи - это одна запись, там есть поле текущего баланса, есть поле суммарного холда).
                  В целом контроль платежей штука достаточно сложная - там много разных проверок проводится.

                4. Сумма холда уменьшается на сумму платежного документа только тогда, когда от получателя придет подтверждение о том, что деньги реально пришли на его счет (иногда это может занимать несколько секунд, иногда несколько минут).

                Т.е. одной транзакцией тут никак не обойтись - между переводом денег с баланса на холд и списанием их с холда или возвратом обратно на баланс (в случае отказа в операции или неподтверждения операции в установленные сроки) может пройти существенное время.

                Это и есть та самая (причем, достаточно упрощенная) бизнес-логика, в отрыве от которой все это превращается в сферического коня в вакууме.

                Кроме того, транзакции достаточно сильно грузят сервер. Я скажу крамольную вещь, но мы работаем с COMMITMENT CONTROL (*NONE). Т.е. без коммитов и роллбеков. Вместо этого ведется журналирование всех операций (в специальные журналы пишутся образы записей "до" и "после" изменения (или только "после" в случае добавления или только "до" в случае удаления). Плюс двойное чтение - прочитали запись "до", внесли изменения (получили "после"), перед записью изменений еще раз читаем запись с проверкой - совпадет она с образом "до" - если да, то записываем изменения, если нет - возвращаем ошибку "кто-то другой изменил запись" (и дальше она уже по бизнес-логике обрабатывается).

                По журналам можно откатить что угодно - хоть вчерашнее, хоть позавчерашнее. Кроме того, по журналам в начале нового дня накатываются все изменения из юнита ночного ввода (писал ранее об этом).

                Правда, опять крамола, мы не используем SQL для изменений в продуктовых таблицах. Наш стек позволяет напрямую работать с БД (SQL используется там, где нужный сложные выборки по нескольким таблицам и многим условиям, и то, там не так все просто с точки зрения производительности).

                Строго говоря, для каждой продуктовой таблицы есть "опция ведения". Которая состоит из 4-х модулей:

                • Update модуль - собственно работа с таблицей и ее журналом. Чтение образа "до", запись образа "после" (с проверкой что "до" не изменился). В реальной жизни может быть достаточно сложным т.к. может работать не с одной, а с несколькими логически связанными таблицами.

                • Validate модуль - контроль валидности данных записи в соответствии с бизнес-логикой

                • Модуль интерактивной работы с записью (позволяет вносить ручные изменения). Вводим уникальный ключ - если запись есть - работаем режиме изменения, если нет - в режиме добавления. После внесенных изменений вызывается Validate модуль и, если ошибки нет - Update модуль.

                • Модуль внешнего ввода. Получает на вход образ "после", читает из таблицы образ "до" (опять, есть запись - изменение, нет - добавление), вызывает Validete модуль, если ошибок нет - Update модуль. Этот же модуль позволяет делать "накат по журналу". Для этого на вход передается не образ "после", а имя библиотеки где лежит журнал и ключ записи с образом "после". В этом случае образ "после" берется из журнала.

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


                1. Ivan22
                  10.05.2025 08:16

                  у автора же для этого как раз поле с версией заведено. Логическая transaction_id так сказать


                1. Mapar
                  10.05.2025 08:16

                  Мне кажется автор просто не удачно назвал статью, тем самым притянув не ту аудиторию. Статья вообще не про "денежные переводы". И тут я понимаю Вы все прекрасно расписали.

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

                  Но безусловно интересно почитать детали Вашей реализации.


                  1. askv
                    10.05.2025 08:16

                    Мне кажется автор просто не удачно назвал статью, тем самым притянув не ту аудиторию.

                    Это действительно так, но раз уж это произошло, то почему бы и не обсудить смежную тему?

                    Я не один раз наблюдал, когда неправильно прописанная (или неправильно понятая) бизнес-логика приводила к непомерному росту затрат на техническую реализацию.

                    Например, для реализации одной задачи мы сводили все сделки в одну таблицу Excel (выгрузка из системы). Всё хорошо работало, пока все сделки были в одной IT-системе. Но как только появилась вторая система со сделками (при живой первой), технари тут же бросились реализовывать перекачку сделок из одной системы в другую (через какие-то шины, хранилища данных и т.д. и т.п.), чтобы потом объединить все сделки в одной табличке. Хорошо, что я вовремя это заметил и сказал, что их необязательно объединять в системе, исходя из бизнес-задачи табличек может быть больше чем одна, их легче объединить вовне системы, чем внутри и т.д. и т.п., что вызвало значительный вздох облегчения у технолога на другом конце телефонной линии...


                1. inkelyad
                  10.05.2025 08:16

                  Кроме того, транзакции достаточно сильно грузят сервер. Я скажу крамольную вещь, но мы работаем с COMMITMENT CONTROL (*NONE).

                  И дальше, похоже, вручную делается то, что в более простых случаях (или более умных базах) делается самим движком базы данных.

                   Плюс двойное чтение - прочитали запись "до", внесли изменения (получили "после"), перед записью изменений еще раз читаем запись с проверкой - совпадет она с образом "до" - если да, то записываем изменения,

                  Без уточнения что происходит - не гонка? Потому что между "еще раз читаем с проверкой' и 'то записываем изменения' - записи могут и измениться.


                  1. SpiderEkb
                    10.05.2025 08:16

                    И дальше, похоже, вручную делается то, что в более простых случаях (или более умных базах) делается самим движком базы данных.

                    Тут вопрос не в том, что может БД. Она (DB2 for i) много что может.

                    Вопрос в производительности в условиях больших нагрузок. Наши исследования показали что SQL может начать работать нестабильно (в смысле скорости выполнения) в ситуациях, когда один и тот же запрос начинает выполняться параллельно из многих заданий (job) с большой плотностью вызовов. При этом прямая работа с БД не только работает стабильно в таких условиях, но и еще обеспечивает меньшую загрузку системы.

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

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

                    Ну и SQL (точнее, commitment control) не решает проблему журналирования, возможность которого для нашей архитектуры является ключевой.

                    Без уточнения что происходит - не гонка? Потому что между "еще раз читаем с проверкой' и 'то записываем изменения' - записи могут и измениться.

                    Да. Могут измениться. Именно поэтому и проводится проверка с выдачей ошибки. Как эта ошибка обрабатывается - тут все зависит от бизнес-логики процеса.

                    Но блокировка записи на длительное время (пока идет какая-то обработка - это прямой путь к дедлокам и деградации производительности. Что в нашем случае неприемлемо.

                    Я описал как в реальности происходит обработка платежа - кратковременная блокировка при переносе суммы платежа с баланса на холд. Дальше платеж может обрабатываться сколь угодно долго - баланс счета уже уменьшился, но в случае отката (отказа в проведении платежа) может быть увеличен обратно обратной операцией - возврата суммы с холда обратно на баланс. И транзакцией это не решается т.к. вы не можете в такой системе держать транзакцию открытой в течении длительного времени (а это может достигать суток - пока пройдет проверки, пройдет при необходимости ручной контроль, будет получено подтверждение из того банка, на счет в котором уходит платеж...)

                    А чтобы перевести со счета на холд транзакция не нужна - это одна запись (счет - сумма текущего баланса - сумма холдов по счету) - read (с блокировкой), cerbal -= n, curhold += n, update (с разблокировкой). А транзакция по определению, это цепочка связанных изменений в нескольких записях.


                    1. inkelyad
                      10.05.2025 08:16

                      И транзакцией это не решается т.к. вы не можете в такой системе держать транзакцию открытой в течении длительного времени

                      Только главное не налететь на синонимы терминологии. Потому что с точки зрения клиента: пока холд не нулевой - это разве не означает, что есть открытая транзакция(другая, не БД-ная) перевода денег? Ну вот и висит эта транзакция несколько дней.

                      И все описанный операции - это ручное выполнение протокола этой транзакции, а не БД-ной. Чтобы было то самое 'либо целиком выполнилось, либо сделаем вид что ничего не было'.


                      1. askv
                        10.05.2025 08:16

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


                      1. SpiderEkb
                        10.05.2025 08:16

                        Ну на самом деле Холды часто бывают не нулевые. Например, на счет у вас 10 000р На холде 0. Пошли по магазинам - тут купили на 1 000, там 2 000, здесь на 3 000... В результате "на счете" 4 000, на холде 6 000. Потом, когда начинают приходить подтверждения по платежным операциям, холд начинает уменьшаться (каждый раз на ту сумму, на которую пришло подтверждение).

                        Или на заправке - оплачиваете бензина на 2 500р Они встают на холд. Но в бак влезло только на 2 400р. Тут возможны варианты (это уже как банк отработает). Или сначала отмена все операции на 2 500р с возвратом всей суммы с холда на счет и сразу создание новой операции уже на 2 400р с перемещением на холд со счета 2 400р. Или 2 400р остаются на холде до подтверждения , а 100р оформляются как "возврат" т с холда переходят обратно на счет. И так и этак встречал.


    1. izibrizi2
      10.05.2025 08:16

      У бухгалтеров это называется - проводки. В 1с есть еще регистр накопления для оперативного отображения сумм (агрегат, чтобы в реальном времени транзакции не считать). Всё хранится в журналах, sqrs + event sourcing до того как это стало мейнстримом.

      Если балансы корректировать через ячейки - выгонят на мороз и спасибо если не посадят


  1. Mapar
    10.05.2025 08:16

    Последние 2 варианта содержат ошибку в реализации - фиксация транзакции на шаге 2, до update - обнуляет все усилия. Между шагом 2 и 3 могут втиснуться любые транзакции, а блокировки уже сняты.

    Вот этот текст, на мой взгляд спорный:

    "Особенности: зависит от реализации: при Snapshot Isolation — аналог оптимистической блокировки с версией, при блокировках — аналог пессимистической блокировки"

    В стандарте SQL, для этого уровня блокировки, при совершении обновления строк измененных с момента начала транзакции должно выдать ошибку (которую приложение должно быть готово обработать), в той же версионной СУБД PostgreSQL возникает по факту ожидание снятие блокировки изменённой другой транзакцией строки (вдруг там rollback и все же можно), что приводит нас что даже в версионниках будет ожидание как в пессимистическом варианте. Тут конечно автор может предположить о существовании другой версионной СУБД где такого ожидания не происходит, но я о такой не знаю (в другом популярном версионнике Oracle - нет Repeatable Read). В целом такие истории, наверное, лучше рассматривать в привязке к СУБД.


    1. askv
      10.05.2025 08:16

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

      1. Принять к исполнению документ о переводе со счёта на счёт (поручение/заявление владельца счёта, инкассо, безакцептное и т.д.).

      2. Проверить наличие денег на счёте, заблокировать соответствующую сумму.

      3. Создать проводку о переводе денег со счёта на счёт.

      4. Пометить исходный документ исполненным, снять блокировку суммы.

      5. Создать документы для получателя о приходе денег на счёт.

      И каждый этап — добавление (корректировка) записей нескольких таблиц (таблицы с документами, таблицы с проводками и т.д. и т.п.). Соответственно, если там суммы в итоге где-то не сойдутся, то регулярные сверки (контроль) это выявят.


      1. Mapar
        10.05.2025 08:16

        Это понятно, я писал реальную банковскую систему и видел внутри еще 2-3. И там все сложнее. Понятно что там есть стадийность принятия решения, есть системы где есть остаток на начало дня и все остальное досчитывается, есть системы где есть несколько остатков (прогнозный и реальный). Если добавить карты то там еще "чудесатее" - и остаток на карте и счете - несколько дней после совершения операции разный.

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


        1. askv
          10.05.2025 08:16

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

          У меня есть знакомый, который регулярно делает управленческие отчёты в своей организации, и при этом из года в год борется с тем, что эти отчёты с чем-то не совпадают (возникают необъяснимые исчезновения денег или наоборот, они появляются ниоткуда). Но реальная проблема у него не в отчётах, а в учёте. И исправлять нужно сначала учёт...


          1. Mapar
            10.05.2025 08:16

            Ой, с отчетами конечно оффтопик: но там основная причина это исправление данных задним числом и отсутствие версионности данных в системе. Если система не позволяет получить отчет за январь, в том виде как он выглядел в момент принятия решения, скажем 10 февраля, то такая фигня будет постоянно.

            У меня был опыт разработки хранилища с полной версионности и присутствие на забавном диалоге гендира и главбуха, когда гендир получил отчет за прошлый год как он выглядел в январе и потом как в марте, и главбух имел очень бледный вид.


            1. askv
              10.05.2025 08:16

              Конкретно в описанном мной случае причина в том, что в учёте сохраняются не все данные, которые в итоге необходимы для построения отчётов. В формализованной отчётности такое редко бывает, а вот управленческий учёт чаще всего требует дополнительных данных, которые по умолчанию не сохраняются.

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


            1. askv
              10.05.2025 08:16

              Если уж офтопить, то до конца: вспомнил случай, когда в банке сидела проверка ЦБ РФ, мы сделали переоценку стоимости одного актива, причём сильно в плюс. Эта переоценка очень не понравилась председателю, было совещание, я сам не присутствовал, но мне сказали, что скандал был жуткий. В обычной ситуации могли бы успеть исправить, но отчётные документы уже успели передать проверяющим...


        1. SpiderEkb
          10.05.2025 08:16

          В обработке платежей это наименьшая из проблем


      1. SpiderEkb
        10.05.2025 08:16

        Плюс еще комплекс контроля платежей. Лимиты, комплаенс контроль...

        И кроме внутренних платежей (оба счета в одном банке), есть входящие (из другого банка) и исходящие (в другой банк)


        1. Mapar
          10.05.2025 08:16

          Коллеги, вы слишком глубоко копаете, на мой взгляд, статья простая и тема ее не финансовые системы - тут полный швах. А просто рассказ об уровнях изоляции и проблемах с ними связанных.


          1. askv
            10.05.2025 08:16

            Заголовок и первая фраза статьи задают основную тему. )


            1. Mapar
              10.05.2025 08:16

              Да к ИТшникам просто так не приходи, народ дотошный )))

              Нужно очень аккуратно формулировать тему и пример, а то получишь 100500 замечаний на другом уровне абстракции )))


              1. askv
                10.05.2025 08:16

                У меня начальник возмущался, почему он от меня получает ответ на тот вопрос, который он задал, а не на тот, который он имел в виду...


    1. zebin Автор
      10.05.2025 08:16

      Спасибо, добавил пояснение что все блоки в одной транзакции. спорный текст - также уточнил что я имел в виду.


      1. Mapar
        10.05.2025 08:16

        От ваших пояснений толку нет, у вас в коде явно commit на 2 шаге, который закроет транзакцию и 3 шаг будет в новой транзакции. Код лучшее пояснение, а он с ошибками.

        Новичок скопирует код и получит ерунду.


        1. zebin Автор
          10.05.2025 08:16

          Оу, большое спасибо, исправил.


  1. Mapar
    10.05.2025 08:16

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


    1. SpiderEkb
      10.05.2025 08:16

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

      Если платкж заблокировпн, сумма с холда возвращается обратно.

      Описывать технические проблемы в отрыве от бизеслогики в корне неверно


      1. askv
        10.05.2025 08:16

        Описывать технические проблемы в отрыве от бизеслогики в корне неверно

        100%. Причём ошибки в бизнес-логике чаще всего ведут к проблемам, которые техническими средствами неустранимы в принципе.


  1. PrinceKorwin
    10.05.2025 08:16

    Статья напомнила про фееричный баг у разработчиков банкоматов в Индии. Его уже пофиксили, но это было шикарно. Суть проста - при снятии наличных забираете не всю выданную наличность, а оставляете пару купюр. Банкомат видит, что деньги не забраны и забирает их обратно внутрь и откатывает всю транзакцию.

    Этим багом люди полгода пользовались и сняли в сумме дохренилион денег. Пока не смогли понять в чем именно был баг.


    1. Mapar
      10.05.2025 08:16

      Ну так тут еще фееричнее, 3 и 4 вариант, делают все проверки, а потом закрывают транзакцию, и потом просто фигачат update, а то что между закрытием транзакции и update может быть все что угодно, автор не думает.


      1. zebin Автор
        10.05.2025 08:16

        Спасибо, добавил пояснение что все блоки в одной транзакции


        1. Mapar
          10.05.2025 08:16

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

          Если не понимаете как, то просто уберите из 3 и 4 варианта на 2 шаге:
          } else { commit();


          1. zebin Автор
            10.05.2025 08:16

            Большое спасибо, моя грубая ошибка - исправил


            1. Mapar
              10.05.2025 08:16

              Да, так будет работать.


  1. Akina
    10.05.2025 08:16

    Этот подход гарантирует, что ни одно конкурентное обновление не изменило те же строки. Если одно из обновлений не прошло (несовпадение версий), транзакция откатывается.

    Да? Давайте посмотрим попристальнее.

    UPDATE accounts
    SET amount = amount + :transferAmount,
    version = :creditAccountIdVersionAfter
    WHERE id = :creditAccountId
    AND version = :creditAccountIdVersionBefore;

    Что видим? А видим мы прилетевшие откуда-то снаружи значения параметров какой-то там версии. Ну ладно, опустим тот момент, что эта самая версия есть совершенно непонятная и неясно кому и зачем нужная фигня. Но давайте, убедите нас, что эта версия ну никак не может получить одно и то же значение в двух разных конкурентных процессах... а если это вдруг и получится, в чём я лично сомневаюсь, то объясните, почему при рассмотрении внутренних для сервера БД вопросов мы априорно закладываемся на гарантии от внешнего и совершенно неконтролируемого сервером БД процесса.

    Шаг 3: Проверка перерасхода. Исключение при отрицательном балансе

    Класс... то есть если получен отрицательный баланс (в скобках отмечу - неважно, сотворили это мы или процесс-конкурент), то это ошибка, но если он неотрицателен, то всё в порядке, даже будь результат хоть трижды неправильный. Походу, кто-то успел забыть, что же надо проверять...

    ----------------

    А на самом деле всего-то и надо, что

    UPDATE accounts
    SET amount = CASE id
    WHEN :debitAccountId THEN amount - :transferAmount
    WHEN :creditAccountId THEN amount + :transferAmount

    END
    WHERE id IN (:debitAccountId, :creditAccountId);

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


    1. SpiderEkb
      10.05.2025 08:16

      И еще раз - что делать когда счета плательщика и аолучателя в разных банках?

      Что делать, когда платкж по каким-то причинам отправлен на ручной контроль и подтверждение его будет через час, два а то и вообще только завтра?

      Еще более сложное. В банке каждый день наступает фаза закрытия опредня и перехода на следующий день. Фактически - сведение баланса. Но при этом платежи принимаются и обрабатываются. Но не в том юните, где идет переход на следующий лень, а в другом - юните ночного ваода. А потом, когда основной юниттперешел на следующий день, в него накатываются все изменения из ночи... Как с этим быть?

      Статья про сферического коня в вакууме, а не про реальную жизнь.


      1. Akina
        10.05.2025 08:16

        Статья про сферического коня в вакууме, а не про реальную жизнь.

        Несомненно. Но я-то тут каким боком?


      1. inkelyad
        10.05.2025 08:16

        И еще раз - что делать когда счета плательщика и аолучателя в разных банках?

        А там есть транзакции в том смысле, что в статье? Нет, слово-то одно и то же. Но что мне кажется что банковская транзакция (что бы они там ни имели в виду) и транзакция БД (которые с уровнем изоляции) - сильно не то же самое.


  1. gnub
    10.05.2025 08:16

    спасибо! очень наглядно


  1. ogregor
    10.05.2025 08:16

    Ребята лучше почитайте Алекса Петрова с его книгой "Распределенные базы данных" там достаточно информации на эту тему и даже больше. Кроме того могу посоветовать Алекса Ху. С его книгами "System design interview" там достаточно информации чтобы увидеть архитектуру подобных решений.


  1. LKU
    10.05.2025 08:16

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

    На практике в банковских приложениях разных банков РФ последние годы при переводе денег между собственными счетами внутри банка регулярно наблюдаю дикую дичь:

    • Деньги задваиваются (на исходящем счёте ещё не списались, а на новом уже отражаются)

    • Деньги испаряются (на исходящем счёте уже списались, а на целевом все ещё показывает ноль

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

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

    Вопрос знатокам - почему всё же банки на практике ушли от нормальных транзакций в рамках одной БД к распределенной асинхронщине?


    1. askv
      10.05.2025 08:16

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


    1. inkelyad
      10.05.2025 08:16

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

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

      Я в том обсужденнии даже пару старых книг находил. Прошлых веков. Там этих журналов, обеспечивающих работу транзакций - пачками.

      Вопрос знатокам - почему всё же банки на практике ушли от нормальных транзакций в рамках одной БД к распределенной асинхронщине?

      Одна БольшаяБазаДанных обладает недостатками. Прежде всего по железу. Мейнфрейм - он дорогой и труднодоступный.

      Потому нарезали, географически распределили, и откатились назад по времени, когда сообщения ходят между 'клерками-микросервисами'. И каждый из них сам по себе их обрабатывает. Только поскольку протоколы и правила оформления немного забыли - все и показывается в виде 'деньги временно непонятно где'.


      1. askv
        10.05.2025 08:16

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

        Не знаю как сейчас, а когда был рейсовый механизм взаиморасчётов в ЦБ РФ, то банки могли рассчитаться, даже если ни у кого денег на счёте. Например, банк А платит банку Б 1000 рублей, банк Б платит банку В 1000 рублей, банк В платит банку А 1000 рублей. ЦБ РФ мог обработать все эти платёжки, даже если у каждого из них не было этой тысячи на счёте. Если потребовать синхронности отражений остатков и обрабатывать платёжки по очереди, то такой финт не удалось бы проворачивать без лимита овердрафта.


        1. inkelyad
          10.05.2025 08:16

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

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

          И вот тут, если уж асинхронщина - оно должно отображаться не как человек жаловался "на одном счете пропали, а на другом еще не появились", а "На одном счете пропали, но видны как 'Перевод в обработке'- XYZруб".


          1. askv
            10.05.2025 08:16

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


            1. inkelyad
              10.05.2025 08:16

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

              • Деньги задваиваются (на исходящем счёте ещё не списались, а на новом уже отражаются)

              • Деньги испаряются (на исходящем счёте уже списались, а на целевом все ещё показывает ноль

              Т.е. эти самые процедуры, предусмотренные правилами, и не выполнятся - деньги берутся из ниоткуда или пропадают.


              1. askv
                10.05.2025 08:16

                Ну потому что в реальности эти цифры в базе они юридического значения не имеют. Даже если списание/зачисление прошло несинхронно, то вряд ли кто-то сможет этими деньгами воспользоваться повторно. Это просто картинка лагает (что для конечных пользователей, как я понимаю, существенного значения не имеет, так как каждый пользователь и так видит операции и может понять, сколько у него должно быть денег). Если даже в силу сбоя банк проведёт операций больше, чем было денег на счёте, то они потом откатят. Если не получится откатить, подадут иск. Для системы юридическая составляющая важнее, чем техническая.


              1. PrinceKorwin
                10.05.2025 08:16

                Т.е. эти самые процедуры, предусмотренные правилами, и не выполнятся - деньги берутся из ниоткуда или пропадают.

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


    1. SpiderEkb
      10.05.2025 08:16

      Ну не все :-) У нас, слава богу, это дичи нет.

      Скорее всего проблема в микросервисах у которых каждый имеет свою копию БД. И начинаются задержки репликации и синхронизации этих копий. Тут от микросервисов бльше проблем чем пользы.

      На самом деле там все не так. А примерно так:

      • С баланса счета плательщика сумма переводится на холд

      • Проводится комплекс проверок платежа.

      • Если все проверки пройдены, сумма зачисляется на счет получателя

      • При успешном зачислении суммы на счет получателя она списывается с холда на счету плательщика.

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

      При запросе баланса счета показывается только баланс, без учета холдов. Работаем с одной БД, без репликаций (точнее, они есть, но для разных "внешних систем" и там, естесвтенно, не вся БД, а только нужные этим внешним системам данные реплицируются). И да, там могут быть лаги, но для тех систем это несущественно по их логике работы.


      1. askv
        10.05.2025 08:16

        так и для случая когда счета в разных банках.

        Когда в разных банках, у вас в общем случае нет возможности за разумное время пройти этап

        сумма зачисляется на счет получателя

        Там этот критерий как-то иначе формулируется (списано с корсчёта или как-то иначе). Если СБП, то может быть, там же онлайн всё.