Вы когда-нибудь получали два списания с карты за одну покупку? Или видели дважды созданный заказ после одного клика? Это не баг платёжной системы — это баг вашего кода. Имя этому баг — отсутствие идемпотентности.
Что вообще происходит?
Представьте: пользователь нажал «Оплатить». Запрос улетел на сервер, но ответ не пришёл — таймаут. Клиент думает: «Что-то пошло не так» — и повторяет запрос. На сервере тем временем первый запрос успешно выполнился. Итог: деньги списаны дважды, пользователь в ярости, вы — на ночном дежурстве.
Или другой сценарий: 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
Любая распределённая система рано или поздно столкнётся с дублями — это не вопрос «если», а вопрос «когда». Идемпотентность — это не усложнение кода, это инвестиция в спокойный сон без ночных дежурств.
Если статья была полезна — поделитесь с коллегами, которые ещё не знают, почему их платёжная система иногда списывает деньги дважды.
Автор: NGdust
