Один ключ — одна логическая операция

  1. 01ЗапросКлюч + тело
  2. 02ФиксацияКлюч вместе с эффектом
  3. 03ПовторСохранённый результат
Уникальный ключ помогает только вместе с атомарной проверкой и записью.

Когда мы разбирали баг в CRUD-модуле Tarantool, для каждой операции пришлось отвечать на простой вопрос: что произойдёт, если клиент повторит запрос после таймаута?

Так идемпотентность перестаёт быть термином из учебника и становится частью контракта системы.

Точное определение

Операция идемпотентна, если несколько одинаковых запросов оказывают на сервер тот же предусмотренный эффект, что и один такой запрос.

Это определение про состояние, а не про одинаковый HTTP-ответ. Например, первый DELETE /documents/42 может вернуть 204, а повторный — 404. Документ после обоих запросов отсутствует, поэтому предусмотренный эффект не изменился.

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

HTTP-метод — только начало

По HTTP-семантике PUT, DELETE и безопасные методы идемпотентны. POST и PATCH не обязаны быть идемпотентными.

Но название метода не исправит реализацию. Если PUT /limits/42 при каждом вызове прибавляет 100 к лимиту, контракт нарушен. И наоборот: POST /payments можно сделать безопасным для повторов с помощью idempotency key.

Почему UUID недостаточно

Уникальный идентификатор полезен только тогда, когда сервер сохраняет и проверяет его. Сгенерировать новый UUID для каждого ретрая — значит создать несколько разных операций.

Рабочий сценарий выглядит так:

  1. Клиент создаёт один ключ для одной логической операции и повторяет запрос с тем же ключом.
  2. Сервер ограничивает область ключа, например пользователем и маршрутом.
  3. Вместе с ключом сохраняется хеш запроса и итог операции.
  4. Бизнес-изменение и запись ключа фиксируются атомарно либо защищаются уникальным ограничением и согласованной транзакцией.
  5. Повтор с тем же ключом и тем же телом получает сохранённый результат.
  6. Тот же ключ с другим телом отклоняется как конфликт.

Нужно заранее определить срок хранения ключей. Он должен покрывать реальное окно повторов, иначе старый запрос после очистки снова станет «новым».

Идемпотентный потребитель сообщений

При at-least-once доставке дубль — штатная ситуация. Потребитель может защититься двумя способами:

  • сделать операцию естественно идемпотентной: status = 'approved' вместо approved_count += 1;
  • хранить event_id во входной таблице и фиксировать его в одной транзакции с бизнес-изменением.

Присваивание статуса безопасно только относительно повтора того же действия. Поздний повтор status = 'approved' после отмены может вернуть объект в неверное состояние. Нужны проверка допустимого перехода и версии объекта; идемпотентность не заменяет порядок событий. Даже для сохранённого результата повторно проверяем полномочия: знание ключа не даёт права читать чужую операцию.

Если обработчик вызывает внешнюю систему, одной локальной транзакции уже мало. Нужны idempotency key у получателя, согласованный процесс повторов или отдельный паттерн вроде inbox/outbox.

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

Что дают базы данных

Уникальные ограничения, транзакции и условные обновления — строительные блоки идемпотентности, но не готовая гарантия.

ACID-транзакция может дважды успешно выполнить balance = balance - 100. UUID может дважды попасть в разные таблицы, если его нигде не проверяют. Надёжность появляется только тогда, когда повтор распознаётся в той же границе, где меняется бизнес-состояние.

Похожий принцип действует в инфраструктурном коде. Многие модули Ansible описывают желаемое состояние и поэтому допускают повторный запуск. Произвольная shell-команда автоматически идемпотентной не становится.

Короткая проверка операции

Перед релизом я задаю четыре вопроса:

  1. Что повторит клиент после таймаута?
  2. Как сервер поймёт, что это та же логическая операция?
  3. Где атомарно сохраняются бизнес-эффект и признак обработки?
  4. Что произойдёт, если сбой случится между ними?

Ответы полезнее, чем формальная пометка «идемпотентно» в спецификации.

Определение HTTP-идемпотентности — в RFC 9110, раздел 9.2.2.

Разбор сбоя: ответ потерялся, операция уже выполнена

Учебный сценарий: клиент создаёт заявку с ключом req-42. Сервер записывает заявку, но ответ теряется в сети. Клиент видит тайм-аут — это не доказательство, что сервер ничего не сделал.

Следующий запросРеакция сервера
Тот же ключ и то же содержимоеВернуть сохранённый результат без второй заявки
Тот же ключ, другое содержимоеСообщить о конфликте: ключ нельзя переиспользовать для другого действия
Тот же ключ, первая операция ещё идётВернуть согласованный статус ожидания или предложить повторить позже
Другой ключРассматривать как другую операцию в рамках бизнес-правил

Самое неприятное место — сбой между бизнес-записью и сохранением результата для повторов. Если они находятся в одной базе, проектируем общую транзакцию и уникальность ключа. Если есть внешний эффект, например отправка письма, потребуется отдельная стратегия его доставки и дедупликации. Таблица ключей в нашей базе не может отменить уже отправленное чужим сервисом письмо.

Проверять стоит не только два последовательных запроса, но и два одновременных. Оба обработчика могут успеть увидеть «ключа ещё нет». От гонки защищает атомарное ограничение, а не оптимистичная проверка в коде.


Из моего Telegram-канала @it_underside.

Ваш ДПУПП

Похожие статьи