Idempotency в DRF под нагрузкой: что ломается первым
Мобильный клиент повторил POST /payments из-за таймаута — и списал деньги дважды. Разбираем, почему наивная идемпотентность на DRF ломается под гонкой запросов и как её чинить.

Клиент отправляет POST /api/payments. Сеть моргнула, ответ не дошёл за 30 секунд, клиент повторил запрос. На бэкенде создались два платежа на одну сумму. Классический сценарий, который в проде выстреливает не на первом запросе, а на трёхстах параллельных повторах от флапающих мобильных клиентов. Ключ идемпотентности вроде бы решает проблему — но наивная реализация на Django REST Framework ломается ровно там, где два запроса с одним ключом приходят одновременно.
Разбираем, что именно отказывает первым при конкурентных повторах, почему get_or_create здесь не спасает и как построить хранилище ключей, устойчивое к гонке. Речь про DRF 3.14/3.15 и PostgreSQL 14+; поведение блокировок отличается у других СУБД.
Наивная реализация и где она рвётся
Первое, что приходит в голову — проверить, видели ли мы такой ключ, и если нет, обработать запрос. Псевдокод логики (схематично):
# схематично, НЕ используйте в проде
key = request.headers.get("Idempotency-Key")
if IdempotencyRecord.objects.filter(key=key).exists():
return cached_response(key)
process_payment(...)
IdempotencyRecord.objects.create(key=key, response=...)Между exists() и create() есть окно. Два параллельных запроса с одним ключом оба проходят проверку exists() (записи ещё нет), оба вызывают process_payment, оба создают платёж. Проверка на существование без блокировки на уровне БД не защищает от гонки в принципе — она только уменьшает вероятность.
Под нагрузкой ломается именно это окно. При одиночных повторах с интервалом в секунды первый запрос успевает записать ключ. При всплеске повторов (клиент ретраит по таймауту, а таймаут короткий) запросы приходят в одну и ту же долю секунды и попадают в окно все разом.
Идемпотентность — это не «проверить, был ли ключ». Это «атомарно застолбить ключ до начала обработки». Разница в порядке операций, и под нагрузкой она стоит денег.
Что отказывает по порядку
Если начать нагружать эндпоинт параллельными повторами, отказы идут в предсказуемой очерёдности.
- Первым — окно проверки. Логика «проверил-обработал-записал» пропускает дубли. Это база, с этого начинается всё остальное.
- Вторым — уникальный индекс без обработки конфликта. Вы добавили
unique=Trueна поле ключа. Теперь второйINSERTпадает сIntegrityError, но если его не поймать — клиент получит 500 вместо кэшированного ответа первого запроса. - Третьим — состояние «в процессе». Ключ застолбили, обработка идёт. Приходит третий повтор. Записи о готовом ответе ещё нет, а платёж уже создаётся. Без явного статуса записи вы не отличите «ещё обрабатывается» от «не начиналось».
- Четвёртым — транзакционные границы. Если запись ключа и создание платежа в разных транзакциях, откат платежа оставит висящий ключ, и повтор вернёт кэшированный успех при фактически несозданном платеже.
Атомарный захват ключа
Правильный порядок: сначала пытаемся вставить запись ключа со статусом «в обработке», полагаясь на уникальный индекс как на замок. Если вставка прошла — мы первые, обрабатываем. Если упала на конфликте — кто-то уже занял ключ.
В PostgreSQL это делается через INSERT ... ON CONFLICT DO NOTHING и проверку числа затронутых строк. В Django ORM 4.1+ доступен параметр update_conflicts/ignore_conflicts у bulk_create. Для одиночной вставки удобнее ловить IntegrityError в транзакции. Точную сигнатуру bulk_create для вашей версии сверьте в документации Django по QuerySet API — набор поддерживаемых аргументов менялся между 4.1 и 5.x.
from django.db import transaction, IntegrityError
from rest_framework.response import Response
def handle_with_idempotency(request, key, process):
# Пытаемся застолбить ключ. unique-индекс на поле key
# играет роль замка на уровне БД.
try:
with transaction.atomic():
record = IdempotencyRecord.objects.create(
key=key,
status="processing",
)
except IntegrityError:
# Ключ уже занят другим запросом.
record = IdempotencyRecord.objects.get(key=key)
if record.status == "done":
return Response(record.response_body,
status=record.response_code)
# Ещё обрабатывается — просим клиента повторить позже.
return Response({"detail": "request in progress"},
status=409)
# Мы застолбили ключ первыми — обрабатываем.
with transaction.atomic():
result = process(request)
record.status = "done"
record.response_body = result.data
record.response_code = result.status_code
record.save(update_fields=[
"status", "response_body", "response_code",
])
return resultКлючевой момент — статус processing и ответ 409 на конкурентный повтор. Клиент повторит через backoff, к тому времени первый запрос дойдёт до done. Возвращать 409 честнее, чем блокировать соединение в ожидании: под нагрузкой висящие соединения кончатся быстрее, чем воркеры Gunicorn.
Почему не SELECT FOR UPDATE на несуществующей строке
Соблазн взять select_for_update() и заблокировать строку ключа не работает: блокировать нечего, строки ещё нет. Row-level lock в PostgreSQL берётся на существующую строку. До первого INSERT блокировать можно только через advisory-локи или сам уникальный индекс. Индекс проще: он уже нужен для дедупликации и не требует ручного освобождения.
Транзакция и висящий статус processing
Главная граничная ситуация: воркер застолбил ключ, начал обработку и упал — OOM, деплой, kill. Запись осталась в статусе processing навсегда. Все повторы получают 409 и платёж не проходит вообще.
Лечится TTL на статус processing. Если запись висит в processing дольше разумного времени обработки (скажем, дольше 30 секунд), считаем её протухшей и разрешаем перезахват. Проверку встраивают в ветку IntegrityError:
- запись
done— вернуть кэш; - запись
processing, возраст меньше TTL — вернуть 409; - запись
processing, возраст больше TTL — атомарно обновитьcreated_atи статус через условныйUPDATE ... WHERE status='processing' AND created_at < :threshold, и если строка обновилась — обрабатывать заново.
Условный UPDATE с проверкой числа затронутых строк снова спасает от гонки двух перезахватов: обновит строку только один запрос.
Где хранить: БД или Redis
Хранилище ключей — отдельное решение с ценой.
| Критерий | PostgreSQL (та же БД) | Redis |
|---|---|---|
| Атомарность захвата | Уникальный индекс, в одной транзакции с платежом | SET NX, но отдельно от транзакции платежа |
| Согласованность с бизнес-данными | Полная: откат платежа откатывает ключ | Нет: платёж и ключ в разных хранилищах |
| TTL | Ручной, через поле и чистку | Встроенный, EXPIRE |
| Нагрузка на основную БД | Растёт: лишние INSERT/UPDATE | Снимает нагрузку с БД |
Для платежей и других критичных операций храните ключ в той же БД и той же транзакции, что и результат — только так откат обработки гарантированно откатывает и застолбленный ключ. Redis выигрывает по TTL и нагрузке, но платит рассинхроном: платёж откатился, а ключ в Redis остался, и повтор вернёт ложный успех. Для некритичных идемпотентных операций (лайк, подписка) рассинхрон терпим и Redis оправдан.
Что мерить в проде
Синтетика не покажет гонку — она вылезает на реальном профиле повторов. Что смотреть:
- Доля 409 к 2xx на идемпотентных эндпоинтах. Рост 409 — сигнал, что клиенты ретраят агрессивно, либо обработка замедлилась и запросы наслаиваются.
- Записи в статусе processing старше TTL. Ненулевое число — воркеры падают в середине обработки. Отдельный алерт.
- Дубли бизнес-сущностей с разными idempotency-ключами. Если клиент генерирует новый ключ на каждый ретрай — идемпотентность не работает на его стороне, и это уже не ваш баг, но деньги задвоятся.
Начните с того, что переведёте один критичный POST на схему «атомарный захват + статус + TTL», прогоните нагрузочный тест с параллельными повторами одного ключа (например, 50 одновременных запросов с одним Idempotency-Key) и убедитесь, что бизнес-сущность создалась ровно одна, а остальные получили либо 409, либо кэшированный ответ. Инвалидация и чистка протухших ключей — отдельная тема, здесь её не разворачиваем.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Django QuerySet API: bulk_create и conflicts, PostgreSQL: INSERT ON CONFLICT
Частые вопросы
А если у меня MySQL, а не PostgreSQL?
Не сработало: два платежа всё равно создаются под нагрузкой
Клиент не присылает Idempotency-Key — что делать?
Почему 409, а не заблокировать запрос до готовности первого?
Как долго хранить записи idempotency-ключей?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.