Сервис принимает запрос на создание платежа, а клиент повторяет его после тайм-аута. Как спроектировать API, чтобы повтор не создал второй платёж?
Нужно сделать операцию идемпотентной: клиент передаёт уникальный ключ операции, а сервер атомарно связывает этот ключ с результатом создания платежа. Повторный запрос с тем же ключом должен вернуть тот же результат или текущее состояние операции, но не запускать создание повторно.
Ключ должен иметь область действия, достаточную уникальность и срок хранения, согласованный с максимальным периодом повторов. Сервер также обязан отклонять повторное использование ключа с другим набором значимых параметров.
В распределённых системах клиент не всегда может отличить обработанный запрос от необработанного: соединение может оборваться после фиксации результата, но до получения ответа. Поэтому сетевые тайм-ауты и повторные попытки неизбежно создают риск повторного выполнения операции.
Идемпотентность API появилась как практический способ отделить повтор доставки запроса от повторного бизнес-действия. Она особенно важна для операций, где дубль имеет материальные последствия: платежей, заказов, резервирования или выдачи документов.
Пусть сервер получил запрос, создал платёж и записал его в базу данных, но ответ не дошёл до клиента. Клиент повторяет запрос; без специального механизма сервер может создать второй платёж, поскольку технически это новый HTTP-запрос.
Простая проверка ключа перед созданием недостаточна: два одинаковых запроса могут одновременно проверить отсутствие ключа и оба начать операцию. Ошибка также возникает, если запись ключа и создание платежа выполняются в разных хранилищах без надёжной координации.
Клиент генерирует идемпотентный ключ на одну логическую попытку создания платежа и передаёт его во всех повторах. Сервер хранит соответствие «ключ операции — параметры запроса — результат или состояние обработки».
Обработка должна включать атомарное резервирование ключа. Первый запрос создаёт запись с состоянием обработки; конкурентный запрос с тем же ключом либо ждёт завершения, либо получает понятный ответ о незавершённой операции. После успешного создания платежа сервер сохраняет его идентификатор и результат, чтобы последующие повторы возвращали тот же платёж.
Если ключ уже существует, сервер сравнивает значимые параметры с первоначальным запросом. Совпадение означает повтор и допускает возврат сохранённого результата; несовпадение должно приводить к ошибке конфликта, иначе один ключ сможет неоднозначно обозначать разные платежи.
Нужно определить поведение для промежуточных состояний: например, вернуть статус обработки и предложить клиенту проверить результат позже. Нельзя бесконечно хранить ключи без оценки стоимости: слишком короткий срок хранения снова допускает дубли после позднего повтора, а слишком длинный увеличивает объём данных и усложняет очистку.
Идемпотентность защищает от повторного выполнения только тех эффектов, которые покрыты общей моделью операции. Если создание платежа вызывает внешний шлюз, одной транзакции локальной базы недостаточно: потребуется поддержка идемпотентного ключа шлюзом, надёжный журнал исходящих действий или механизм согласованного восстановления.
Компромисс заключается между простотой и надёжностью. Уникальное ограничение в базе хорошо защищает от конкурентных вставок, но само по себе не решает повтор внешнего вызова; распределённая блокировка может снизить конкуренцию, но не устраняет последствия сбоя между внешним действием и фиксацией результата.
Интернет-магазин отправлял запрос на оплату во внешний платёжный шлюз. После успешного ответа шлюза сервис иногда аварийно завершался до сохранения результата в своей базе, поэтому повтор от клиента мог инициировать второй платёж.
Рассматривались три варианта. Увеличение тайм-аута уменьшало число повторов, но не устраняло сбои между системами. Локальная блокировка по идентификатору заказа защищала от одновременных запросов одного экземпляра сервиса, но не от повторов после перезапуска или при нескольких экземплярах. Передача одного и того же ключа операции во внутренний сервис и во внешний шлюз, дополненная сохранением состояния и периодической сверкой незавершённых операций, была сложнее, но покрывала оба класса сбоев.
Выбрали третий вариант: ключ связывался с заказом, запросом к шлюзу и локальной записью операции; повтор возвращал сохраненный результат или состояние проверки. Это исключило повторное списание при сетевых повторах, а незавершённые операции обрабатывались отдельным процессом сверки.
1. Достаточно ли сделать уникальным только идентификатор заказа?
Нет. Один заказ может легитимно иметь несколько попыток оплаты, например после отклонения карты или выбора другого способа. Ключ должен обозначать именно одну логическую операцию, а бизнес-правила отдельно должны запрещать недопустимые повторные платежи по уже оплаченному заказу.
2. Что должен вернуть сервер, если первый запрос ещё выполняется?
Нельзя создавать новую операцию. Сервер может дождаться результата в пределах разумного тайм-аута и вернуть его либо сообщить, что операция ещё обрабатывается, предоставив клиенту безопасный способ проверки состояния. Ответ должен позволять отличить временное состояние от окончательного отказа.
3. Почему нельзя просто хранить ключ только в кэше?
Кэш может удалить запись по сроку жизни, вытеснить её из-за нехватки места или потерять данные при сбое. Если после этого клиент повторит запрос, защита исчезнет. Кэш допустим как ускоряющий слой, но окончательная гарантия должна опираться на устойчивое хранилище с атомарным ограничением или эквивалентным механизмом согласованности.