dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Повтор запроса не должен списывать деньги дважды

Коротко: сеть не сообщает, прошёл платёж или нет, — она просто молчит. Клиент нажимает кнопку второй раз, мобильное приложение повторяет запрос само, и без защиты вы списываете дважды. Разбираем, как устроена идемпотентность на уровне таблиц и кода и где её обычно ломают.

6 мин чтения

Коротко

  • Ключ идемпотентности генерирует клиент, и он привязан к намерению, а не к пользователю или сессии.
  • Защита от гонки — уникальный индекс в базе, а не проверка «нет ли такой операции» перед вставкой.
  • Тот же ключ с другим телом запроса — это ошибка, а не повтор: иначе подменят сумму.
  • Вызов провайдера выносится в outbox, иначе транзакция откатится, а деньги уже уйдут.

Почему «проверить перед списанием» не работает

Это один из трёх узлов, которые невозможно добавить в финтех-продукт задним числом (два других — неизменяемый журнал и сверка; общая картина — в руководстве по разработке финтех-продукта). Разберём его подробно. Клиент отправил запрос на списание. Сервер принял его, провёл операцию — и в этот момент оборвалось соединение. Клиент не получил ответа. Он не знает, произошло списание или нет, и по всем правилам обязан повторить запрос: POST без дополнительных договорённостей не является идемпотентным (RFC 9110).

Первое, что приходит в голову, — перед списанием проверять, нет ли уже похожей операции: та же сумма, тот же получатель, последние пять минут. Это не работает по двум причинам. Во-первых, человек имеет полное право дважды перевести другу одну и ту же сумму. Во-вторых, между проверкой и вставкой проходит время, и два параллельных запроса пройдут проверку оба.

КлиентПовтор запросаПриём платежаКлюч идемпотентностиЖурнал операцийБанк / эквайрингТот же ответ · Списание одно
Повторный запрос приходит с тем же ключом идемпотентности, попадает в ту же запись журнала и получает тот же ответ. К провайдеру уходит одно списание.

Ключ идемпотентности: кто его создаёт и на что он указывает

Ключ генерирует клиент — приложение или фронтенд, — в момент, когда пользователь сформировал намерение. Не сервер: сервер не может отличить повтор от нового намерения. И не в момент отправки запроса, а в момент открытия формы: иначе повторное нажатие создаст новый ключ, и вся конструкция развалится.

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

Как это выглядит в базе

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

sql
create table payment_request (
  idempotency_key uuid        primary key,
  account_id      bigint      not null,
  request_hash    text        not null,
  status          text        not null
    check (status in ('in_progress', 'succeeded', 'failed')),
  response        jsonb,
  operation_id    bigint      references operation(id),
  created_at      timestamptz not null default now()
);

-- Журнал операций: только вставка. Отмена — новая запись со знаком минус,
-- а не update и тем более не delete.
create table operation (
  id         bigserial   primary key,
  account_id bigint      not null,
  amount     numeric(20, 4) not null,
  kind       text        not null,
  reverses   bigint      references operation(id),
  created_at timestamptz not null default now()
);
Отпечаток тела запроса нужен, чтобы отличить честный повтор от подмены: тот же ключ с другой суммой — это ошибка клиента, а не ретрай.

Баланс здесь — не колонка, а sum(amount) по счёту. Как только баланс становится хранимым значением, которое кто-то обновляет, появляется второй источник правды и вопрос «почему сумма операций не сходится с балансом», на который нет хорошего ответа.

Обработчик: порядок действий имеет значение

Защита от гонки — это insert с уникальным ключом, а не select перед insert. Проверка перед вставкой не спасает: два параллельных запроса пройдут её оба, потому что между проверкой и вставкой есть время. База умеет решать это за нас — уникальный индекс атомарен.

typescript
async function charge(req: ChargeRequest) {
  const hash = sha256(canonicalJson(req.body));

  // Пытаемся занять ключ. Если он уже занят — это повтор.
  const claimed = await db.insertIgnoreConflict('payment_request', {
    idempotency_key: req.key,
    account_id: req.accountId,
    request_hash: hash,
    status: 'in_progress',
  });

  if (!claimed) {
    const prev = await db.get('payment_request', req.key);

    // Тот же ключ, другое тело — клиент ошибся или пытается подменить сумму.
    if (prev.request_hash !== hash) throw new Conflict('key_reused');

    // Первый запрос ещё в работе: просим повторить позже, но не проводим второй раз.
    if (prev.status === 'in_progress') throw new Retry('in_progress');

    // Повтор завершённого запроса — отдаём ТОТ ЖЕ ответ, а не новый.
    return prev.response;
  }

  const result = await db.transaction(async (tx) => {
    const op = await tx.insert('operation', {
      account_id: req.accountId,
      amount: -req.body.amount,
      kind: 'charge',
    });

    // Вызов провайдера НЕ здесь: сеть внутри транзакции — это способ
    // получить откат в базе при уже ушедших деньгах. Кладём задание
    // в исходящую очередь той же транзакцией.
    await tx.insert('outbox', { type: 'provider.charge', operation_id: op.id });

    return { operationId: op.id };
  });

  await db.update('payment_request', req.key, {
    status: 'succeeded',
    response: result,
    operation_id: result.operationId,
  });

  return result;
}
Ключевой момент — строка с onConflict: гонку разрешает база, а не наш код.

Сверка: то, что находит ошибки, которые вы не предусмотрели

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

Как устроена ночная сверка
  1. 01

    Забираем выписку за сутки

    В том виде, в каком её отдаёт провайдер, и сохраняем как есть — до всякой обработки. Исходник понадобится, когда результат сверки вызовет вопросы.

  2. 02

    Сопоставляем по идентификатору операции

    Не по сумме и времени: совпадение сумм — это не совпадение операций. Идентификатор провайдера должен храниться рядом с вашей операцией с самого начала.

  3. 03

    Раскладываем расхождения по типам

    Есть у нас, нет у них. Есть у них, нет у нас. Есть у обоих, но суммы разные. Третий тип — самый неприятный и самый информативный.

  4. 04

    Заводим задачу, а не письмо

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

Чего не даёт идемпотентность

Она не превращает доставку «хотя бы один раз» в «ровно один раз» — это невозможно в распределённой системе. Она делает повторную обработку безопасной, и это другое обещание: система по-прежнему получит запрос дважды, просто второй раз ничего не произойдёт. Всё, что вы строите вокруг денег, должно исходить из того, что любое сообщение придёт больше одного раза.

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

Достаточно ли уникального индекса без отдельной таблицы запросов?

Уникальный индекс защитит от второй операции, но клиент получит ошибку вместо результата первой — и не сможет отличить «уже прошло» от «не прошло». Смысл таблицы запросов не только в защите, но и в том, чтобы отдать повторному запросу тот же ответ, что и первому.

Сколько хранить ключи идемпотентности?

Сутки покрывают практически любые ретраи: дольше клиент повторять не будет. Записи старше срока удаляются по расписанию, иначе таблица растёт вечно. Сама операция при этом хранится столько, сколько требует учёт, — это разные сроки для разных таблиц.

Что вернуть, если первый запрос ещё выполняется?

Код 409 с понятным телом и указанием повторить позже. Возвращать 200 нельзя: клиент решит, что операция прошла, и покажет пользователю успех, которого ещё не было. Ждать завершения внутри обработчика тоже не стоит — так вы получите очередь заблокированных соединений.

Это нужно только в платежах?

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

Источники

Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.

  1. 01RFC 9110 — HTTP Semantics, § идемпотентные методыпочему POST требует отдельной договорённости о повторах
  2. 02Stripe API — Idempotent requestsсрок жизни ключа, поведение при совпадении ключа и различии тела
  3. 03Transactional outbox patternкак не отправлять запрос провайдеру внутри транзакции
  4. 04PostgreSQL — Transaction Isolationпочему «проверить и вставить» не является атомарной операцией
Инженерная редакция dbit.one
Инженеры, которые строят эти системы

Материалы пишут инженеры, работающие на проектах, — но без подписи именем. Причина та же, по которой на сайте нет логотипов клиентов: почти все проекты идут под NDA или white-label, и авторство статьи о платёжном ядре указывает на заказчика не хуже логотипа. Взамен мы отвечаем за текст правилами, а не именами — кроме материалов о собственном открытом коде: те подписаны автором.

Как мы пишем и что проверяем

Услуги по теме

Читать дальше

Руководство8 мин чтения

Разработка финтех-продукта: полное руководство

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

Инженерия6 мин чтения

Как переносят базу под нагрузкой и не теряют записи

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

Инженерия9 мин чтения

Уровни изоляции: что ваша база разрешает на самом деле

Коротко: уровень изоляции — это не настройка производительности, а список того, что вашему приложению разрешено увидеть. Между «read committed» и «serializable» лежат аномалии с именами и с ценой: списание сверх остатка, две смены без дежурного, задвоенный документ. Прочитать в документации, что база «поддерживает snapshot isolation», недостаточно — это свойство исполнений, а не текста.

Остались вопросы по вашему проекту?

Опишите задачу — в течение 24 часов вернёмся с оценкой, сроками и планом.

[email protected]