- PVSM.RU - https://www.pvsm.ru -
Вы когда-нибудь получали два списания с карты за одну покупку? Или видели дважды созданный заказ после одного клика? Это не баг платёжной системы — это баг вашего кода. Имя этому баг — отсутствие идемпотентности.
Представьте: пользователь нажал «Оплатить». Запрос улетел на сервер, но ответ не пришёл — таймаут. Клиент думает: «Что-то пошло не так» — и повторяет запрос. На сервере тем временем первый запрос успешно выполнился. Итог: деньги списаны дважды, пользователь в ярости, вы — на ночном дежурстве.
Или другой сценарий: Stripe отправил вам webhook payment.succeeded. Ваш сервис упал в момент обработки, Stripe отправил webhook ещё раз — и вы выполнили заказ дважды.
Оба сценария объединяет одно: операция выполнилась больше одного раза там, где должна была выполниться ровно один раз. Лечение называется идемпотентность.
Операция называется идемпотентной, если её многократное выполнение с одними и теми же параметрами даёт тот же результат, что и однократное.
f(f(x)) = f(x)
В HTTP это уже встроено для некоторых методов:
|
Метод |
Идемпотентный? |
Почему |
|---|---|---|
|
|
✅ Да |
Только читает, не меняет состояние |
|
|
✅ Да |
«Установи значение X» — хоть 100 раз, результат тот же |
|
|
✅ Да |
Удалить уже удалённое — ничего не изменится |
|
|
❌ Нет |
Каждый вызов создаёт новый ресурс |
|
|
❌ Нет* |
Зависит от реализации |
*PATCH /balance {amount: -100} — не идемпотентен. PATCH /status {status: "paid"} — идемпотентен.
Всё это хорошо в теории, но давайте разберём реальные боевые сценарии.
Большинство провайдеров работает по принципу 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"}
Ключевой момент: сначала вставляем запись, потом обрабатываем. Не наоборот. Иначе два параллельных запроса оба пройдут проверку до того, как первый из них запишет результат.
Иногда провайдер не даёт уникального ID события. Тогда считаем хэш от тела запроса:
import hashlib, json
def compute_event_hash(payload: dict) -> str:
# Сортируем ключи для детерминированности
canonical = json.dumps(payload, sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()
Это работает, если тело идентично при повторной доставке (обычно так и есть).
Хранить все event_id вечно избыточно. Большинство провайдеров повторяют вебхук в течение нескольких часов, максимум — нескольких дней. Достаточно хранить записи 7–30 дней и чистить их по крону.
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)
@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 на консьюмере.
Причин несколько, и все они происходят в продакшне регулярно:
Пользователь дважды кликнул кнопку — классика, лечится на фронте disabled-кнопкой, но это не защита на уровне сервера
Клиент сделал retry — библиотеки вроде axios-retry, urllib3, Faraday делают повторный запрос при 5xx или таймауте
Load balancer сделал retry — AWS ALB, Nginx upstream retry
Мобильная сеть переподключилась — запрос ушёл дважды на уровне сети
Это стандартный паттерн, который используют 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)
Важный нюанс: если клиент прислал тот же 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"
)
Если два запроса с одним ключом пришли одновременно, оба могут пройти проверку кэша до того, как первый запишет результат. Решение — 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)
Финансовые операции — это место, где цена ошибки максимальна. Двойное списание = потеря клиента. Двойное зачисление = финансовый убыток компании.
Типичная проблема: пользователь нажал «Оплатить», приложение зависло, пользователь нажал снова.
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 строк — значит, кто-то уже сменил статус. Это конкурентный запрос, его нужно отклонить или вернуть текущее состояние.
Если создаёте пользователя по 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 должна быть в вашем дашборде.
Частый вопрос — где хранить processed IDs и кэши ответов.
|
Критерий |
Redis |
PostgreSQL |
|---|---|---|
|
Скорость проверки |
⚡ Очень быстро (~1 мс) |
🐢 Медленнее (~5-10 мс) |
|
TTL из коробки |
✅ Да, |
❌ Нужен cron или pg_partman |
|
Атомарность с основными данными |
❌ Нет |
✅ В одной транзакции |
|
Риск потери данных |
⚠️ При рестарте без AOF |
✅ ACID |
|
Сложность |
Простой |
Чуть сложнее |
Рекомендация: для вебхуков и Idempotency-Key — Redis (быстро, TTL бесплатен). Для финансовых операций — PostgreSQL (атомарность с основными данными критична).
Можно комбинировать: Redis как быстрый первый уровень, PostgreSQL как надёжный второй.
Чтобы не писать проверку в каждом эндпоинте, вынесем логику в 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
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/backend/447503
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1013716/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1013716
Нажмите здесь для печати.