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

Повторная доставка сообщения, таймаут HTTP-клиента или нажатие кнопки после зависшего экрана могут создать два заказа вместо одного. Идемпотентность не убирает повторы: она делает их безопасными. Ниже — практические приёмы для API и фоновых обработчиков, рассчитанные на PostgreSQL 16 и обычную сервисную архитектуру.
Идемпотентность начинается не с ключа
Операция идемпотентна, если повторное выполнение с теми же входными данными приводит к тому же значимому результату, что и первое выполнение. В HTTP это не означает, что сервер обязан вернуть абсолютно одинаковый ответ или не обновить техническое поле вроде времени последнего обращения.
Запрос на чтение обычно повторить безопасно. Запрос на создание платежа, заказа или отправку письма — нет, если система не умеет связать повторы с одной исходной операцией.
Первый вопрос при проектировании звучит не так: «Где положить идемпотентный ключ?» Сначала нужно определить бизнес-операцию:
- какое действие считается одним — создание заказа, а не каждый HTTP-запрос;
- какой результат нужно вернуть при повторе;
- сколько времени повтор должен распознаваться;
- что делать, если первый запуск завершился ошибкой или оборвался после записи в базу.
Идемпотентность — это гарантия на границе бизнес-операции, а не декоративный заголовок в HTTP.
Сноска. Идемпотентный ключ — уникальный идентификатор одной попытки выполнить бизнес-операцию. Он не равен идентификатору пользователя, заказа или HTTP-соединения: один и тот же ключ должен сопровождать повторы одной операции.
Четыре рабочих подхода
1. Естественный уникальный ключ
Иногда отдельный токен не нужен. У операции уже есть устойчивый идентификатор: номер внешнего платежа, идентификатор поставки или комбинация «магазин — номер заказа во внешней системе». В базе на этот набор полей ставят уникальное ограничение.
При повторе сервис пытается записать ту же сущность, получает уже существующую запись и возвращает её. Проверка «сначала прочитать, потом вставить» хуже: два параллельных запроса могут одновременно не найти строку и оба пройти проверку. Защищать нужно самой базой данных.
Подход хорошо работает, когда внешний идентификатор действительно стабилен и однозначен. Если клиент может случайно повторно использовать такой номер для другого заказа, уникальность начнёт блокировать легитимные операции.
2. Таблица ключей идемпотентности
Для публичного API обычно передают отдельный ключ в запросе. Сервер сохраняет его вместе с владельцем операции, отпечатком входных параметров, статусом и результатом. Повтор с тем же ключом получает сохранённый результат, а не запускает бизнес-логику заново.
Отпечаток параметров важен. Если клиент повторно использовал ключ, но прислал другой товар или сумму, сервер не должен молча вернуть старый ответ. Такой запрос нужно отклонить как конфликт параметров.
В PostgreSQL 16 запись ключа и изменение бизнес-сущности следует проводить в одной транзакции, когда это возможно. Тогда после успешного ответа не останется ключа без результата, а после отката не появится блокировка на операцию, которой фактически не было.
3. Уникальность на бизнес-сущности
Этот вариант похож на естественный ключ, но применяется внутри доменной модели. Например, у заказа может быть уникальная пара «идентификатор корзины — версия оформления». Повторный запрос не создаёт новый заказ, потому что сама структура данных запрещает второй экземпляр.
Это хороший базовый слой защиты, но не всегда достаточный. Если первый запрос создал заказ, а ответ потерялся, повтор можно корректно связать с заказом. Если же внутри операции есть побочные действия — письмо, начисление бонусов или вызов платёжного провайдера, — для них потребуются отдельные гарантии.
4. Очередь и идемпотентный обработчик
Очередь может доставить одно сообщение несколько раз. Поэтому обработчик обязан считать повтор нормальным сценарием. Обычно сообщение содержит устойчивый идентификатор события, а обработчик сохраняет факт обработки с уникальным ограничением.
Важно не путать «сообщение обработано» и «сообщение получено». Факт обработки нужно фиксировать в той же транзакции, что и изменения, которые должны быть защищены от повтора. Если брокер подтвердить раньше записи в базе, падение процесса создаст дыру: сообщение исчезнет, а действие не завершится.
| Подход | Когда выбирать | Что выигрываете | Чем платите |
|---|---|---|---|
| Естественный уникальный ключ | Есть стабильный ID внешней операции | Меньше инфраструктуры, защита на уровне БД | Нужно доказать, что ключ не переиспользуется |
| Таблица идемпотентности | Публичный API и повторяемые команды | Можно вернуть тот же результат и проверить параметры | Нужно хранить записи, решать вопрос срока жизни и очистки |
| Уникальность сущности | Одна доменная сущность должна существовать в единственном экземпляре | Правило видно в схеме базы | Не защищает автоматически внешние побочные эффекты |
| Идемпотентный обработчик очереди | События доставляются повторно | Повторная доставка становится штатной | Нужно согласовать обработку и подтверждение сообщения |
Что хранить вместе с ключом
Минимальная запись должна отвечать на четыре вопроса: кто отправил операцию, что именно запросил, чем она закончилась и когда запись можно удалить. Практический набор выглядит так:
- идентификатор клиента или пользователя;
- сам ключ с уникальностью в нужной области — например, для конкретного клиента, а не глобально;
- отпечаток значимых параметров запроса;
- статус: выполняется, завершено или завершилось ошибкой;
- код и тело ответа либо ссылка на созданную сущность;
- время создания и срок хранения.
Секреты, платёжные реквизиты и полное тело запроса хранить без необходимости не стоит. Для проверки совпадения параметров достаточно отпечатка, рассчитанного по канонизированному набору полей. При этом алгоритм и состав полей должны быть стабильными: разный порядок ключей в JSON не должен превращать одинаковые данные в разные запросы.
Гонки, таймауты и незавершённые операции
Самый опасный сценарий выглядит так: сервер списал деньги, но упал до ответа клиенту. Клиент повторяет запрос. Если сервис воспринимает повтор как новый, вы получаете двойное списание. Если он возвращает старый результат по ключу, нужна уверенность, что первый результат действительно сохранён.
Для конкурентных запросов полезна явная промежуточная запись со статусом «выполняется». Второй запрос с тем же ключом может:
- дождаться завершения первой операции;
- получить ответ «операция ещё выполняется» и проверить результат позже;
- вернуть сохранённую ошибку, если первая попытка завершилась неуспешно;
- перезапустить обработку только после безопасного истечения аренды, если первый процесс действительно погиб.
Бесконечно держать статус «выполняется» нельзя. Но и короткий таймаут опасен: медленная операция ещё может продолжаться, а новый запуск начнётся раньше её завершения. Время аренды выбирают по реальному максимальному времени операции, а не по среднему latency.
Когда какой вариант выбирать
Если операция синхронно меняет данные в PostgreSQL и у неё есть естественный внешний идентификатор, начните с уникального ограничения и возврата существующей записи. Это проще, чем отдельный механизм ключей.
Если клиент не имеет устойчивого идентификатора, а команда может повториться после таймаута, используйте ключ идемпотентности и хранение результата. Для платежей и других необратимых действий этого обычно недостаточно без проверки статуса у внешнего провайдера: ваша база не знает, что произошло за пределами транзакции.
Если работа уходит в очередь, идемпотентность должна быть у обработчика. Таблица обработанных событий или уникальный идентификатор операции защищают от повторной доставки, но не решают автоматически проблему частичного выполнения. Для цепочек действий отдельно проектируют компенсацию или повторяемый шаг.
Что ломается на практике
Ключ генерируется сервером после получения запроса
Если клиент не успел получить ответ и повторил запрос, сервер создаст новый ключ. Защита не сработает. Ключ должен появляться у инициатора операции и сохраняться до получения окончательного результата.
Ключ не связан с владельцем
Глобальная уникальность может привести к тому, что один клиент случайно получит результат операции другого. Область уникальности и права доступа проверяют отдельно. Сам ключ не заменяет авторизацию.
В ключ попадают случайные поля
Время запроса, случайный заголовок или служебное поле меняют отпечаток. Повтор выглядит новым, хотя бизнес-команда та же. В отпечаток включают только поля, влияющие на результат.
Ответ сохранён, а побочный эффект — нет
Запись результата в таблице ключей не доказывает, что письмо ушло или платёжный провайдер подтвердил списание. Для таких действий применяют отдельную очередь, журнал событий и повторяемый обработчик. Инвалидация кэша — отдельная тема: здесь рассматриваем только защиту бизнес-операции от повторного запуска.
Срок хранения выбран «на глаз»
Слишком короткий срок открывает окно для повторного выполнения. Слишком длинный увеличивает объём данных и усложняет исправление ошибочных операций. Срок должен быть не меньше максимального времени, в течение которого клиент, очередь или внешний провайдер реально могут повторить команду.
Как проверять идемпотентность на проекте
Обычный интеграционный тест одного успешного запроса ничего не доказывает. Минимальный набор проверок должен включать:
- два одинаковых запроса одновременно;
- повтор после успешной записи, но до отправки ответа;
- повтор с тем же ключом и изменённой суммой или составом заказа;
- повтор после временной ошибки внешнего сервиса;
- перезапуск обработчика очереди после частичного выполнения;
- истечение срока хранения ключа и повтор операции после него.
В метриках полезно разделять новые операции и повторы: считать долю повторных ключей, конфликты параметров, зависшие статусы и время восстановления после сбоя. Для каждой защищённой операции должен существовать идентификатор, по которому можно пройти путь от входного запроса до записи в базе и внешнего вызова.
Что делать дальше
Начните с карты необратимых действий: списание денег, создание заказа, выдача бонусов, отправка письма, публикация события. Для каждого укажите естественный ключ, границу транзакции, допустимое время повтора и побочные эффекты. Затем добавьте конкурентные тесты и дашборд повторов.
На следующем этапе проверьте не только HTTP-эндпоинты, но и фоновые обработчики. В проде смотрите на зависшие операции, конфликты отпечатков и случаи, когда внешний сервис сообщил результат после сетевого таймаута. Именно эти события показывают, действительно ли идемпотентность защищает систему, а не просто присутствует в контракте API.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: RFC 9110: HTTP Semantics, раздел 9.2.2 Idempotent Methods, PostgreSQL 16 Documentation: Constraints
Частые вопросы
Идемпотентность означает, что повторный запрос всегда вернёт тот же HTTP-ответ?
Достаточно ли передавать Idempotency-Key в заголовке?
Что будет, если повторить ключ с другими параметрами?
Нужно ли хранить ключи бесконечно?
Защитит ли идемпотентность от двойного списания у платёжного провайдера?
Что делать, если обработчик очереди упал после изменения базы, но до подтверждения сообщения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.