Идемпотентность в Python: цена ключей и цифры под нагрузкой
Дубли платежей и повторные списания рождаются не в коде обработчика, а в том, как вы храните ключи идемпотентности. Разбираем механику блокировок, замеры и места, где схема ломается.

Платёжный сервис на FastAPI начал создавать по два заказа на один клик: клиент нажимал «Оплатить», получал таймаут, жал ещё раз — и уходило два списания. Обработчик был написан «правильно»: перед вставкой заказа проверялся ключ идемпотентности в таблице. Проблема в том, что проверка и вставка шли двумя отдельными запросами, а между ними успевал вклиниться второй параллельный запрос с тем же ключом. Классическая гонка «check-then-act». Ниже — как устроены разные способы гарантировать идемпотентность в Python-сервисе, во что каждый обходится по латентности и где схема ломается первой.
Что именно защищаем
Идемпотентность операции значит, что повторный вызов с тем же ключом даёт тот же результат и не порождает побочных эффектов дважды. Ключ идемпотентности — строка, которую клиент генерирует один раз на логическую операцию (обычно UUID) и присылает в заголовке или теле запроса. Сервер обязан: при первом вызове выполнить операцию и запомнить результат, при повторе — вернуть сохранённый результат, не выполняя побочный эффект заново.
Тонкость в слове «параллельном». Два запроса с одним ключом могут прийти одновременно, и оба увидят пустую таблицу, если проверка не атомарна. Значит, задача сводится не к «проверить наличие», а к «атомарно застолбить ключ или узнать, что его уже застолбили».
Идемпотентность держится не на проверке «есть ли запись», а на том, что попытку застолбить ключ выигрывает ровно один запрос. Всё остальное — кто как обрабатывает проигравших.
Сноска: побочный эффект
Побочный эффект — это то, что операция меняет во внешнем мире и что нельзя откатить простым возвратом ответа: списание денег, отправка письма, создание записи в БД, вызов стороннего API. Именно его нельзя выполнить дважды. Возврат кешированного ответа побочного эффекта не несёт, поэтому его повторять безопасно.
Четыре подхода и их цена
Рассматриваю сервис на Python 3.12, PostgreSQL 16 и Redis 7. Инвалидация и срок жизни ключей — отдельная тема, здесь только сам захват ключа и его цена по латентности.
1. Уникальный индекс в PostgreSQL
Самый надёжный вариант: колонка с ключом получает UNIQUE-ограничение, а вставку и побочный эффект держим в одной транзакции. Проигравший запрос ловит UniqueViolation и знает, что операцию уже выполняет кто-то другой.
# Python 3.12, psycopg 3.x, PostgreSQL 16
import psycopg
from psycopg.errors import UniqueViolation
def create_payment(conn, idem_key: str, amount: int) -> dict:
try:
with conn.transaction():
# столбим ключ и создаём заказ в одной транзакции
conn.execute(
"INSERT INTO idempotency_keys (key, status) VALUES (%s, 'in_progress')",
(idem_key,),
)
row = conn.execute(
"INSERT INTO payments (idem_key, amount) VALUES (%s, %s) RETURNING id",
(idem_key, amount),
).fetchone()
payment_id = row[0]
# фиксируем результат для будущих повторов
conn.execute(
"UPDATE idempotency_keys SET status='done', response_id=%s WHERE key=%s",
(payment_id, idem_key),
)
return {"payment_id": payment_id, "replayed": False}
except UniqueViolation:
# ключ уже застолблен другим запросом — возвращаем сохранённый результат
return _read_stored_result(conn, idem_key)
Цена: побочный эффект и запись ключа связаны транзакцией, разъехаться не могут. Но при высокой конкуренции по одному ключу проигравшие запросы ловят исключение и лезут читать результат — это лишний round-trip. И если побочный эффект долгий (внешний API на 800 мс), транзакция всё это время держит строку.
2. SELECT ... FOR UPDATE
Берём advisory- или строковую блокировку на ключ, пока идёт операция. Проигравшие ждут на блокировке, а не падают с исключением.
# PostgreSQL 16: блокировка по хешу ключа на время сессии
conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (idem_key,))
# внутри той же транзакции проверяем и выполняем
Цена: проигравшие не тратят попытки на исключение, но выстраиваются в очередь. Если операция висит на внешнем API, за ней копится хвост коннектов из пула. Пул psycopg на 20 соединений выгребается быстро, дальше — ожидание уже за пул.
3. Redis SET NX
Атомарный захват через SET key value NX PX ttl: команда либо ставит ключ и возвращает успех, либо ничего не делает. Один сетевой round-trip, без транзакции БД.
# redis-py 5.x, Redis 7
import redis
r = redis.Redis()
def try_acquire(idem_key: str, ttl_ms: int = 30000) -> bool:
# NX ставит ключ только если его нет; PX задаёт TTL в миллисекундах
return r.set(f"idem:{idem_key}", "in_progress", nx=True, px=ttl_ms) is True
Цена: захват дешёвый, но Redis — отдельное хранилище. Если процесс упал между захватом ключа и завершением побочного эффекта, ключ провисит до истечения TTL, и повтор в это окно вернёт «уже в работе» без результата. Согласованность между Redis и БД теперь ваша забота, а не транзакции.
4. ON CONFLICT DO NOTHING
Компромисс между первым и вторым: вставка с ON CONFLICT не бросает исключение, а возвращает пустой результат, если ключ занят.
row = conn.execute(
"INSERT INTO idempotency_keys (key, status) VALUES (%s, 'in_progress') "
"ON CONFLICT (key) DO NOTHING RETURNING key",
(idem_key,),
).fetchone()
if row is None:
return _read_stored_result(conn, idem_key) # ключ уже был
Цена: нет накладных расходов на обработку исключения в Python, поведение предсказуемо. Но проигравший всё равно делает второй запрос за результатом, и если первый запрос ещё in_progress, читать нечего — нужен ретрай с бэкоффом.
Сравнение
| Подход | Round-trip на захват | Проигравший | Риск при падении процесса |
|---|---|---|---|
| UNIQUE + транзакция | 1 (в транзакции) | Исключение + чтение | Транзакция откатывается целиком |
| FOR UPDATE / advisory lock | 1 + ожидание | Ждёт в очереди | Блокировка снимается на разрыве сессии |
| Redis SET NX | 1 (сеть до Redis) | Мгновенный отказ | Ключ висит до TTL, результата нет |
| ON CONFLICT DO NOTHING | 1 | Пустой результат + ретрай | Транзакция откатывается |
Как это мерить у себя
Абстрактные «Redis быстрее Postgres» бесполезны: цифры зависят от вашего железа, сети до БД и доли конкурентных запросов по одному ключу. Мерьте так:
- Латентность захвата отдельно от операции. Оберните только вызов захвата ключа таймером (
time.perf_counter()) и снимите p50/p95/p99. Захват на уникальном индексе в локальной сети до Postgres — единицы миллисекунд; та же операция при разрыве коннекта и открытии нового — десятки. Сеть до Redis добавляет свой RTT: измерьте егоredis-cli --latency, а не предполагайте. - Долю проигравших. Счётчик «сколько запросов пошли по ветке уже-занято». Если это доли процента — накладные расходы ветки проигравшего не важны, оптимизируйте счастливый путь. Если это десятки процентов (агрессивные ретраи клиента) — цена обработки проигравших выходит на первый план.
- Насыщение пула. Метрику «занятых коннектов» из пула psycopg под нагрузкой. Подход с
FOR UPDATEпри долгом побочном эффекте выедает пул раньше, чем упрётесь в CPU БД.
Нагрузку по одному ключу воспроизводите отдельным сценарием в locust или k6: N параллельных пользователей шлют один и тот же idem_key. Именно этот сценарий вскрывает гонку, обычный разброс ключей её маскирует.
Где всё ломается
- Ключ застолблён, побочный эффект не завершён. Статус
in_progressесть, результата нет, процесс упал. Повтор в это окно должен не возвращать пустоту, а либо ждать с бэкоффом, либо честно отвечать 409. Не отдавайте клиенту «успех» без сохранённого результата. - Разные тела при одном ключе. Клиент прислал тот же
idem_key, но с другой суммой. Правильное поведение — отклонить с ошибкой, а не молча вернуть старый результат. Храните хеш тела запроса рядом с ключом и сверяйте. - TTL короче времени операции. В варианте с Redis ключ протух, пока внешний API думал 40 секунд при TTL 30. Повтор проходит как новый — и вы получаете тот самый двойной эффект. TTL должен быть заведомо больше максимального времени операции с запасом.
- Идемпотентность на уровне HTTP, но не на уровне очереди. Если побочный эффект — публикация в очередь, а потребитель обрабатывает сообщение дважды при ретрае доставки, HTTP-идемпотентность вас не спасёт. Потребителю нужна своя.
Что делать дальше
Начните с подхода на уникальном индексе или ON CONFLICT, если побочный эффект живёт в том же PostgreSQL: одна транзакция закрывает и гонку, и согласованность бесплатно. Redis выбирайте, только когда захват должен быть быстрым и распределённым, и вы готовы вручную мирить Redis с БД при падениях.
Дальше настройте: хранение хеша тела запроса рядом с ключом, чтобы ловить конфликтующие повторы; политику для зависшего in_progress (таймаут перевода в failed фоновой задачей); и метрику доли проигравших запросов в проде — её рост обычно значит, что клиент ретраит слишком агрессивно, и лечить нужно там.
Автоэксперт / механик: Расшифровка OBD2-кодов, выбор б/у авто, диагностика по симптомам.
Спросить за 12 ₽Источники: PostgreSQL 16: Explicit Locking (advisory locks, FOR UPDATE), Redis SET command (NX, PX options)
Частые вопросы
А если у меня SQLite или MySQL, а не PostgreSQL?
Не сработало: два запроса всё равно создали дубль. В чём причина?
Нужен ли Redis, если всё уже в PostgreSQL?
Какой TTL ставить для ключа идемпотентности?
Как быть, если клиент прислал один ключ, но разные данные?
Идемпотентность на HTTP защищает потребителей очереди?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.