Идемпотентность и повторная доставка

В распределённой системе любое сообщение может прийти дважды. Урок о том, как сделать так, чтобы от этого не пострадали ни данные, ни деньги пользователя.

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

Предыдущий урок закончился неприятным выводом: ретрай спасает от сетевых сбоев, но повторяет запросы. А повторить «спиши 300 рублей» — значит списать 600. Или 900. Задача этого урока — закрыть эту дыру раз и навсегда.

Почему сообщение придёт дважды (и это не баг)

Разработчики часто верят, что дубли — признак кривой настройки брокера. Нет. Дубли — фундаментальное свойство сетей. Вот честный список источников:

  • Ответ потерялся. Сервис платежей списал деньги и отправил 200 OK, но ответ не доехал: сеть моргнула, под перезапустился. Клиент видит таймаут и честно повторяет запрос. Операция была выполнена — клиент об этом не знает.
  • Подтверждение (ack) не доехало до брокера. Consumer обработал сообщение и отправил ack, но упал за миллисекунду до того, как ack ушёл. Брокер (RabbitMQ, Kafka) не увидел подтверждения и по правилам at-least-once отдаёт сообщение снова — другому потребителю.
  • Ребалансировка группы потребителей. Consumer обработал 500 сообщений, но не успел закоммитить offset — и в этот момент группа перебалансировалась. Новый consumer начнёт с последнего закоммиченного offset и переобработает всю пачку.
  • Пользователь. Двойной клик по кнопке «Оплатить». Обновление страницы формы. «Что-то долго думает, нажму ещё раз».

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

exactly-once обработка = at-least-once доставка + идемпотентность получателя.

То есть эту проблему невозможно решить настройкой брокера. Её решает ваш код.

Что вообще значит «идемпотентная операция»

Формально: f(f(x)) = f(x). На практике всё решает формулировка операции.

  • balance = 700 — «сделай баланс равным 700». Повторяй сколько хочешь, баланс будет 700. Идемпотентно.
  • balance = balance - 300 — «отними 300». Три повтора — минус 900. Не идемпотентно.

HTTP формализовал это в семантике методов:

ЗапросИдемпотентен?Почему
GET /orders/42ДаЧтение ничего не меняет
PUT /orders/42 со статусом paidДа«Сделай состояние таким» — сколько ни повторяй, результат один
DELETE /orders/42ДаВторой раз удалять уже нечего; 404 в ответ — тоже корректный исход
POST /ordersНет«Создай новый» — три вызова создадут три заказа
POST /accounts/7/withdraw, сумма 300НетТри вызова спишут 900

Иногда операцию можно просто переформулировать в идемпотентную — и это самое дешёвое решение. Вместо «начисли 10 бонусов за заказ» пишем «состояние бонусов по заказу ord-8831: начислено 10». Вместо «увеличь счётчик просмотров» — «запиши факт просмотра с id события, счётчик посчитаем агрегацией». Прежде чем городить инфраструктуру, спросите себя: можно ли сформулировать операцию как «установи», а не как «измени»?

Ключ идемпотентности

Ключ идемпотентности (idempotency key) — уникальный идентификатор намерения клиента, который клиент придумывает сам и повторяет во всех ретраях одного и того же запроса. Сервер запоминает уже обработанные ключи и на повтор отдаёт сохранённый ответ, не выполняя операцию заново.

Именно так работают платёжные API. Stripe принимает заголовок Idempotency-Key, брокеры сообщений передают message_id. Критично, что ключ генерирует клиент: сервер физически не может отличить ретрай от нового запроса, если клиент не сказал ему, что это одно и то же намерение.

А вот какой ключ выбрать — вопрос, на котором ошибаются чаще всего.

Вид ключаПримерОценка
Случайный UUID на каждую попыткуuuid4() прямо перед каждым HTTP-вызовомНе работает вообще. Каждый ретрай приходит с новым ключом — сервер видит новое намерение
Случайный UUID на операциюuuid4() один раз, переиспользуется во всех ретраяхХорошо. Но если клиент перезапустится и забудет ключ, при новой попытке будет дубль
Естественный бизнес-ключorder_id, payment_intent_idЛучший вариант. Переживает перезапуск клиента, читается в логах, восстанавливается из данных
Хэш тела запросаsha256(body)Опасно. Два законных одинаковых платежа (купил кофе дважды по 200 рублей) склеятся в один

Как сделать списание денег безопасным для повтора

Разберём по шагам самый жёсткий случай — деньги.

Заводим таблицу ключей рядом с бизнес-данными, в той же базе. Уникальность ключа обеспечивает не приложение, а СУБД — первичным ключом.

CREATE TABLE idempotency_keys (
    key          TEXT PRIMARY KEY,
    request_hash TEXT NOT NULL,        -- чтобы поймать «тот же ключ, другое тело»
    status       TEXT NOT NULL,        -- in_progress | done
    response     JSONB,                -- сохранённый ответ для повторов
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Обработчик запроса выглядит так:

BEGIN;

-- 1. Пробуем застолбить ключ. Если он уже есть — вставки не будет.
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ('req-7f3a', 'a91c...', 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key;

-- 2a. Строка вернулась = это ПЕРВЫЙ раз. Делаем бизнес-операцию
--     ТУТ ЖЕ, в этой же транзакции:
UPDATE accounts SET balance = balance - 300
 WHERE id = 7 AND balance >= 300;         -- защита от ухода в минус

INSERT INTO ledger (account_id, amount, idempotency_key)
VALUES (7, -300, 'req-7f3a');

UPDATE idempotency_keys
   SET status = 'done', response = '{"ok": true, "balance": 700}'
 WHERE key = 'req-7f3a';

COMMIT;

-- 2b. Строка НЕ вернулась = ключ уже видели.
--     Читаем сохранённый response и отдаём клиенту его же.
--     Если там status = 'in_progress' — первый запрос ещё в работе,
--     отвечаем 409 Conflict: «операция выполняется, повторите позже».

Вся суть — в одной транзакции. Списание денег и отметка «ключ обработан» коммитятся вместе. Либо оба, либо ни одного. Если процесс умрёт посередине, база откатит всё, и повтор запроса пройдёт как первый — корректно.

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

balance = {"alice": 1000}
processed = {}          # idempotency_key -> сохранённый ответ (это и есть журнал ключей)


def withdraw(key, user, amount):
    if key in processed:                       # ключ уже видели — баланс НЕ трогаем
        return processed[key], "повтор (ответ взят из журнала)"
    if balance[user] < amount:
        raise ValueError("недостаточно средств")
    balance[user] -= amount                    # в реальности — одна транзакция БД:
    receipt = {"key": key, "user": user, "amount": amount, "balance": balance[user]}
    processed[key] = receipt                   # списание И запись ключа коммитятся вместе
    return receipt, "выполнено впервые"


for attempt in range(1, 4):
    receipt, note = withdraw("req-7f3a", "alice", 300)
    print("попытка %d: %-30s баланс=%d" % (attempt, note, receipt["balance"]))

# а это уже ДРУГАЯ операция — другой ключ, деньги списываются снова
receipt, note = withdraw("req-91bc", "alice", 200)
print("новый ключ: %-28s баланс=%d" % (note, receipt["balance"]))

Результат:

попытка 1: выполнено впервые              баланс=700
попытка 2: повтор (ответ взят из журнала) баланс=700
попытка 3: повтор (ответ взят из журнала) баланс=700
новый ключ: выполнено впервые            баланс=500

Три одинаковых запроса — одно списание. Другой ключ — новое списание. Ровно то поведение, которое нужно.

Как это работает

Почему ключ обязан лежать в той же транзакции

Соблазн велик: «положим обработанные ключи в Redis, там же быстро». И это ломается на первой же аварии. Между «проверил ключ в Redis» и «списал деньги в Postgres» процесс может умереть. Тогда деньги списаны, а ключ не отмечен — повтор спишет ещё раз. Или наоборот: ключ отмечен, а транзакция в Postgres откатилась — деньги не списаны, а повтор вежливо ответит «уже сделано».

Два хранилища — это распределённая транзакция со всеми её бедами. Уникальный индекс в той же базе, где лежат деньги, даёт атомарность бесплатно. Внешний Redis допустим для дешёвых операций («не отправляй письмо дважды»), но не для денег.

Гонка одновременных дублей

Два ретрая пришли одновременно и попали на разные поды. Оба делают INSERT ... ON CONFLICT DO NOTHING — но первичный ключ пропустит только одного. Второй увидит, что вставки не было и статус in_progress, и вернёт 409 Conflict: «операция выполняется». Клиент повторит чуть позже и получит сохранённый ответ. Именно уникальный индекс, а не проверка «а нет ли уже такого ключа?» в коде, спасает от гонки: между вашей проверкой и вставкой всегда может влезть конкурент.

Срок жизни ключей

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

Тот же ключ, другое тело

Для этого и нужен request_hash. Если клиент прислал ключ req-7f3a, но с суммой 5000 вместо 300 — это не ретрай, это баг на его стороне. Правильный ответ — 422 с внятным сообщением, а не тихая отдача чужого ответа.

Та же логика у потребителей очередей

С сообщениями всё идентично: перед обработкой пробуем вставить message_id (или бизнес-ключ вроде order_id) в таблицу обработанных — в той же транзакции, что и сама обработка. Эта таблица часто называется inbox, по симметрии с паттерном transactional outbox из раздела о данных.

Частые ошибки

  • «У нас RabbitMQ с подтверждениями, дубли невозможны». Возможны. Ack теряется, и брокер честно доставляет сообщение снова — он делает ровно то, что обещал.
  • Дедупликация «по времени». «Если такой же запрос был в последние 5 секунд — игнорируем». Ретрай придёт через 30 секунд (и пройдёт), а два законных платежа за кофе могут прийти за 2 секунды (и один потеряется). Не работает в обе стороны сразу.
  • Ключ генерирует сервер. Тогда он бесполезен: каждый ретрай клиента = новый запрос = новый ключ.
  • Ключ пишется отдельным коммитом от бизнес-операции. Между ними — окно, в котором падение процесса ломает всё.
  • Ключ сохранён, ответ — нет. На повтор отдаём пустой 200, и клиент не знает payment_id. Сохраняйте ответ, а не только факт обработки.
  • balance = balance - 300 без проверки остатка. При гонке баланс уходит в минус. Нужны и WHERE balance >= 300, и проверка числа изменённых строк.
  • Забыли про компенсации. Повторный «возврат средств» тоже обязан быть идемпотентным, иначе вернёте деньги дважды. Это вдвойне обидно.

Итоги

  • Дубли неизбежны: теряются ответы, теряются ack, ребалансируются группы потребителей, дважды кликают пользователи.
  • Exactly-once на уровне сети не существует. Есть at-least-once доставка + идемпотентный получатель. Это задача вашего кода, а не настроек брокера.
  • Самое дешёвое решение — переформулировать операцию: «установи значение» вместо «измени значение».
  • Если не получается — ключ идемпотентности, который генерирует клиент и повторяет во всех ретраях. Лучший ключ — естественный бизнес-id, а не случайный UUID.
  • Ключ и бизнес-операция коммитятся в одной транзакции той же базы. Уникальный индекс защищает от гонки, сохранённый ответ — от бессмысленных повторов.
  • У ключей есть срок жизни, у тела запроса — хэш, у параллельного дубля — ответ 409. И не забудьте про идемпотентность отмен и возвратов.
Проверьте себя
1. Почему запрос POST /accounts/7/withdraw с суммой 300 не является идемпотентным?
AПотому что стандарт HTTP запрещает повторять любые POST-запросы
BПотому что каждое повторное выполнение списывает ещё 300 — три повтора дадут минус 900
CПотому что в запросе не передан заголовок Idempotency-Key
DПотому что сервер может ответить ошибкой 500
2. Где должна сохраняться отметка об обработанном ключе идемпотентности при списании денег?
AВ Redis сразу после того, как клиенту отправлен успешный ответ
BВ логах приложения — оттуда её всегда можно поднять
CВ памяти процесса на несколько секунд, этого достаточно для ретраев
DВ той же транзакции той же базы данных, что и само списание