Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Idempotency‑Key в API: минус дубли и двойные списания, спокойнее финансы

Разработка и технологии21 января 2026 г.
Повторные запросы случаются всегда: мобильная сеть, таймауты, повтор клиента. Без идемпотентности это выливается в двойные списания и дубли заказов. Разбираем, как спроектировать и внедрить Idempotency‑Key в API и очередях так, чтобы снизить инциденты и не усложнить архитектуру.
Idempotency‑Key в API: минус дубли и двойные списания, спокойнее финансы

  • Содержание
    • Зачем бизнесу идемпотентность
    • Где и почему появляются дубли
    • Базовый подход: ключ запроса + детерминированный результат
    • Дизайн HTTP‑API: заголовок, статусы, TTL
    • Хранилище ключей: БД против Redis
    • Схема таблицы в PostgreSQL и индексы
    • Транзакции: один шаг — один результат
    • Пример реализации на Node.js + PostgreSQL
    • Очереди и фоновые задания: как не обработать задачу дважды
    • Наблюдаемость и защита от злоупотреблений
    • Чек‑лист внедрения

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

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

  • двойные списания и возвраты (дорого и нервно),
  • дубли заказов и отгрузок (потери и хаос на складе),
  • загадочные инциденты и поддержка в огне.

Идемпотентность делает повтор безопасным: один и тот же запрос с тем же Idempotency‑Key приводит к одному и тому же результату. Это уменьшает инциденты, экономит деньги и время саппорта.

Где и почему появляются дубли

Типичные сценарии:

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

В каждой ситуации нужно одно: определить «повтор это или нет» и вернуть прежний результат, не выполняя действие второй раз.

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

Простая, но сильная идея:

  • Клиент отправляет уникальный ключ запроса (Idempotency‑Key), обычно случайный UUID.
  • Сервер хранит соответствие «ключ → результат операции» и возвращает его при повторах.
  • Если тело или смысл запроса изменился, а ключ тот же — это ошибка клиента.

Важно: идемпотентность не про «сделать операцию безопасной сама по себе», а про «сделать повтор безопасным». Внутри операция может быть сложной, но снаружи результат для данного ключа фиксирован.

Дизайн HTTP‑API: заголовок, статусы, TTL

Рекомендации по интерфейсу:

  • Клиент присылает заголовок Idempotency-Key: <uuid> на операции, которые создают/меняют состояние (платеж, заказ, возврат, назначение курьера).
  • Сервер проверяет ключ и тело. Если ключ уже есть и тело совпадает — вернуть сохранённый ответ (тот же статус/тело/заголовки, кроме технических).
  • Если ключ уже есть, но тело не совпадает — 409 Conflict с пояснением.
  • Если ключ в обработке (первая попытка началась, но не закончилась) — можно подождать до N секунд и либо вернуть готовый результат, либо прислать 409 Conflict + Retry-After.
  • TTL хранения ключей: практично 24–72 часа для клиентских ретраев. Для финансовых операций дольше (до 7–30 дней), чтобы покрыть поздние повторы.

Заголовки, которые помогают:

  • Idempotency-Key (в запросе и можно отражать в ответе для трассировки),
  • Idempotency-Result: reused|created (удобно для отладки),
  • Retry-After при статусе 409 в случае «ещё в обработке».

Хранилище ключей: БД против Redis

  • Реляционная БД (PostgreSQL): безопасно и удобно. Можно хранить результат и статус, обеспечить уникальность, транзакционность, TTL по расписанию.
  • Redis: быстр, но по умолчанию неустойчив (eviction, перезапуск). Подходит для вторичной защиты и коротких TTL, либо при включенной персистентности и репликации.
  • Комбинированный вариант: запись в БД как источник истины, Redis как кэш для быстрых повторов.

Для финансовых и критичных операций — храните ключи с результатами в БД.

Схема таблицы в PostgreSQL и индексы

Ниже — рабочая схема для хранения состояния идемпотентности и ответа. Мы также храним «хэш запроса», чтобы запретить переиспользование одного и того же ключа для разных тел.

-- Таблица ключей идемпотентности
create table if not exists idempotency_keys (
  key text primary key,
  endpoint text not null,
  request_hash bytea not null,
  status text not null check (status in ('in_progress','completed','failed')),
  response_status int,
  response_headers jsonb,
  response_body jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  expires_at timestamptz
);

create index if not exists idempotency_keys_expires_at_idx
  on idempotency_keys (expires_at) where expires_at is not null;

-- Обновляем updated_at триггером
create or replace function set_updated_at()
returns trigger as $$
begin
  new.updated_at := now();
  return new;
end;
$$ language plpgsql;

drop trigger if exists set_updated_at_trg on idempotency_keys;
create trigger set_updated_at_trg
before update on idempotency_keys
for each row execute procedure set_updated_at();

Пояснения:

  • key — сам Idempotency‑Key (уникальный).
  • endpoint — нормализованный путь и метод (POST /payments). Это часть хеша.
  • request_hash — криптографический хеш тела/параметров запроса и эндпоинта.
  • status — в обработке/завершён/ошибка (ошибки стоит кэшировать коротко).
  • response_* — сохранённый ответ, который вернём при повторе.
  • expires_at — когда запись можно удалить задачей очистки.

Хеш удобно считать, например, через SHA‑256. В Postgres можно хранить как bytea.

Транзакции: один шаг — один результат

Ключевая идея: создание записи Idempotency‑Key и бизнес‑операцию выполняем в одной транзакции.

  • Начинаем транзакцию.
  • Пытаемся вставить запись со статусом in_progress.
  • Если запись уже есть: проверяем хеш запроса. Совпадает — ждём/возвращаем результат, отличается — 409.
  • Выполняем бизнес‑операцию (создание платежа, заказа и т. п.).
  • Сохраняем итоговый ответ в response_*, выставляем completed.
  • Коммит.

Так мы исключаем «ответ записан, но операция не выполнена» и наоборот.

Пример реализации на Node.js + PostgreSQL

Ниже — минимальный, но рабочий пример на TypeScript с pg. Он показывает обработку POST /payments, где для каждого запроса требуется Idempotency‑Key. При повторах мы возвращаем сохранённый ответ.

import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { Pool } from 'pg';

const app = express();
app.use(express.json());

const pool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/postgres',
  max: 10,
});

function hashRequest(endpoint: string, body: any): Buffer {
  const h = crypto.createHash('sha256');
  h.update(endpoint);
  h.update('\n');
  h.update(JSON.stringify(body, Object.keys(body).sort()));
  return h.digest();
}

async function withClient<T>(fn: (client: any) => Promise<T>): Promise<T> {
  const client = await pool.connect();
  try {
    return await fn(client);
  } finally {
    client.release();
  }
}

app.post('/payments', async (req: Request, res: Response) => {
  const idemKey = req.header('Idempotency-Key');
  if (!idemKey || idemKey.length > 200) {
    return res.status(400).json({ error: 'Idempotency-Key is required and must be <= 200 chars' });
  }
  const endpoint = 'POST /payments';
  const reqHash = hashRequest(endpoint, req.body);

  try {
    await withClient(async (client) => {
      await client.query('BEGIN');

      // Попытка вставить новую запись. Если ключ уже существует — конфликт.
      const insertResult = await client.query(
        `insert into idempotency_keys(key, endpoint, request_hash, status, expires_at)
         values ($1, $2, $3, 'in_progress', now() + interval '3 days')
         on conflict (key) do nothing
         returning key`,
        [idemKey, endpoint, reqHash]
      );

      if (insertResult.rowCount === 0) {
        // Ключ существует: проверим тело и статус
        const rowRes = await client.query(
          'select key, request_hash, status, response_status, response_headers, response_body from idempotency_keys where key = $1 for update',
          [idemKey]
        );
        const row = rowRes.rows[0];
        if (!row) {
          // Теоретически не должно случиться, но на всякий случай
          throw new Error('Idempotency record disappeared');
        }
        const sameHash = Buffer.compare(row.request_hash, reqHash) === 0;
        if (!sameHash) {
          await client.query('ROLLBACK');
          return res.status(409).json({ error: 'Idempotency-Key reused with different payload' });
        }
        if (row.status === 'completed') {
          await client.query('COMMIT');
          // Возвращаем сохранённый ответ
          res.set('Idempotency-Result', 'reused');
          if (row.response_headers) {
            const hdrs = row.response_headers as Record<string,string>;
            Object.entries(hdrs).forEach(([k,v]) => { if (k.toLowerCase() !== 'content-length') res.set(k, String(v)); });
          }
          return res.status(row.response_status || 200).json(row.response_body || {});
        }
        // Если in_progress — подождём до 5 секунд, вдруг первая попытка сейчас завершится
        const started = Date.now();
        while (Date.now() - started < 5000) {
          await new Promise(r => setTimeout(r, 200));
          const check = await client.query(
            'select status, response_status, response_headers, response_body from idempotency_keys where key = $1',
            [idemKey]
          );
          const st = check.rows[0];
          if (st && st.status === 'completed') {
            await client.query('COMMIT');
            res.set('Idempotency-Result', 'reused');
            return res.status(st.response_status || 200).json(st.response_body || {});
          }
        }
        await client.query('ROLLBACK');
        res.set('Retry-After', '2');
        return res.status(409).json({ error: 'Request with this Idempotency-Key is still in progress. Retry later.' });
      }

      // Новая попытка: выполняем бизнес-логику.
      // Здесь — имитация платёжной операции и создание ресурса.
      // В реальном коде вызывайте платёжный провайдер, БД и т.д., но всё в этой транзакции либо с надёжной компенсацией.
      const paymentId = crypto.randomUUID();
      // Пример: создаём запись о платеже
      await client.query(
        'create table if not exists payments(id uuid primary key, amount int not null, currency text not null, created_at timestamptz default now())'
      );
      const { amount, currency } = req.body || {};
      if (!Number.isInteger(amount) || amount <= 0 || typeof currency !== 'string') {
        // Ошибка валидации — можно пометить failed и короткий TTL
        await client.query(
          `update idempotency_keys set status = 'completed', response_status = $2, response_headers = $3, response_body = $4, expires_at = now() + interval '6 hours' where key = $1`,
          [idemKey, 422, { 'Content-Type': 'application/json' }, { error: 'Invalid amount or currency' }]
        );
        await client.query('COMMIT');
        res.set('Idempotency-Result', 'created');
        return res.status(422).json({ error: 'Invalid amount or currency' });
      }

      await client.query(
        'insert into payments(id, amount, currency) values ($1, $2, $3)',
        [paymentId, amount, currency]
      );

      const responseBody = { id: paymentId, status: 'succeeded', amount, currency };

      // Сохраняем ответ
      await client.query(
        `update idempotency_keys
         set status = 'completed', response_status = $2, response_headers = $3, response_body = $4
         where key = $1`,
        [idemKey, 201, { 'Content-Type': 'application/json' }, responseBody]
      );

      await client.query('COMMIT');
      res.set('Idempotency-Result', 'created');
      return res.status(201).json(responseBody);
    });
  } catch (e: any) {
    console.error(e);
    return res.status(500).json({ error: 'Internal error' });
  }
});

const port = Number(process.env.PORT || 3000);
app.listen(port, () => console.log(`Server on :${port}`));

Что здесь важно:

  • Вставка ключа и бизнес‑операция в одной транзакции.
  • Если ключ занят — ждём до 5 секунд, иначе отвечаем 409 + Retry‑After. Это разумный компромисс без сложной блокировки.
  • Мы кэшируем не только статус‑код, но и тело ответа и обычные заголовки.

Очереди и фоновые задания: как не обработать задачу дважды

«Ровно один раз» в распределённых системах — миф. Реалистичный контракт — «как минимум один раз», значит нужно:

  • Делать обработчики идемпотентными: операция «создать» превращается в «создать если нет». Например, insert ... on conflict do nothing с уникальным бизнес‑ключом.
  • Де‑дупликация задач на стороне потребителя: храните в БД таблицу processed_messages с уникальным message_id. Перед началом обработки — попытка вставки; если конфликт, пропускаем обработку.
  • Для долгих задач — периодически обновляйте «сердцебиение» в записи и имейте таймаут, после которого задачу можно взять снова.

Пример таблицы для де‑дупликации сообщений:

create table if not exists processed_messages (
  message_id text primary key,
  processed_at timestamptz not null default now(),
  expires_at timestamptz
);

create index if not exists processed_messages_expires_at_idx
  on processed_messages (expires_at) where expires_at is not null;

Логика потребителя:

  • Начало обработки: insert into processed_messages(message_id, expires_at) values (...) on conflict do nothing.
  • Если вставка не произошла — сообщение уже обрабатывалось, можно пропускать.
  • После успешной обработки можно очистить expires_at или оставить для TTL‑очистки.

Связка с outbox‑паттерном: события публикуем из таблицы outbox, а на стороне консьюмера защищаемся от дублей через processed_messages.

Наблюдаемость и защита от злоупотреблений

Метрики и логи, которые стоит собирать:

  • Сколько операций завершилось по новому ключу vs по повторному (Idempotency-Result: created|reused).
  • Сколько конфликтов по «другому телу» (реальные клиентские ошибки).
  • Распределение времени ожидания «in_progress».
  • Размер таблицы ключей, скорость очистки.

Защита:

  • Квота на число новых ключей в единицу времени на пользователя/аккаунт.
  • Ограничение длины ключа и фильтрация мусора.
  • TTL и ежедневная очистка просроченных записей (например, cron‑задача).

Чек‑лист внедрения

  • Определите операции, где повтор опасен: платежи, создание заказа, создание возврата, списание баллов.
  • Введите обязательный Idempotency-Key для этих эндпоинтов.
  • Реализуйте хранилище ключей с проверкой хеша запроса.
  • Выполняйте вставку ключа и бизнес‑операцию в одной транзакции.
  • Сохраняйте и возвращайте результат при повторах.
  • Обработайте состояние in_progress: короткое ожидание + 409/Retry‑After.
  • Настройте TTL и сборку мусора.
  • Для фоновых воркеров реализуйте де‑дупликацию сообщений и идемпотентные операции.
  • Добавьте метрики, логи и алерты.
  • Обучите клиентов/мобильные SDK автоматически генерировать ключи (UUIDv4) и ретраить корректно.

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

  • Можно ли делать ключи на стороне сервера? Можно, но обычно удобнее, когда ключ генерирует клиент (он знает, что «это один логический запрос», даже если сеть ломается).
  • Что хэшировать? Метод + путь + тело + важные параметры строки запроса. Это защищает от «тот же ключ, другой смысл».
  • Сколько хранить? Зависит от домена. Для платежей — недели, для обычных POST — сутки.
  • Нужны ли распределённые блокировки? Часто нет. Короткое ожидание + 409 достаточно и проще. Если нужна строгая сериализация — можно использовать advisory lock в БД.

Идемпотентность — это не надстройка «для галочки». Это фундаментальная защита денег, заказов и репутации. Реализовать её можно просто и надёжно, без экзотики и лишних сервисов. Начните с ключей в API и де‑дупликации в воркерах, а дальше совершенствуйте UX и наблюдаемость.


APIPostgreSQLidempotency