Идемпотентность и повторная доставка
В распределённой системе любое сообщение может прийти дважды. Урок о том, как сделать так, чтобы от этого не пострадали ни данные, ни деньги пользователя.
Идемпотентная операция — операция, повторное выполнение которой с теми же входными данными не меняет результат по сравнению с однократным. Выполнить её один раз и пять раз — одно и то же.
Предыдущий урок закончился неприятным выводом: ретрай спасает от сетевых сбоев, но повторяет запросы. А повторить «спиши 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. И не забудьте про идемпотентность отмен и возвратов.