Почему «проверить перед списанием» не работает
Это один из трёх узлов, которые невозможно добавить в финтех-продукт задним числом (два других — неизменяемый журнал и сверка; общая картина — в руководстве по разработке финтех-продукта). Разберём его подробно. Клиент отправил запрос на списание. Сервер принял его, провёл операцию — и в этот момент оборвалось соединение. Клиент не получил ответа. Он не знает, произошло списание или нет, и по всем правилам обязан повторить запрос: POST без дополнительных договорённостей не является идемпотентным (RFC 9110).
Первое, что приходит в голову, — перед списанием проверять, нет ли уже похожей операции: та же сумма, тот же получатель, последние пять минут. Это не работает по двум причинам. Во-первых, человек имеет полное право дважды перевести другу одну и ту же сумму. Во-вторых, между проверкой и вставкой проходит время, и два параллельных запроса пройдут проверку оба.
Ключ идемпотентности: кто его создаёт и на что он указывает
Ключ генерирует клиент — приложение или фронтенд, — в момент, когда пользователь сформировал намерение. Не сервер: сервер не может отличить повтор от нового намерения. И не в момент отправки запроса, а в момент открытия формы: иначе повторное нажатие создаст новый ключ, и вся конструкция развалится.
- Один ключ — одно намерение пользователя. Открыл форму перевода — получил ключ; нажал «отправить» три раза — ключ тот же.
- Ключ должен быть случайным (UUID), а не производным от суммы и времени: два одинаковых перевода подряд — законный сценарий.
- Ключ живёт ограниченное время. Сутки — разумный срок: он покрывает любые ретраи и не превращает таблицу в вечное хранилище.
Как это выглядит в базе
Минимальная схема — одна таблица. Важны в ней три вещи: первичный ключ по ключу идемпотентности, отпечаток запроса и сохранённый ответ.
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. Проверка перед вставкой не спасает: два параллельных запроса пройдут её оба, потому что между проверкой и вставкой есть время. База умеет решать это за нас — уникальный индекс атомарен.
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: гонку разрешает база, а не наш код.Сверка: то, что находит ошибки, которые вы не предусмотрели
Идемпотентность защищает от дублей, но не от расхождений: провайдер мог отклонить операцию после того, как ответил «принято», комиссия могла прийти отдельной строкой, возврат мог пройти вне вашей системы. Единственный способ узнать об этом — ежедневно сравнивать свой журнал с выпиской провайдера.
- 01
Забираем выписку за сутки
В том виде, в каком её отдаёт провайдер, и сохраняем как есть — до всякой обработки. Исходник понадобится, когда результат сверки вызовет вопросы.
- 02
Сопоставляем по идентификатору операции
Не по сумме и времени: совпадение сумм — это не совпадение операций. Идентификатор провайдера должен храниться рядом с вашей операцией с самого начала.
- 03
Раскладываем расхождения по типам
Есть у нас, нет у них. Есть у них, нет у нас. Есть у обоих, но суммы разные. Третий тип — самый неприятный и самый информативный.
- 04
Заводим задачу, а не письмо
Расхождение без ответственного и срока превращается в ежедневное уведомление, которое через неделю перестают читать.
Чего не даёт идемпотентность
Она не превращает доставку «хотя бы один раз» в «ровно один раз» — это невозможно в распределённой системе. Она делает повторную обработку безопасной, и это другое обещание: система по-прежнему получит запрос дважды, просто второй раз ничего не произойдёт. Всё, что вы строите вокруг денег, должно исходить из того, что любое сообщение придёт больше одного раза.
Частые вопросы
Достаточно ли уникального индекса без отдельной таблицы запросов?
Уникальный индекс защитит от второй операции, но клиент получит ошибку вместо результата первой — и не сможет отличить «уже прошло» от «не прошло». Смысл таблицы запросов не только в защите, но и в том, чтобы отдать повторному запросу тот же ответ, что и первому.
Сколько хранить ключи идемпотентности?
Сутки покрывают практически любые ретраи: дольше клиент повторять не будет. Записи старше срока удаляются по расписанию, иначе таблица растёт вечно. Сама операция при этом хранится столько, сколько требует учёт, — это разные сроки для разных таблиц.
Что вернуть, если первый запрос ещё выполняется?
Код 409 с понятным телом и указанием повторить позже. Возвращать 200 нельзя: клиент решит, что операция прошла, и покажет пользователю успех, которого ещё не было. Ждать завершения внутри обработчика тоже не стоит — так вы получите очередь заблокированных соединений.
Это нужно только в платежах?
Нет. Любое действие с внешним эффектом — отправка письма, начисление бонусов, создание заказа, публикация документа — выигрывает от того же приёма. В платежах цена ошибки просто выше всего, поэтому там об этом думают первым делом.
Источники
Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.
- 01RFC 9110 — HTTP Semantics, § идемпотентные методы — почему POST требует отдельной договорённости о повторах
- 02Stripe API — Idempotent requests — срок жизни ключа, поведение при совпадении ключа и различии тела
- 03Transactional outbox pattern — как не отправлять запрос провайдеру внутри транзакции
- 04PostgreSQL — Transaction Isolation — почему «проверить и вставить» не является атомарной операцией
Материалы пишут инженеры, работающие на проектах, — но без подписи именем. Причина та же, по которой на сайте нет логотипов клиентов: почти все проекты идут под NDA или white-label, и авторство статьи о платёжном ядре указывает на заказчика не хуже логотипа. Взамен мы отвечаем за текст правилами, а не именами — кроме материалов о собственном открытом коде: те подписаны автором.
Как мы пишем и что проверяем