Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Идемпотентные API и фоновые задачи: как убрать дубли заказов и двойные списания

Разработка и технологии24 марта 2026 г.
Дубли запросов и повторная обработка задач приводят к лишним списаниям, испорченной статистике и затратам саппорта. Разбираем, как сделать API и фоновые процессы идемпотентными: ключ идемпотентности, блокировки, дедупликация и аккуратная работа с побочными эффектами. В статье есть готовые схемы таблиц, примеры кода и чек‑лист внедрения за 1–2 спринта.
Идемпотентные API и фоновые задачи: как убрать дубли заказов и двойные списания

  • Зачем бизнесу идемпотентность
  • Где и почему возникают дубли
  • Дизайн API с ключом идемпотентности
  • Пример: реализация на PostgreSQL + FastAPI
  • Фоновые задачи без дублей: блокировки и очереди
  • Идемпотентные побочные эффекты: списания и проводки
  • Хранение ключей, TTL и очистка
  • Наблюдаемость и обработка ошибок
  • Безопасность и гигиена ключей
  • План внедрения за 1–2 спринта
  • Чек‑лист перед релизом
  • Частые вопросы

Зачем бизнесу идемпотентность

Дубли запросов — это деньги и репутация. Клиент нажал кнопку «Оплатить» дважды, браузер повторил запрос после обрыва, интеграция сделала ретрай. Без идемпотентности вы получите:

  • двойные списания и возвраты, которые стоят комиссии и часов саппорта;
  • разъехавшуюся аналитику и неприятные разборы инцидентов;
  • блокировки релизов из‑за страха «что-то продублировать».

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

Где и почему возникают дубли

  • Пользователь перезагрузил страницу во время оплаты.
  • Мобильное приложение повторило запрос после таймаута.
  • Прокси или шлюз сделал автоматический ретрай.
  • Фоновый обработчик перезапустился посреди выполнения.
  • Очередь доставила сообщение повторно (at‑least‑once — типично для надёжных очередей).

Вы не контролируете сеть и перезапуски, но контролируете свою архитектуру и хранение фактов. Это и есть опорная точка для идемпотентности.

Дизайн API с ключом идемпотентности

Базовая идея проста: клиент присылает уникальный ключ намерения. Сервер:

  1. сопоставляет ключу хеш тела запроса;
  2. если ключ новый — выполняет бизнес‑операцию и кэширует ответ под этим ключом;
  3. если ключ уже видели — возвращает сохранённый ответ;
  4. если ключ одинаковый, а тело запроса другое — отказывает (ошибка 409), чтобы не смешивать разные намерения.

Как формировать ключ:

  • Клиент генерирует криптослучайный идентификатор (например, UUID v4) на каждую попытку создать «новое» намерение: заказ, платёж, бронирование.
  • Повтор того же намерения — тот же ключ.
  • Новый заказ — новый ключ.

Срок жизни ключа зависит от домена: для заказов обычно от 24 часов до 7 дней достаточно.

Пример: реализация на PostgreSQL + FastAPI

Ниже рабочая схема для REST‑эндпоинта создания заказа. Принципы легко переносятся на любой стек.

Схема таблицы ключей

-- Ключ идемпотентности и кэш ответа
create table if not exists idempotency_keys (
  key text primary key,
  body_hash text not null,
  response_status int,
  response_body jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index if not exists idempotency_keys_created_at_idx on idempotency_keys (created_at);

create or replace function set_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger trg_idem_updated
before update on idempotency_keys
for each row execute function set_updated_at();

Эндпоинт: принять ключ, выполнить, вернуть кэш

# requirements: fastapi, uvicorn, psycopg[binary]

import hashlib
import json
from typing import Any, Dict

import psycopg
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse

app = FastAPI()

# Подключение к БД (pool)
pool = psycopg.ConnectionPool(
    conninfo='postgresql://app:app@localhost:5432/app',
    min_size=1,
    max_size=10,
)


def sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


async def create_order(tx: psycopg.Connection, payload: Dict[str, Any]) -> Dict[str, Any]:
    # Пример бизнес‑логики: создаём заказ и возвращаем его состояние
    cur = tx.execute(
        'insert into orders(customer_id, amount, status) values (%s, %s, %s) returning id, customer_id, amount, status',
        (payload['customer_id'], payload['amount'], 'new')
    )
    row = cur.fetchone()
    return {
        'id': row[0],
        'customer_id': row[1],
        'amount': float(row[2]),
        'status': row[3],
    }


@app.post('/orders')
async def create_order_endpoint(request: Request, idempotency_key: str = Header(None, convert_underscores=False)):
    if not idempotency_key:
        raise HTTPException(status_code=400, detail='Отсутствует заголовок Idempotency-Key')

    raw = await request.body()
    body_hash = sha256_hex(raw)
    payload = await request.json()

    with pool.connection() as conn:
        conn.execute('begin')
        try:
            # Пытаемся вставить ключ: если вставился — мы «владельцы» выполнения
            inserted = conn.execute(
                'insert into idempotency_keys(key, body_hash) values (%s, %s) on conflict do nothing',
                (idempotency_key, body_hash)
            )

            if inserted.rowcount == 1:
                # Новый ключ — выполняем бизнес‑операцию в той же транзакции
                result = await create_order(conn, payload)
                status = 201
                # Кэшируем ответ
                conn.execute(
                    'update idempotency_keys set response_status = %s, response_body = %s where key = %s',
                    (status, json.dumps(result), idempotency_key)
                )
                conn.execute('commit')
                return JSONResponse(content=result, status_code=status)

            # Ключ уже существует: ждём завершения первого запроса и проверяем хеш тела
            row = conn.execute(
                'select body_hash, response_status, response_body from idempotency_keys where key = %s for update',
                (idempotency_key,)
            ).fetchone()

            if not row:
                conn.execute('rollback')
                raise HTTPException(status_code=500, detail='Сбой хранения ключа идемпотентности')

            stored_hash, resp_status, resp_body = row

            if stored_hash != body_hash:
                conn.execute('rollback')
                raise HTTPException(status_code=409, detail='Idempotency-Key уже использован с другим телом запроса')

            if resp_status is not None:
                # Ответ уже готов — возвращаем кэш
                conn.execute('commit')
                return JSONResponse(content=json.loads(resp_body), status_code=resp_status)

            # Ответ ещё не записан: редко, но возможно. Подождём чуть‑чуть и перечитаем.
            # В бою лучше применить нотификации LISTEN/NOTIFY или короткий поллинг с таймаутом.
            conn.execute('commit')
            raise HTTPException(status_code=425, detail='Запрос обрабатывается, повторите чуть позже')

        except Exception:
            conn.execute('rollback')
            raise

Пример вызова:

curl -X POST \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 4f2b4a1a-7f0e-4d1b-9b2e-9a0fb02a1d77' \
  -d '{"customer_id":123,"amount":1500}' \
  http://localhost:8000/orders

Повтор того же запроса с тем же ключом вернёт ровно тот же ответ. Если тело другое — 409, чтобы не было «магии» и скрытых конфликтов данных.

Фоновые задачи без дублей: блокировки и очереди

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

Есть два слоя защиты:

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

Очередь на PostgreSQL: простая и устойчивая

create table if not exists tasks (
  id bigserial primary key,
  task_key text not null, -- бизнес‑идентификатор для дедупликации (например, payment:123)
  payload jsonb not null,
  status text not null default 'pending', -- pending | running | done | failed
  run_at timestamptz not null default now(),
  attempts int not null default 0,
  last_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (task_key)
);

create index if not exists tasks_ready_idx on tasks (status, run_at);

Исполнитель выбирает задачку без гонок через блокировку строк:

import time
import json
import psycopg

pool = psycopg.ConnectionPool(
    conninfo='postgresql://app:app@localhost:5432/app',
    min_size=1,
    max_size=10,
)


def handle_task(tx: psycopg.Connection, task):
    # Здесь вызывается бизнес‑операция, которая сама по себе идемпотентна
    data = task['payload']
    # ... делаем работу ...
    return {'ok': True}


def worker_loop():
    while True:
        with pool.connection() as conn:
            conn.execute('begin')
            row = conn.execute(
                """
                select id, task_key, payload, attempts
                from tasks
                where status = 'pending' and run_at <= now()
                for update skip locked
                limit 1
                """
            ).fetchone()

            if not row:
                conn.execute('commit')
                time.sleep(0.5)
                continue

            task = {
                'id': row[0],
                'task_key': row[1],
                'payload': row[2],
                'attempts': row[3],
            }

            conn.execute('update tasks set status = %s where id = %s', ('running', task['id']))
            conn.execute('commit')

        # Выполняем вне транзакции, чтобы не держать блокировки
        ok = True
        err = None
        try:
            with pool.connection() as c2:
                c2.execute('begin')
                handle_task(c2, task)
                c2.execute('commit')
        except Exception as e:
            ok = False
            err = str(e)

        with pool.connection() as conn3:
            conn3.execute('begin')
            if ok:
                conn3.execute('update tasks set status = %s where id = %s', ('done', task['id']))
            else:
                delay_sec = min(60, 2 ** min(6, task['attempts']))
                conn3.execute(
                    'update tasks set status = %s, attempts = attempts + 1, last_error = %s, run_at = now() + (%s || '' seconds'')::interval where id = %s',
                    ('pending', err, str(delay_sec), task['id'])
                )
            conn3.execute('commit')

За счёт for update skip locked одновременно работающие процессы не возьмут одну и ту же запись. Уникальный task_key не даст создать дубликат семантической задачи.

Идемпотентные побочные эффекты: списания и проводки

Даже с ключами и блокировками у вас останутся повторные попытки. Чтобы деньги и учёт были в порядке, делаем эффекты идемпотентными на уровне БД.

Идея: перед выполнением эффекта фиксируем «факт применения» с уникальным бизнес‑идентификатором. Если такой факт уже есть — пропускаем эффект. Делать это нужно в той же транзакции, где меняются связанные данные.

-- Факты применённых эффектов (например, списаний)
create table if not exists applied_effects (
  id bigserial primary key,
  effect_key text not null unique, -- например, charge:payment_id:123
  created_at timestamptz not null default now()
);

Использование в транзакции:

-- Псевдокод на SQL для атомарности
begin;

-- Пытаемся зафиксировать факт эффекта
insert into applied_effects(effect_key) values ('charge:payment:123')
on conflict do nothing;

-- Проверяем, действительно ли мы первые
with ins as (
  select 1 from applied_effects where effect_key = 'charge:payment:123'
)
-- Выполняем эффект только если запись появилась сейчас
-- На практике можно смотреть количество затронутых строк при insert
update payments set charged = true where id = 123 and not charged;

commit;

В прикладном коде проще: делаете insert и анализируете rowcount. Если 0 — эффект уже применён, выходим без действий.

Хранение ключей, TTL и очистка

  • Срок жизни: выбирайте по домену. Для платежей и заказов обычно 3–7 дней. Для простых операций — 24 часа.
  • Очистка: периодическая задача удаляет старые ключи и их ответы.
-- Чистим ключи старше 7 дней
delete from idempotency_keys where created_at < now() - interval '7 days';

Важно: удаление старых ключей не должно ломать активные ретраи. Поэтому выбирайте TTL больше возможного окна повторов клиента/интеграции.

Наблюдаемость и обработка ошибок

Метрики и логи помогут быстро ловить неверное использование ключей и регрессии:

  • доля запросов с ключом идемпотентности;
  • число конфликтов 409 (переиспользование ключа с другим телом);
  • средняя задержка до готовности кэшированного ответа;
  • доля повторов задач в очереди, среднее число попыток;
  • количество «пропущенных» эффектов (когда insert в applied_effects вернул 0 строк).

Алерты:

  • резкий рост 409 — клиенты неправильно переиспользуют ключи;
  • рост времени ожидания ответа при форке запросов — возможны блокировки или деградация БД;
  • всплеск повторов задач — внешняя зависимость деградирует или таймауты слишком агрессивные.

Безопасность и гигиена ключей

  • Ключ идемпотентности — это технический токен, но не секрет. Его можно хранить в логах. Тем не менее не используйте в качестве ключа персональные данные.
  • Валидируйте длину и формат ключа, чтобы избежать чрезмерного потребления памяти.
  • Сверяйте хеш тела запроса для защиты от случайного или злонамеренного переиспользования ключа под другое действие.
  • Не включайте в сохранённый ответ приватные данные сверх того, что вернули клиенту.

План внедрения за 1–2 спринта

Спринт 1:

  • Добавить таблицу idempotency_keys и обёртку на уровне веб‑слоя для чтения/записи ключей.
  • Реализовать хеширование тела запроса и сравнение.
  • Включить поддержку ключей в критичных эндпоинтах: создание заказа, начало оплаты, бронирование.
  • Покрыть интеграционные сценарии: повтор до/после коммита, два параллельных запроса с одним ключом.

Спринт 2:

  • Внедрить очередь задач на БД или существующий брокер с захватом без гонок.
  • Добавить applied_effects для денежных и бухгалтерских эффектов.
  • Настроить очистку старых ключей и метрики.
  • Обновить документацию API для партнёров: как формировать и переиспользовать ключи.

Чек‑лист перед релизом

  • Эндпоинты с побочными эффектами принимают заголовок Idempotency-Key и работают по протоколу выше.
  • При переиспользовании ключа с другим телом — 409 и понятное сообщение.
  • Ответ кэшируется вместе со статусом и возвращается бит‑в‑бит одинаковым.
  • Тесты: параллельные запросы с одним ключом, обрыв после коммита, обрыв до коммита, повтор через несколько часов.
  • Очередь задач захватывает записи через for update skip locked, есть бэкофф и ограничение попыток.
  • Побочные эффекты завёрнуты в applied_effects с уникальным ключом эффекта.
  • Метрики и логи настроены, есть алерты на аномалии.

Частые вопросы

Можно ли обойтись без хранения кэшированного ответа?

Если договориться, что повтор вернёт только итоговый ресурс (например, объект заказа по его идентификатору), то да. Но хранение готового ответа упрощает жизнь клиентам и снижает нагрузку на бэкенд.

Чем идемпотентность отличается от транзакционности?

Транзакция защищает от частично применённых изменений внутри одной попытки. Идемпотентность гарантирует одинаковый результат при повторах разных попыток во времени.

Подойдёт ли Redis для ключей?

Можно хранить ключи в Redis с TTL для очень нагруженных систем. Но критичные эффекты всё равно защищайте уникальными ключами в постоянном хранилище (БД), чтобы переживать перезагрузки.

Что делать с длинными запросами?

При повторном запросе лучше ждать готовый ответ с коротким таймаутом и затем вернуться к клиенту с 425 или 202. Для UX — показывайте «ожидаем завершения операции», а не спиннер без конца.

Идемпотентность — это не про красивый паттерн, а про спокойные деньги и предсказуемые процессы. Внедрив её в API и фоновые задачи, вы сокращаете инциденты и стоимость поддержки, а команда релизит смелее.


APIPostgreSQLочередиидемпотентностьретраи