golang

Задачи на собеседованиях. Денежные переводы в SQL. Обновление счетов и уровни изоляций

  • воскресенье, 11 мая 2025 г. в 00:00:08
https://habr.com/ru/articles/908198/

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

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