Как решить проблему одновременного списания денег с кошелька при параллельных запросах в PostgreSQL
Что проверяют
Понимаете ли вы, откуда берётся гонка при списании и какими средствами базы её закрыть. Следом почти всегда спрашивают про перевод между двумя кошельками и дедлоки или про повтор запроса клиентом.
Как отвечать
Корень проблемы в схеме «прочитать баланс в Python, проверить, записать новый». Два запроса читают 100, оба решают, что 80 списать можно: в итоге либо баланс уходит в минус, либо одно списание теряется. Уровень изоляции PostgreSQL по умолчанию, Read Committed, от этого не спасает.
- атомарный условный UPDATE:
UPDATE wallets SET balance = balance - %s WHERE id = %s AND balance >= %s RETURNING balance. Второй запрос ждёт блокировку строки, затем перепроверяет условие на свежей версии. Строка не вернулась, значит денег не хватило. Самый простой и быстрый вариант; - пессимистичная блокировка:
SELECT ... FOR UPDATEв транзакции, если перед списанием нужна сложная логика в коде. В SQLAlchemy это.with_for_update(); - оптимистичная блокировка: колонка
versionиUPDATE ... WHERE version = %s. При конфликте повтор, подходит при редких пересечениях; - Serializable: база сама откатит конфликтную транзакцию с кодом
40001, но код обязан уметь повторять.
Страховка на уровне схемы: CHECK (balance >= 0), чтобы ошибка в коде не превратилась в отрицательный баланс. Для повторов клиента нужен ключ идемпотентности с уникальным индексом, иначе ретрай после таймаута спишет деньги дважды. В серьёзных системах баланс выводят из журнала операций, а не хранят одним числом.
Где спотыкаются
Считают, что транзакция сама решает проблему. Без блокировки или условия в UPDATE две транзакции в Read Committed спокойно теряют обновление друг друга.
Ставят threading.Lock или блокировку в Redis. Первое не работает между процессами и воркерами, второе добавляет точку отказа там, где хватает блокировки строки в базе.
При переводе блокируют кошельки в порядке «сначала отправитель». Встречные переводы ловят дедлок, поэтому строки блокируют по возрастанию id.