Один ключ — одна логическая операция
- 01ЗапросКлюч + тело
- 02ФиксацияКлюч вместе с эффектом
- 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 для каждого ретрая — значит создать несколько разных операций.
Рабочий сценарий выглядит так:
- Клиент создаёт один ключ для одной логической операции и повторяет запрос с тем же ключом.
- Сервер ограничивает область ключа, например пользователем и маршрутом.
- Вместе с ключом сохраняется хеш запроса и итог операции.
- Бизнес-изменение и запись ключа фиксируются атомарно либо защищаются уникальным ограничением и согласованной транзакцией.
- Повтор с тем же ключом и тем же телом получает сохранённый результат.
- Тот же ключ с другим телом отклоняется как конфликт.
Нужно заранее определить срок хранения ключей. Он должен покрывать реальное окно повторов, иначе старый запрос после очистки снова станет «новым».
Идемпотентный потребитель сообщений
При 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-команда автоматически идемпотентной не становится.
Короткая проверка операции
Перед релизом я задаю четыре вопроса:
- Что повторит клиент после таймаута?
- Как сервер поймёт, что это та же логическая операция?
- Где атомарно сохраняются бизнес-эффект и признак обработки?
- Что произойдёт, если сбой случится между ними?
Ответы полезнее, чем формальная пометка «идемпотентно» в спецификации.
Определение HTTP-идемпотентности — в RFC 9110, раздел 9.2.2.
Разбор сбоя: ответ потерялся, операция уже выполнена
Учебный сценарий: клиент создаёт заявку с ключом req-42. Сервер записывает заявку, но ответ теряется в сети. Клиент видит тайм-аут — это не доказательство, что сервер ничего не сделал.
| Следующий запрос | Реакция сервера |
|---|---|
| Тот же ключ и то же содержимое | Вернуть сохранённый результат без второй заявки |
| Тот же ключ, другое содержимое | Сообщить о конфликте: ключ нельзя переиспользовать для другого действия |
| Тот же ключ, первая операция ещё идёт | Вернуть согласованный статус ожидания или предложить повторить позже |
| Другой ключ | Рассматривать как другую операцию в рамках бизнес-правил |
Самое неприятное место — сбой между бизнес-записью и сохранением результата для повторов. Если они находятся в одной базе, проектируем общую транзакцию и уникальность ключа. Если есть внешний эффект, например отправка письма, потребуется отдельная стратегия его доставки и дедупликации. Таблица ключей в нашей базе не может отменить уже отправленное чужим сервисом письмо.
Проверять стоит не только два последовательных запроса, но и два одновременных. Оба обработчика могут успеть увидеть «ключа ещё нет». От гонки защищает атомарное ограничение, а не оптимистичная проверка в коде.
Из моего Telegram-канала @it_underside.
Ваш ДПУПП
Похожие статьи
EventStorming: польза, ограничения и практика
Практический разбор EventStorming: какие задачи решает метод, где возникают ограничения и как подготовить сессию, чтобы команда получила полезный результат.
Форматы обмена данными: JSON, XML, Protobuf, Avro и MessagePack
Как выбирать формат не по моде, а по контракту: эволюция схем, обратная и прямая совместимость, инструменты и измеренная стоимость обработки.
UML на практике: где диаграмма помогает, а где становится лишней
Как выбрать UML-диаграмму под конкретный вопрос, определить срок её жизни и не превратить моделирование в отдельный проект.