Вы когда-нибудь получали два списания с карты за одну покупку? Или видели дважды созданный заказ после одного клика? Это не баг платёжной системы — это баг вашего кода. Имя этому баг — отсутствие идемпотентности.


Что вообще происходит?

Представьте: пользователь нажал «Оплатить». Запрос улетел на сервер, но ответ не пришёл — таймаут. Клиент думает: «Что-то пошло не так» — и повторяет запрос. На сервере тем временем первый запрос успешно выполнился. Итог: деньги списаны дважды, пользователь в ярости, вы — на ночном дежурстве.

Или другой сценарий: Stripe отправил вам webhook payment.succeeded. Ваш сервис упал в момент обработки, Stripe отправил webhook ещё раз — и вы выполнили заказ дважды.

Оба сценария объединяет одно: операция выполнилась больше одного раза там, где должна была выполниться ровно один раз. Лечение называется идемпотентность.


Сухая теория (быстро, обещаю)

Операция называется идемпотентной, если её многократное выполнение с одними и теми же параметрами даёт тот же результат, что и однократное.

f(f(x)) = f(x)

В HTTP это уже встроено для некоторых методов:

Метод

Идемпотентный?

Почему

GET

✅ Да

Только читает, не меняет состояние

PUT

✅ Да

«Установи значение X» — хоть 100 раз, результат тот же

DELETE

✅ Да

Удалить уже удалённое — ничего не изменится

POST

❌ Нет

Каждый вызов создаёт новый ресурс

PATCH

❌ Нет*

Зависит от реализации

*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 из коробки

✅ Да, SETEX

❌ Нужен 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

Любая распределённая система рано или поздно столкнётся с дублями — это не вопрос «если», а вопрос «когда». Идемпотентность — это не усложнение кода, это инвестиция в спокойный сон без ночных дежурств.


Если статья была полезна — поделитесь с коллегами, которые ещё не знают, почему их платёжная система иногда списывает деньги дважды.