Идемпотентность: как не создать два заказа одним кликом
Идемпотентность запросов в сервисе: повторные клики, потерянные ответы и одинаковые операции. Как проектировать ключ, результат и проверку дублей.
Автор: Александр Поркулевич, CEO и основатель, 404gen.
Идемпотентность бизнес-операции означает, что повтор одного намерения не создаёт дополнительный результат. Для заказа приложение связывает повторные запросы с одной операцией, проверяет её состояние и возвращает понятный ответ. Заблокировать кнопку недостаточно: повтор может прийти из сети, фонового задания или другого окна.
Клиент нажал дважды. Склад получил дважды
Учебная сцена: покупатель оформляет заказ в кабинете поставщика. Нажимает «Подтвердить». Крутится кружок. Ответа нет. Покупатель нажимает ещё раз, потому что товар нужен, а кружок не входит в производственный план. Через минуту на почту приходят два подтверждения. В системе два заказа, а намерение было одно.
У пользователя логичная претензия: «Я просто хотел понять, сработало ли». У разработчика привычная защита: «Зачем нажимать повторно?» У менеджера теперь два документа и одна головная боль. Если заказ уже ушёл в подбор или резерв, удалить лишнюю запись недостаточно. Нужно разобраться с последующими действиями. Кнопка была маленькая, экскурсия по компании получилась большая.
Проблема называется дублирование заказов, но причиной не обязательно является второй клик. Запрос может повторить клиентская библиотека, прокси, очередь или служба интеграции. Пользователь может открыть другое окно после зависания. Сервер может закончить операцию и потерять ответ на обратном пути. Поэтому борьба только с пальцем клиента оставляет большую часть маршрута без защиты.
В определении MDN идемпотентность описывается через одинаковый эффект повторения операции. Для прикладного сервиса важно определить этот эффект на уровне бизнеса. Повторить чтение каталога и повторить создание заказа — разные задачи. Название HTTP-метода само по себе не делает создание безопасным.
В этом разборе речь о создании заказа и похожих операций, а не о доставке всех событий интеграции. Отдельная статья про вебхуки и повторную доставку рассматривает соседний участок. Здесь начнём с человека, который один раз захотел купить, и системы, которая должна это намерение сохранить.
Что считать одним намерением
Заказ с одинаковыми товарами не обязательно является дублем. Покупатель может законно оформить два одинаковых заказа для разных объектов или дат. Если система объявляет любую похожую корзину ошибкой, она мешает продажам. Нужно различать повтор передачи одной операции и новую самостоятельную операцию, даже когда их содержимое совпадает.
Один подход — создавать идентификатор попытки оформления до первого запроса. Он относится к конкретному подтверждению корзины. Пока клиент выясняет результат этой попытки, он использует тот же идентификатор. Когда пользователь осознанно начинает новое оформление, приложение создаёт новый. Важно описать этот переход в интерфейсе и на сервере, а не оставить его случайному обновлению страницы.
Идентификатор не заменяет содержимое. Сервер связывает его с принятой версией операции: организацией, составом, адресом и существенными условиями. Если повтор приходит с тем же ключом, но другим заказом, нельзя молча вернуть старый успех. Нужно сообщить конфликт и помочь пользователю продолжить с правильной версией. Иначе защита от дублей превратится в защиту от понимания.
Область ключа тоже требует решения. Один и тот же набор символов у двух компаний не должен связывать их операции. Контекст клиента или организации определяется доверенным механизмом авторизации. Пользовательский параметр не должен позволять указать чужого владельца. Ключ делает повтор узнаваемым; права определяют, кто может увидеть и продолжить эту операцию.
Не используйте похожесть текста как единственное основание. «Товар А, десять штук» может описывать повторный заказ следующей недели. Метка времени с округлением тоже ненадёжна: два законных действия могут произойти рядом. Бизнес-идентификатор намерения даёт более явную связь. Проверка похожих заказов может дополнительно предупреждать человека, но это уже другой механизм.
Таймаут не означает, что заказ не создался
Сеть даёт несколько разных картин. Запрос не дошёл до сервера. Дошёл, но операция ещё выполняется. Выполнился, но ответ потерялся. Ответ пришёл поздно, а интерфейс уже показал ошибку. Пользователь видит одну надпись «Попробуйте ещё раз», хотя серверные состояния различаются. Если повтор всегда создаёт новый заказ, приложение умножает неопределённость.
Нужен способ узнать статус первоначальной операции. Это может быть запрос состояния по её идентификатору или повтор с тем же ключом, обработанный по принятому контракту. Выбор зависит от системы. Существенно другое: клиент не обязан угадывать, есть ли заказ, по наличию ответа на первый запрос. Результат операции должен иметь самостоятельную жизнь.
В интерфейсе стоит различать «ещё проверяем», «не удалось установить результат» и «операция отклонена». Первый вариант не приглашает создать новую покупку. Второй предлагает безопасно восстановить состояние или связаться с поддержкой. Третий показывает причину, которую можно исправить. Эти сообщения не обязаны быть техническими, но должны соответствовать фактам.
Потерянный ответ особенно неприятен при переходе на другую страницу. Покупатель возвращается в историю и не находит заказ из-за задержки обновления списка. Нажимает снова. Поэтому история, экран оформления и уведомления должны опираться на одинаковое состояние операции. Пустой список после неопределённого результата нельзя выдавать за доказательство отсутствия заказа.
Не обещайте мгновенное восстановление, если сервер сам не знает финала внешнего действия. Бывает, что заказ передан в отдельную систему, которая ответила с задержкой. В этом случае промежуточное состояние честнее ложного успеха или автоматического нового запроса. Поддержка должна видеть связь попытки, заказа и внешней передачи, чтобы не расследовать всё по скриншоту клиента.
Где должна находиться защита
Отключить кнопку после нажатия полезно. Это уменьшает случайные повторы в одном окне. Но защита на клиенте исчезает при обновлении страницы, повторе запроса или работе другого устройства. Сервер принимает решение о создании результата. Именно там требуется проверка идентичности операции и согласованное изменение состояния.
Типовой проектный вариант: запись операции с уникальным ключом в пределах владельца, состояние выполнения и связанный результат. Два параллельных запроса пытаются зарегистрировать одну операцию. Только один должен получить право создать заказ. Второй узнаёт о существующем выполнении или результате. Конкретные блокировки и транзакции выбирают под базу и нагрузку.
Документация PostgreSQL об ограничениях описывает уникальность, в том числе для сочетания полей. Это полезный строительный элемент. Но уникальная строка в базе не защищает сама по себе письмо, резерв в другой системе или внешнюю оплату. Для таких последствий нужна связанная процедура исполнения и восстановления.
Проверка «сначала посмотрели, потом создали» без согласованности уязвима к гонке. Два процесса одновременно видят, что записи ещё нет. Оба создают результат. На спокойной локальной демонстрации это может не проявиться. Испытание должно включать параллельную отправку одного ключа, а не только два последовательных клика с паузой.
Отдельно проверьте сбой между регистрацией операции и созданием заказа. Если система записала «начали» и упала, ключ не должен навсегда превращаться в тупик. Нужны срок контроля, состояние для восстановления и ответственная процедура. «Не создавать дубль» не равно «никогда больше не позволять завершить заказ».
Состояния важнее универсального ответа OK
У операции могут быть состояния: принята, выполняется, завершена, отклонена, требует восстановления. Они должны иметь понятный смысл. «Завершена» означает, что согласованный результат создан и доступен. «Отклонена» — что запрос не может быть выполнен по указанной причине. «Требует восстановления» — что последствия нужно выяснить, а не начать заново.
Не все ошибки стоит сохранять одинаково. Неверные исходные данные могут быть исправлены до исполнения. Сбой после внешнего действия может требовать сверки. Поэтому ответ на вопрос «повторять ли этот ключ?» зависит от стадии, а не только от кода ошибки. Контракт должен объяснять, какие изменения допускаются и когда новая версия становится новой операцией.
В качестве конкретного примера можно посмотреть правила идемпотентных запросов Stripe. У API v1 и v2 различается поведение повторов; детали сохранения ответов и сроков нельзя переносить в собственный сервис без решения. Это пример документированного контракта, а не универсальная инструкция «делать как у платежей».
Для кабинета поставщика разумно писать контракт языком заказа. Если подтверждение уже создало заказ, повтор возвращает ссылку на него. Если операция выполняется, интерфейс показывает ожидание и проверяет состояние. Если состав с тем же ключом изменился, сервер сообщает конфликт. Если последствия неизвестны, автоматическое новое создание блокируется до восстановления.
При этом одинаковый результат не обязан означать дословно одинаковый ответ через неделю. Статус созданного заказа мог измениться. Нужно решить, возвращается ли исходное подтверждение, актуальное состояние или оба представления. Главное — не создавать ещё один заказ и не смешивать факт создания с текущим этапом обработки.
Ключ не разрешает подменить заказ
Покупатель отправил корзину из трёх позиций. Затем исправил адрес и нажал повторно. Что произошло? Если повтор относится к прежней попытке, адрес уже принятого заказа нельзя менять незаметно. Если это новая операция, прежняя может оставаться действующей. Интерфейс должен предложить явный путь: проверить существующий заказ, изменить его по правилам или отменить и оформить новый.
Для сравнения содержимого определите существенные поля. Порядок позиций в JSON обычно не должен делать одинаковый заказ разным. Но изменение количества, организации, адреса, способа получения или согласованной цены может менять смысл. Нормализацию нельзя придумывать после первой спорной ситуации: она входит в контракт операции.
Производные значения требуют осторожности. Если цена пересчитывается на сервере, повтор не должен внезапно создать заказ по новым условиям из-за обновления прайса. Возможно, первоначальное предложение имеет срок действия и версию. Возможно, повтор возвращает уже принятые условия. Эти решения продуктовые: база данных не знает, что обещано покупателю.
Не следует использовать хэш содержимого как единственный идентификатор намерения. Два законных одинаковых заказа дадут одно значение. Хэш полезен для сравнения принятой версии с повтором, но отдельный идентификатор операции сохраняет право оформить такое же содержание ещё раз. Намерение и состав связаны, но не взаимозаменяемы.
Если клиент пытается продолжить чужую операцию, верните корректный отказ без раскрытия её данных. Наличие ключа не является разрешением на просмотр заказа. Область владельца, права пользователя и доступ к результату проверяются так же, как при обычном чтении. Подробности ролевой модели раскрыты в материале про доступ в бизнес-приложении.
Заказ один. Писем всё равно три
Допустим, запись заказа защищена уникальностью. Но каждый повтор запускает отправку письма, создание задачи менеджеру и резервирование товара. Формально заказа один. На практике покупатель получает три подтверждения, а склад видит несколько запросов. Идемпотентность нужно определить для согласованных последствий, а не только для основной строки.
Полезно составить карту эффектов. Основной результат: созданный заказ. Внутренние последствия: задачи, журнал, резерв. Внешние: письмо, сообщение, передача в ERP. Для каждого эффекта определяют идентификатор, состояние и возможность повторения. Не все действия можно сделать атомарно вместе с базой. Поэтому порядок и восстановление нужно проектировать отдельно.
Один возможный подход — сохранять намерение внешней передачи рядом с результатом и исполнять его отдельным обработчиком. Но даже такой обработчик должен понимать повтор, сбой и ответ внешней системы. Название архитектурного шаблона не отменяет неопределённость сети. Если получатель не поддерживает идентичность операции, нужна сверка по доступным бизнес-признакам и процедура исключений.
Для уведомлений повтор иногда допустим, но его цена должна быть осознанной. Повторное письмо о том же заказе — неприятно. Повторная команда отгрузки — намного серьёзнее. Не делайте один универсальный режим для всех побочных действий. Определите, какие можно повторить, какие требуют проверки и какие должны остановиться при неизвестном результате.
Особенно важно не объявлять доставку письма завершением всего заказа. Заказ может существовать, даже если почтовая служба временно недоступна. Пользователь должен видеть результат в кабинете. Менеджер — понимать, что уведомление задержано. Смешивание состояний создаёт повод оформлять заново то, что уже успешно принято.
Сколько хранить связь попытки и результата
Срок хранения ключа определяется маршрутом операции. Если мобильное приложение может восстановить очередь через несколько дней, часовая память сервера недостаточна. Если интеграция повторяет сообщения после длительного сбоя, окно должно учитывать это поведение. Срок выбирают из реальных повторов и бизнес-процесса, а не из случайного примера SDK.
Запись результата и временная запись дедупликации могут иметь разные сроки. Заказ существует долго, а техническая попытка может архивироваться. Нужно решить, как поздний повтор связывается с уже существующим результатом. Простое удаление ключей способно превратить старый повтор в новую покупку. Пользователь не знает, что вчера закончился срок вашей памяти.
При очистке сохраняйте достаточно оснований для расследования: идентификатор операции, связанный результат, существенная версия и состояние. Не нужно бессрочно хранить весь исходный запрос с персональными данными, если это не требуется. Объём журналов, доступ и сроки хранения также проектируются. Защита от дублей не даёт автоматического разрешения складировать всё подряд.
Для ручных операций поддержки нужны правила. Сотрудник может разблокировать зависшую попытку или завершить восстановление, но не должен стирать её историю ради удобства. Если после ручного изменения приходит повтор, система обязана понимать новый статус. Ручное вмешательство — часть модели состояний, а не тайный выход из неё.
Периодически анализируйте реальные повторы: когда приходят, откуда, как часто после неизвестного результата. Эти данные помогают уточнить окно хранения и интерфейс. Не считайте каждый повтор попыткой мошенничества. Очень часто это просто человек, которому приложение не смогло нормально ответить.
Проверки, которые отличают защиту от декорации
Проверка начинается с одного нормального оформления: заказ создан, результат доступен, ключ связан с принятой версией. Затем повторяют тот же запрос после успеха. Следующий шаг — параллельные повторы. Потом потеря ответа, сбой обработчика и перезапуск клиента. Это разные испытания, потому что они проходят через разные участки реализации.
| Сценарий | Ожидаемый результат | Что считать ошибкой |
|---|---|---|
| Повтор после успеха | Тот же заказ доступен клиенту | Создана вторая запись |
| Два параллельных запроса | Одно право на исполнение | Два заказа или двойной резерв |
| Ответ потерян | Статус первоначальной операции восстанавливается | Новая покупка вместо проверки |
| Тот же ключ, другой состав | Понятный конфликт версии | Молчаливая подмена результата |
| Чужой пользователь | Нет доступа к чужой операции | Ключ раскрывает заказ |
| Сбой после внешнего действия | Сверка и восстановление | Повторная отгрузка вслепую |
Во время проверки смотрите на базу, задачи и внешний журнал. Надпись «Уже оформлено» на экране не доказывает, что другой обработчик не запустил резерв. Сопоставляйте количество принятых намерений и созданных результатов. Для испытаний используйте тестовые организации и безопасные интеграции, чтобы не отправлять реальные документы ради красивой галочки.
Проверьте новую законную операцию с таким же составом. Она должна создать отдельный заказ, если пользователь осознанно подтверждает новое намерение. Этот тест ловит противоположную ошибку: слишком агрессивная защита режет нормальные продажи. Система должна различать повтор и повторную покупку, а не просто запрещать всё похожее.
Нагрузочный сценарий нужен, если оформление проходит через несколько экземпляров сервиса. Локальный флаг памяти одного процесса не синхронизирует соседний. После перезапуска защита тоже должна сохранять смысл. Полезный критерий приёмки описывает наблюдаемый результат при заданных повторах и сбоях, а не наличие переменной с названием idempotency.
Что должна видеть поддержка
Покупатель пишет: «У меня списалось время, а заказа нет». Поддержке нужны идентификатор попытки, состояние, связанный заказ и последние события. Если поиск доступен только по номеру заказа, а номер не дошёл до пользователя, разбор начинается с угадывания. Добавьте понятный код обращения или способ найти незавершённое оформление в кабинете.
Журнал должен различать первый запрос, повтор, конфликт версии и восстановление. Сохранённый факт повторного запроса не равен второй покупке. В отчётах иначе может появиться ложная конверсия: система посчитала три отправки формы как три новых намерения. Аналитика и операционные данные должны использовать согласованные определения.
Ручное исправление дубля тоже фиксируют. Почему возник, какие последствия отменены, какие остались, что увидел пользователь. История нужна для улучшения процесса и сверки, а не для обвинения покупателя. Про устройство понятного журнала есть отдельный разбор аудита действий в приложении.
Не показывайте в интерфейсе поддержки секреты интеграции или полный технический запрос без необходимости. Сотруднику обычно достаточно бизнес-состояния и безопасных идентификаторов. Доступ к подробным следам можно отделить. Возможность разобраться не должна автоматически превращаться в возможность менять любые операции.
Если система обнаружила дубль задним числом, не удаляйте его незаметно. Второй документ мог уже уйти в бухгалтерию или производство. Нужен управляемый статус отмены и связь с исходной операцией. Техническая чистота таблицы не равна чистоте бизнес-процесса. Особенно когда люди уже начали работать по обеим записям.
Защита экономит не только строки в базе
В условном расчёте можно оценить число спорных оформлений за месяц, время менеджера и склада, стоимость возврата или повторной доставки. Эти величины относятся к конкретному процессу; в статье не приводим выдуманную экономию клиента. Иногда проблема редкая, но последствия дорогие. Иногда дублей много, а исправление простое. Приоритет зависит от обеих сторон.
Есть и потеря доверия. Покупатель не обязан знать, что ответ потерялся после успешной транзакции. Для него компания дважды приняла заказ или не смогла сказать, принят ли он. Чем яснее восстановление статуса, тем меньше поводов писать менеджеру и обходить приложение через телефон. Это можно проверять по обращениям, а не объявлять ростом продаж заранее.
Не обязательно переделывать весь сервис сразу. Выберите операцию с дорогими последствиями: оформление заказа, резерв, регистрация заявки или отправка документа. Опишите намерение, состояния и внешние эффекты. Реализуйте ограниченный маршрут и испытайте его. После этого переносите принцип на соседние операции, учитывая их отличия.
При разработке интеграций и автоматизации такой контракт помогает согласовать ответственность между системами. Одна сторона должна понимать, какой идентификатор передаётся и что означает повтор. Другая — как вернуть результат и восстановить неопределённое состояние. «Мы отправили, у них не создалось» перестаёт быть окончательным объяснением.
Частые вопросы
Достаточно отключить кнопку после первого нажатия?
Нет. Это полезная защита интерфейса, но запрос может повторить сеть, очередь или другое окно. Сервер должен связывать повтор с одной операцией и проверять её результат.
Одинаковые корзины нужно считать одним заказом?
Не всегда. Покупатель может законно оформить одинаковый состав дважды. Различайте повтор передачи одной операции и новое подтверждённое намерение. Состав помогает проверить версию, но не заменяет идентификатор операции.
Уникальная запись в базе решает проблему полностью?
Она может защитить основной результат при правильной транзакционной реализации. Внешние письма, резервы и передачи требуют собственных правил повторения и восстановления. Проверять нужно весь согласованный маршрут.
404gen — креативное digital-агентство полного цикла, работаем удалённо по всей России.
Телефон: +7 993 729-59-59 (пн–пт, 10:00–19:00 МСК). WhatsApp: +7 913 141-20-96. Telegram: @aporkulevich. Почта: ap@404gen.ru.