Задачи на собеседованиях. Денежные переводы в SQL. Обновление счетов и уровни изоляций
- воскресенье, 11 мая 2025 г. в 00:00:08
Задача перевода денег в первом приближении сводится к обновлению пары строк и кажется простой — но обеспечение корректности при параллельном доступе может быть неожиданно сложным для только знакомящихся с уровнями изоляций БД.
В этом посте я покажу примеры решения упрощенной задачи, сосредоточив внимание на проблеме обновления нескольких строк БД, уровнях изоляции и особенностях каждого подхода. Мы будем использовать SQL и Java фрагменты с комментариями.
В решениях нет явных приемов против дедлоков (кроме исключений на уровне БД), что можно взять на заметку как одно из дальнейших улучшений. Не рассмотрена обработка исключений БД и ретраи - обязательная часть работы с БД. Также, в решениях не учтены многие обязательные свойства и функции полноценной банковской системы, например идемпотентнсть при создании перевода клиентом ( здесь также необходимо хранить операции )
Все решения выполняются внутри общей транзакции, для оптимистических стратегий - в последем блоке обычно принимается решение об откате или фиксации.
План
Оптимистическая блокировка (с использованием поля version
)
Оптимистическая стратегия с пост-проверкой на овердрафт
Пессимистическая блокировка (с использованием явных блокировок строк)
Использование уровня изоляции Repeatable Read
Итоги
Заключения
Уровень изоляции: 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
Особенности: зависит от реализации: при 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 | Простой, но поведение зависит от конкретной СУБД |
Решения имеют свои особенности.
Независимо от выбранной стратегии, всегда тестируйте её под нагрузкой и изучайте особенности реализации изоляции в вашей СУБД.
Известные мне популярные задачи на собеседованиях.
Designing Data-Intensive Applications, Martin Kleppmann, Глава 7. Транзакции