> Как решить проблему гонки при переводе денег между счетами? (Python)

Уровень: senior · Роль: backend · Язык: Python · Категория: Технические вопросы

Компании: HeadHunter

Стек: Python

> Пример ответа

Короткий ответ

Гонка при переводе денег решается через атомарность операции: либо блокировка строк в БД (SELECT ... FOR UPDATE), либо оптимистичная блокировка с версионированием, либо атомарные UPDATE-запросы с проверкой баланса. В распределённых системах - saga или outbox. Ключевое - исключить чтение-модификацию-запись без изоляции. В Python с PostgreSQL чаще всего используют SELECT FOR UPDATE в транзакции или UPDATE ... SET balance = balance - X WHERE id = ... AND balance >= X.

Подробное объяснение

Гонка возникает, когда два параллельных запроса читают баланс, вычитают сумму и записывают результат - итог теряет одно обновление. Решения делятся на два класса:

  1. Пессимистичная блокировка - захват строки на время транзакции. В PostgreSQL: SELECT ... FOR UPDATE. Другие транзакции ждут или получают ошибку. Гарантирует сериализацию, но снижает пропускную способность и требует коротких транзакций.

  2. Оптимистичная блокировка - версия или условие в UPDATE. Например, UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100. Если строка изменилась - rowcount = 0, повторяем или возвращаем ошибку. Не блокирует чтение, но требует обработки конфликтов.

  3. Атомарные операции на уровне СУБД - инкремент/декремент в одном выражении. Это частный случай оптимистичной блокировки, но без отдельной проверки версии.

Для переводов между двумя счетами важна атомарность всей операции: списание и зачисление должны быть в одной транзакции. Иначе возможен частичный перевод. В высоконагруженных системах используют компенсационные транзакции (saga) или шардирование с локальными блокировками.

На практике

Для типового backend на Python (FastAPI/Flask + SQLAlchemy/asyncpg) с PostgreSQL:

  • Используй SELECT ... FOR UPDATE для обоих счетов в строго определённом порядке (например, по id), чтобы избежать deadlock.
  • Проверяй баланс после блокировки, а не до.
  • Держи транзакцию короткой: без внешних вызовов, без длительных вычислений.
  • Для высокого конкурентного чтения - оптимистичная блокировка через version или условие в UPDATE.
  • В Redis-кэше не храни баланс как источник истины - только как кэш.

Пример кода

PYTHON
from sqlalchemy import select, update
from sqlalchemy.orm import Session
def transfer_money(session: Session, from_id: int, to_id: int, amount: int):
# Фиксируем порядок блокировки, чтобы избежать deadlock
first_id, second_id = sorted([from_id, to_id])
# Блокируем обе строки
accounts = session.execute(
select(Account).where(Account.id.in_([first_id, second_id])).with_for_update()
).scalars().all()
account_map = {acc.id: acc for acc in accounts}
from_acc = account_map[from_id]
to_acc = account_map[to_id]
if from_acc.balance < amount:
raise InsufficientFunds()
from_acc.balance -= amount
to_acc.balance += amount
session.commit()

Оптимистичный вариант:

PYTHON
def transfer_optimistic(session: Session, from_id: int, to_id: int, amount: int):
# Сначала списание с проверкой баланса
result = session.execute(
update(Account)
.where(Account.id == from_id, Account.balance >= amount)
.values(balance=Account.balance - amount)
)
if result.rowcount == 0:
raise InsufficientFunds()
# Затем зачисление
session.execute(
update(Account)
.where(Account.id == to_id)
.values(balance=Account.balance + amount)
)
session.commit()

Как отвечать на собеседовании

Начни с формулировки проблемы: гонка - это потерянное обновление. Затем предложи два подхода - пессимистичный и оптимистичный. Объясни trade-off: блокировки снижают параллелизм, но проще; оптимизм - быстрее при редких конфликтах, но требует retry. Упомяни deadlock и порядок блокировок. Если спросят про распределённые системы - расскажи про saga и идемпотентность. Покажи код на Python, но не увлекайся деталями ORM - интервьюеру важна логика.

Что проверяет интервьюер

  • Понимание атомарности и изоляции транзакций.
  • Умение видеть гонку в чтении-модификации-записи.
  • Знание механизмов СУБД: FOR UPDATE, rowcount, версионирование.
  • Осознание deadlock и способов его избежать.
  • Умение объяснить trade-off между производительностью и консистентностью.

Типичные ошибки

  • Проверка баланса до блокировки - гонка остаётся.
  • Блокировка только одного счёта - второй может измениться.
  • Разные порядки блокировок в разных вызовах - deadlock.
  • Долгие операции внутри транзакции - рост числа ожидающих.
  • Игнорирование rowcount при оптимистичной блокировке - потеря обновления.
  • Перевод без транзакции - частичное списание при ошибке.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью