> Как решить проблему повторного запроса при переводе денег, чтобы избежать двойного списания? (Python)
Уровень: senior · Роль: backend · Язык: Python · Категория: Технические вопросы
Компании: HeadHunter
Стек: Python
> Пример ответа
Короткий ответ
Проблема решается идемпотентностью: клиент генерирует уникальный idempotency_key для каждого запроса, а сервер сохраняет результат обработки по этому ключу. При повторном запросе с тем же ключом возвращается сохранённый ответ без повторного списания. Дополнительно используется атомарная операция в БД (например, INSERT ... ON CONFLICT DO NOTHING) и транзакция с блокировкой, чтобы гарантировать единственность обработки даже при конкурентных запросах.
Подробное объяснение
Идемпотентность - ключевой паттерн для финансовых операций. Клиент передаёт заголовок Idempotency-Key (UUID или другой уникальный идентификатор). Сервер проверяет, существует ли запись с таким ключом:
- если нет - выполняет перевод, сохраняет результат (статус, сумму, счёт) в таблицу
idempotency_recordsв той же транзакции, что и списание; - если есть - возвращает сохранённый ответ, не выполняя операцию повторно.
Критично, чтобы проверка и запись были атомарными. Используется уникальный индекс на idempotency_key и INSERT ... ON CONFLICT DO NOTHING - если запись уже существует, значит, операция уже обрабатывается или обработана. Это защищает от гонок при параллельных запросах.
Дополнительно применяется блокировка строки (SELECT ... FOR UPDATE) на счёте отправителя, чтобы два одновременных перевода не ушли в минус. Транзакция должна быть изолированной (обычно READ COMMITTED достаточно при блокировках).
На практике
- Ключ генерируется на клиенте и переиспользуется при ретраях (например, при сетевом сбое).
- Срок жизни ключа - обычно 24 часа, затем запись можно архивировать.
- Ответ должен быть идемпотентным: если операция ещё выполняется, возвращается
409 Conflictили202 Accepted, а не ошибка. - Для распределённых систем используется распределённая блокировка (Redis, ZooKeeper) или транзакционная таблица в БД.
- Важно логировать ключ и результат, чтобы можно было расследовать инциденты.
Пример кода
PYTHONfrom fastapi import FastAPI, Header, HTTPExceptionfrom sqlalchemy import create_engine, textfrom sqlalchemy.orm import sessionmakerapp = FastAPI()engine = create_engine("postgresql://...")Session = sessionmaker(bind=engine)def transfer_money(session, from_acc, to_acc, amount, idempotency_key):# атомарная вставка ключаresult = session.execute(text("""INSERT INTO idempotency_records (key, status, response)VALUES (:key, 'processing', NULL)ON CONFLICT (key) DO NOTHINGRETURNING status"""),{"key": idempotency_key})row = result.fetchone()if row is None:# ключ уже существует - возвращаем сохранённый ответexisting = session.execute(text("SELECT response FROM idempotency_records WHERE key = :key"),{"key": idempotency_key}).fetchone()return existing.response# блокируем счёт отправителяsession.execute(text("SELECT balance FROM accounts WHERE id = :acc FOR UPDATE"),{"acc": from_acc})# проверка баланса и списание# ... перевод ...session.execute(text("""UPDATE idempotency_recordsSET status = 'done', response = :respWHERE key = :key"""),{"key": idempotency_key, "resp": "success"})session.commit()return "success"@app.post("/transfer")def transfer(from_acc: int, to_acc: int, amount: float,idempotency_key: str = Header(...)):session = Session()try:result = transfer_money(session, from_acc, to_acc, amount, idempotency_key)return {"status": result}except Exception:session.rollback()raise HTTPException(status_code=500)finally:session.close()
Как отвечать на собеседовании
Начните с идемпотентности как основного механизма, затем объясните, почему простой проверки "был ли такой запрос" недостаточно - нужна атомарность. Упомяните уникальный индекс и ON CONFLICT. Покажите понимание конкурентности: блокировка строк, транзакции, возможные проблемы с репликацией в распределённых системах. Если спросят про отмену операции - расскажите про компенсирующие транзакции (saga pattern). Не углубляйтесь в детали реализации конкретного фреймворка, если не просят.
Что проверяет интервьюер
- Понимание идемпотентности и её применения в финансовых системах.
- Знание атомарных операций в БД и обработки конкурентных запросов.
- Умение проектировать API с учётом повторных запросов и сетевых сбоев.
- Практический опыт с транзакциями, блокировками, обработкой ошибок.
- Способность объяснить trade-off между консистентностью и производительностью.
Типичные ошибки
- Предложение просто проверять "есть ли запись" без атомарности - гонка приведёт к двойному списанию.
- Игнорирование блокировки счёта - два параллельных перевода могут уйти в минус.
- Возврат ошибки при повторном запросе вместо сохранённого ответа - клиент не сможет понять, что операция прошла.
- Хранение ключа в памяти без персистентности - после рестарта сервера защита пропадёт.
- Неверный выбор статуса:
409вместо200с тем же телом ответа - нарушение идемпотентности на уровне API.
> Похожие задачи по Python
Какова иерархия процессов, потоков и asyncio в Python?
Что можно сделать для увеличения стабильности сетевых запросов?
Помогает ли транзакция с уровнем изоляции сериализации при race condition?
Как решить проблему гонки при переводе денег между счетами?
> Похожие задачи по backend
Какова иерархия процессов, потоков и asyncio в Python?
Что можно сделать для увеличения стабильности сетевых запросов?
Помогает ли транзакция с уровнем изоляции сериализации при race condition?
Как решить проблему гонки при переводе денег между счетами?
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью