> Как решить проблему повторного запроса при переводе денег, чтобы избежать двойного списания? (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) или транзакционная таблица в БД.
  • Важно логировать ключ и результат, чтобы можно было расследовать инциденты.

Пример кода

PYTHON
from fastapi import FastAPI, Header, HTTPException
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
app = 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 NOTHING
RETURNING 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_records
SET status = 'done', response = :resp
WHERE 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.

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

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