Как реализовать атомарность операций при переводе средств между счетами
Что проверяют
Понимаете ли вы, что атомарность даёт транзакция базы, а не блокировка в Python, и видите ли три проблемы сразу: частичное списание, гонку двух переводов с одного счёта и взаимную блокировку. Следом спрашивают, что делать, если счета живут в разных сервисах.
Как отвечать
- одна транзакция: списание и зачисление в одном блоке. В Django это
with transaction.atomic():, в SQLAlchemywith session.begin():, в psycopg 3with conn.transaction():. Исключение внутри откатывает всё; - проверка баланса без гонки:
UPDATE accounts SET balance = balance - %s WHERE id = %s AND balance >= %s, и еслиrowcount == 0, денег не хватило, откат. Альтернатива:SELECT ... FOR UPDATEобоих счетов, затем проверка и дваUPDATE; - порядок блокировок: блокируйте счета всегда по возрастанию
id, иначе встречные переводы A→B и B→A поймают дедлок. PostgreSQL его разорвёт, откатив одну из транзакций, но лучше не доводить; - повторы: при дедлоке или ошибке сериализации на
SERIALIZABLEтранзакцию повторяют целиком, с ограничением числа попыток; - идемпотентность: у перевода есть ключ от клиента, уникальный индекс не даст провести его дважды при повторной отправке;
- деньги:
NUMERICв базе иDecimalв Python, копейки целым числом тоже годятся. ПлюсCHECK (balance >= 0)как последняя страховка.
Хорошо добавить, что в серьёзных системах баланс не перезаписывают, а ведут журнал проводок по двойной записи: каждая операция это пара строк с дебетом и кредитом, в сумме дающих ноль. Для счетов в разных сервисах двухфазный коммит почти не используют, берут сагу с компенсирующими операциями и outbox.
Где спотыкаются
Ставят threading.Lock. Он защищает только внутри одного процесса, а у gunicorn несколько воркеров, серверов тоже обычно больше одного, так что гонка остаётся.
Считают деньги во float. 0.1 + 0.2 не равно 0.3, и расхождения в копейках всплывают на сверке.
Делают SELECT balance, проверяют сумму в Python и потом UPDATE без блокировки. Между чтением и записью второй перевод успевает списать те же деньги, и счёт уходит в минус.