Вы когда-нибудь получали два списания с карты за одну покупку? Или видели дважды созданный заказ после одного клика? Это не баг платёжной системы — это баг вашего кода. Имя этому баг — отсутствие идемпотентности.
Что вообще происходит?
Представьте: пользователь нажал «Оплатить». Запрос улетел на сервер, но ответ не пришёл — таймаут. Клиент думает: «Что-то пошло не так» — и повторяет запрос. На сервере тем временем первый запрос успешно выполнился. Итог: деньги списаны дважды, пользователь в ярости, вы — на ночном дежурстве.
Или другой сценарий: Stripe отправил вам webhook payment.succeeded. Ваш сервис упал в момент обработки, Stripe отправил webhook ещё раз — и вы выполнили заказ дважды.
Оба сценария объединяет одно: операция выполнилась больше одного раза там, где должна была выполниться ровно один раз. Лечение называется идемпотентность.
Сухая теория (быстро, обещаю)
Операция называется идемпотентной, если её многократное выполнение с одними и теми же параметрами даёт тот же результат, что и однократное.
f(f(x)) = f(x)
В HTTP это уже встроено для некоторых методов:
Метод | Идемпотентный? | Почему |
|---|---|---|
| ✅ Да | Только читает, не меняет состояние |
| ✅ Да | «Установи значение X» — хоть 100 раз, результат тот же |
| ✅ Да | Удалить уже удалённое — ничего не изменится |
| ❌ Нет | Каждый вызов создаёт новый ресурс |
| ❌ Нет* | Зависит от реализации |
*PATCH /balance {amount: -100} — не идемпотентен. PATCH /status {status: "paid"} — идемпотентен.
Всё это хорошо в теории, но давайте разберём реальные боевые сценарии.
Сценарий 1: Повторный webhook
Почему вебхук приходит дважды
Большинство провайдеров работает по принципу at-least-once delivery — «доставим хотя бы один раз». Это значит: при любой нестабильности сети, перезапуске сервиса или просто медленном ответе (>10 секунд у GitHub, например) провайдер пришлёт webhook повторно.
Stripe, Shopify, PayPal, GitHub, Twilio — все они так делают. Это не баг их систем, это осознанный выбор: лучше доставить дважды, чем не доставить вовсе.
Как это выглядит в продакшне
10:00:01 → Stripe отправил webhook payment.succeeded (attempt 1) 10:00:01 → Ваш сервер получил, начал обработку 10:00:06 → БД зависла на 5 секунд 10:00:11 → Stripe: ответа нет, таймаут 10:00:11 → Stripe отправил webhook payment.succeeded (attempt 2) 10:00:11 → Ваш сервер получил и обработал (attempt 2) — ДУБЛЬ 10:00:12 → БД отвисла, обработка (attempt 1) завершена — СНОВА ДУБЛЬ
Итог: заказ создан дважды, товар отправлен дважды, клиент растерян.
Решение: таблица обработанных событий
Самый надёжный подход — хранить в базе идентификаторы уже обработанных вебхуков с уникальным constraint:
CREATE TABLE processed_webhooks ( event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ DEFAULT now(), payload JSONB );
async def handle_webhook(event_id: str, payload: dict): try: await db.execute( "INSERT INTO processed_webhooks (event_id, payload) VALUES ($1, $2)", event_id, json.dumps(payload) ) except UniqueViolationError: # Уже обрабатывали — возвращаем 200, молча игнорируем return {"status": "already_processed"} # Только сюда попадаем в первый раз await process_payment(payload) return {"status": "ok"}
Ключевой момент: сначала вставляем запись, потом обрабатываем. Не наоборот. Иначе два параллельных запроса оба пройдут проверку до того, как первый из них запишет результат.
Что делать, если event_id нет в payload?
Иногда провайдер не даёт уникального ID события. Тогда считаем хэш от тела запроса:
import hashlib, json def compute_event_hash(payload: dict) -> str: # Сортируем ключи для детерминированности canonical = json.dumps(payload, sort_keys=True) return hashlib.sha256(canonical.encode()).hexdigest()
Это работает, если тело идентично при повторной доставке (обычно так и есть).
TTL для таблицы
Хранить все event_id вечно избыточно. Большинство провайдеров повторяют вебхук в течение нескольких часов, максимум — нескольких дней. Достаточно хранить записи 7–30 дней и чистить их по крону.
Сценарий 2: Повторная задача в очереди
Проблема
RabbitMQ, Kafka, Celery, SQS — все очереди дают гарантию at-least-once. Если воркер упал в момент обработки и не успел подтвердить (ack) сообщение, брокер доставит его заново.
Типичная ошибка — делать ack в начале обработки:
# ❌ НЕПРАВИЛЬНО @app.task def send_invoice(order_id: int): # Если здесь упадём — задача будет считаться выполненной, # но инвойс не отправлен mark_as_processed(order_id) # ack generate_pdf(order_id) # вот тут и упало send_email(order_id)
Или обратная ошибка — делать побочный эффект несколько раз при ретрае:
# ❌ ТОЖЕ НЕПРАВИЛЬНО @app.task def send_invoice(order_id: int): generate_pdf(order_id) send_email(order_id) # письмо ушло дважды mark_as_processed(order_id)
Решение: состояние в базе + check-then-act
@app.task(bind=True, max_retries=3) def send_invoice(self, order_id: int): order = db.get_order(order_id) # Идемпотентная проверка if order.invoice_sent: logger.info(f"Invoice for {order_id} already sent, skipping") return try: pdf_path = generate_pdf(order_id) send_email(order.email, pdf_path) # Атомарно обновляем состояние db.execute( "UPDATE orders SET invoice_sent = true WHERE id = $1 AND invoice_sent = false", order_id ) except Exception as exc: raise self.retry(exc=exc, countdown=60)
Обратите внимание на AND invoice_sent = false в UPDATE — это защищает от race condition при параллельных воркерах.
Паттерн «уникальный ключ задачи»
В Celery можно использовать task_id как ключ идемпотентности:
from celery import uuid task_id = f"invoice-{order_id}" # детерминированный ID send_invoice.apply_async( args=[order_id], task_id=task_id # повторный вызов с тем же ID будет проигнорирован )
В Kafka идемпотентность достигается через enable.idempotence=true на продюсере и isolation.level=read_committed на консьюмере.
Сценарий 3: Двойной HTTP-запрос
Почему запрос приходит дважды
Причин несколько, и все они происходят в продакшне регулярно:
Пользователь дважды кликнул кнопку — классика, лечится на фронте disabled-кнопкой, но это не защита на уровне сервера
Клиент сделал retry — библиотеки вроде
axios-retry,urllib3,Faradayделают повторный запрос при 5xx или таймаутеLoad balancer сделал retry — AWS ALB, Nginx upstream retry
Мобильная сеть переподключилась — запрос ушёл дважды на уровне сети
Решение: Idempotency-Key в заголовке
Это стандартный паттерн, который используют Stripe, Adyen, Braintree и многие другие. Клиент генерирует уникальный ключ (UUID v4) и передаёт его в заголовке:
POST /api/payments HTTP/1.1 Content-Type: application/json Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 { "amount": 1990, "currency": "RUB", "card_token": "tok_abc123" }
Сервер хранит ключ и закэшированный ответ:
async def create_payment(request: Request) -> Response: idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: # Проверяем кэш cached = await redis.get(f"idem:{idempotency_key}") if cached: return JSONResponse( content=json.loads(cached), status_code=200, headers={"X-Idempotent-Replayed": "true"} ) # Выполняем операцию payment = await charge_card(request.body) response_body = payment.to_dict() if idempotency_key: # Кэшируем ответ на 24 часа await redis.setex( f"idem:{idempotency_key}", 86400, json.dumps(response_body) ) return JSONResponse(content=response_body, status_code=201)
Защита от подмены payload
Важный нюанс: если клиент прислал тот же Idempotency-Key, но с другим телом — это ошибка клиента, нужно вернуть 422 Unprocessable Entity:
if cached_request_hash != hash_of_current_request: raise HTTPException( status_code=422, detail="Idempotency key reused with different payload" )
Race condition при параллельных запросах
Если два запроса с одним ключом пришли одновременно, оба могут пройти проверку кэша до того, как первый запишет результат. Решение — distributed lock:
async def create_payment(request: Request) -> Response: key = request.headers.get("Idempotency-Key") lock_key = f"lock:idem:{key}" async with redis_lock(lock_key, timeout=30): # Внутри лока проверяем снова cached = await redis.get(f"idem:{key}") if cached: return JSONResponse(json.loads(cached)) payment = await charge_card(request.body) await redis.setex(f"idem:{key}", 86400, payment.to_json()) return JSONResponse(payment.to_dict(), status_code=201)
Сценарий 4: Повторный платёж, списание, создание сущности
Самый болезненный кейс
Финансовые операции — это место, где цена ошибки максимальна. Двойное списание = потеря клиента. Двойное зачисление = финансовый убыток компании.
Типичная проблема: пользователь нажал «Оплатить», приложение зависло, пользователь нажал снова.
Паттерн: «найди или создай» (get-or-create)
async def process_payment(order_id: str, amount: int) -> Payment: # Сначала ищем существующий платёж для этого заказа existing = await db.fetchrow( "SELECT * FROM payments WHERE order_id = $1 AND status != 'failed'", order_id ) if existing: return Payment.from_row(existing) # Создаём новый — с уникальным constraint на order_id try: payment = await db.fetchrow( """ INSERT INTO payments (order_id, amount, status, created_at) VALUES ($1, $2, 'pending', now()) RETURNING * """, order_id, amount ) await charge_external_api(payment['id'], amount) await db.execute( "UPDATE payments SET status = 'completed' WHERE id = $1", payment['id'] ) return Payment.from_row(payment) except UniqueViolationError: # Параллельный запрос уже создал платёж — возвращаем его return await db.fetchrow( "SELECT * FROM payments WHERE order_id = $1", order_id )
-- Уникальный индекс предотвращает дубли на уровне БД CREATE UNIQUE INDEX payments_order_id_unique ON payments(order_id) WHERE status != 'failed';
Паттерн: статусная машина
Для сложных процессов (оплата → резервирование → отправка) используйте явные статусы:
CREATED → PAYMENT_PENDING → PAYMENT_COMPLETED → FULFILLMENT_STARTED → COMPLETED ↘ FULFILLMENT_FAILED
Каждый переход — атомарный UPDATE с проверкой текущего состояния:
-- Переходим в PAYMENT_PENDING только если статус CREATED UPDATE orders SET status = 'payment_pending', updated_at = now() WHERE id = $1 AND status = 'created' RETURNING id;
Если UPDATE вернул 0 строк — значит, кто-то уже сменил статус. Это конкурентный запрос, его нужно отклонить или вернуть текущее состояние.
Создание сущностей: natural key вместо суррогатного
Если создаёте пользователя по email, товар по артикулу или заказ по номеру — делайте UNIQUE на этом поле:
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, -- естественный уникальный ключ ... );
async def create_user(email: str, name: str) -> User: try: return await db.fetchrow( "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *", email, name ) except UniqueViolationError: # Пользователь уже существует — это не ошибка, это идемпотентность return await db.fetchrow( "SELECT * FROM users WHERE email = $1", email )
Общие принципы: чеклист для идемпотентного кода
После разбора четырёх сценариев можно выделить универсальные правила:
1. Всегда имейте уникальный ключ операции
Это может быть event_id от провайдера, Idempotency-Key от клиента, или order_id / user_id из бизнес-логики. Без ключа идемпотентность невозможна.
2. Используйте UNIQUE constraint в базе данных как последний рубеж
БД — единственный компонент в вашей системе, который гарантирует атомарность. Проверка на уровне кода (if exists: return) недостаточна при параллельных запросах.
3. Сначала проверяй — потом действуй
Паттерн check-then-act: сначала убеждаемся, что операция не выполнялась, потом выполняем. В конце обновляем состояние.
4. Возвращай одинаковый ответ
При повторном запросе с тем же ключом возвращай тот же HTTP статус и тот же body, что и при первом. Клиент не должен знать, что это replay.
5. Всегда возвращай 2xx на дубли вебхуков
Даже если вы проигнорировали повторный вебхук — вернитe 200 OK. Иначе провайдер решит, что доставка не удалась, и будет слать его снова.
6. Ограничивай TTL кэша идемпотентности
Idempotency-Key не нужно хранить вечно. Типичный TTL: 24–48 часов для платежей, 7–30 дней для вебхуков.
7. Логируй idempotency hits
Резкий рост повторных запросов — сигнал проблемы на стороне клиента или в инфраструктуре. Метрика idempotency_hit_rate должна быть в вашем дашборде.
Что выбрать для хранения ключей: Redis vs PostgreSQL?
Частый вопрос — где хранить processed IDs и кэши ответов.
Критерий | Redis | PostgreSQL |
|---|---|---|
Скорость проверки | ⚡ Очень быстро (~1 мс) | 🐢 Медленнее (~5-10 мс) |
TTL из коробки | ✅ Да, | ❌ Нужен cron или pg_partman |
Атомарность с основными данными | ❌ Нет | ✅ В одной транзакции |
Риск потери данных | ⚠️ При рестарте без AOF | ✅ ACID |
Сложность | Простой | Чуть сложнее |
Рекомендация: для вебхуков и Idempotency-Key — Redis (быстро, TTL бесплатен). Для финансовых операций — PostgreSQL (атомарность с основными данными критична).
Можно комбинировать: Redis как быстрый первый уровень, PostgreSQL как надёжный второй.
Пример: middleware для Idempotency-Key на FastAPI
Чтобы не писать проверку в каждом эндпоинте, вынесем логику в middleware:
from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware import redis.asyncio as aioredis import json, hashlib class IdempotencyMiddleware(BaseHTTPMiddleware): def __init__(self, app, redis_client: aioredis.Redis): super().__init__(app) self.redis = redis_client async def dispatch(self, request: Request, call_next) -> Response: # Применяем только к POST/PATCH if request.method not in ("POST", "PATCH"): return await call_next(request) idem_key = request.headers.get("Idempotency-Key") if not idem_key: return await call_next(request) cache_key = f"idem:{idem_key}" # Читаем тело для хэша body = await request.body() body_hash = hashlib.sha256(body).hexdigest() # Проверяем кэш cached = await self.redis.get(cache_key) if cached: data = json.loads(cached) # Проверяем, что payload тот же if data["body_hash"] != body_hash: return Response( content='{"error": "Idempotency key reused with different payload"}', status_code=422, media_type="application/json" ) return Response( content=data["body"], status_code=data["status_code"], media_type="application/json", headers={"X-Idempotent-Replayed": "true"} ) # Выполняем запрос response = await call_next(request) # Кэшируем успешный ответ if response.status_code < 500: response_body = b"" async for chunk in response.body_iterator: response_body += chunk await self.redis.setex( cache_key, 86400, # 24 часа json.dumps({ "status_code": response.status_code, "body": response_body.decode(), "body_hash": body_hash }) ) return Response( content=response_body, status_code=response.status_code, media_type="application/json" ) return response
Антипаттерны, которые стоит избегать
❌ Проверка существования без транзакции
# Между SELECT и INSERT может вставиться параллельный запрос if not await db.exists("SELECT 1 FROM orders WHERE id = $1", order_id): await db.execute("INSERT INTO orders ...")
❌ Идемпотентность только на уровне кода без DB constraint
# Если два воркера одновременно прошли эту проверку — оба создадут запись if order.status == "pending": process_order(order)
❌ Хранение только флага "обработан", без кэширования ответа
Клиент при повторном запросе получит другой ответ (например, пустой 200 вместо созданного ресурса), что ломает логику на его стороне.
❌ Использование предсказуемых idempotency keys
# Плохо — легко угадать и эксплуатировать key = f"order-{order_id}" # Хорошо — криптографически случайный UUID import uuid key = str(uuid.uuid4())
Итого
Идемпотентность — это не сложная математика, это набор конкретных технических решений:
Вебхуки: храни обработанные
event_idв базе с UNIQUE constraintОчереди: проверяй статус в БД перед обработкой, обновляй атомарно
HTTP API: принимай
Idempotency-Keyв заголовке, кэшируй ответы в RedisПлатежи и сущности: используй natural key + UNIQUE index, паттерн get-or-create
Любая распределённая система рано или поздно столкнётся с дублями — это не вопрос «если», а вопрос «когда». Идемпотентность — это не усложнение кода, это инвестиция в спокойный сон без ночных дежурств.
Если статья была полезна — поделитесь с коллегами, которые ещё не знают, почему их платёжная система иногда списывает деньги дважды.
