Как защититься от одновременного выполнения двух запросов перевода, приводящих к отрицательному балансу пользователя?
Что проверяют
Видите ли вы гонку «проверить, потом списать» и знаете ли, что одна транзакция на уровне изоляции по умолчанию её не закрывает. Следом обычно спрашивают про взаимные блокировки при встречных переводах и про повторный запрос от двойного клика.
Как отвечать
В PostgreSQL по умолчанию READ COMMITTED: два запроса одновременно читают баланс 100, оба проверяют, что 80 списать можно, оба делают UPDATE, и баланс уходит в минус. Транзакция здесь даёт атомарность, но не защищает от параллельного чтения.
Рабочие способы:
- условное списание одним запросом:
UPDATE accounts SET balance = balance - %s WHERE id = %s AND balance >= %sи проверкаcursor.rowcount == 1. Второй запрос ждёт блокировку строки, заново проверяет условие на свежей версии и ничего не обновляет. В Django этоfilter(pk=pk, balance__gte=amount).update(balance=F("balance") - amount), метод вернёт число строк; - пессимистичная блокировка:
SELECT ... FOR UPDATEвнутри транзакции, в Djangoselect_for_update()вtransaction.atomic(), в SQLAlchemywith_for_update(); - оптимистичная блокировка: колонка
version, обновление с условием на неё и повтор при нуле обновлённых строк; SERIALIZABLE: база сама откатит одну из конфликтующих транзакций с ошибкой сериализации, код обязан уметь повторять;CHECK (balance >= 0): последний рубеж, если логика где-то ошиблась.
Строки двух счетов блокируйте в одном порядке, например по возрастанию id. Иначе встречные переводы A в B и B в A дают дедлок. От двойного клика защищает ключ идемпотентности: клиент шлёт идентификатор операции, в таблице переводов на нём уникальное ограничение.
Где спотыкаются
Ставят threading.Lock или asyncio.Lock. Он действует в одном процессе, а воркеров gunicorn и подов несколько.
Читают баланс в Python, вычитают и записывают готовое число. Это потерянное обновление даже без ухода в минус: одно из списаний просто исчезнет.
Вызывают select_for_update() вне transaction.atomic(). В автокоммите Django выбросит TransactionManagementError, а если блокировку взять в одной транзакции, а запись сделать в другой, между ними снова открывается окно для гонки.