Клиент повторяет запрос оплаты после таймаута. Объясните, как следующий контракт предотвращает двойное списание:
POST /payments
Idempotency-Key: 8f2a
{"order_id":"A-17","amount":1000}
Идемпотентный ключ связывает несколько повторных запросов с одной логической операцией. Сервер атомарно сохраняет результат обработки по ключу и при повторе возвращает тот же результат, не выполняя списание повторно.
Это устраняет неопределённость после таймаута: клиент не знает, успел ли сервер завершить операцию, но может безопасно повторить запрос. Защита работает только при корректной серверной реализации хранения ключа и согласовании его времени жизни с бизнес-операцией.
В распределённых системах клиент может не получить ответ из-за разрыва соединения, таймаута или сбоя промежуточного узла, хотя сервер уже выполнил операцию. Поэтому повторная доставка запроса часто неизбежна, особенно при взаимодействии через ненадёжную сеть.
Для чтения повтор обычно безопасен, но повтор платежа, создания заказа или списания бонусов может привести к побочному эффекту дважды. Идемпотентность появилась как прикладной механизм устранения такого риска при повторной доставке команд.
После таймаута клиенту известен только факт отсутствия ответа. Возможны оба сценария: сервер ещё не начал обработку или уже списал деньги, но ответ потерялся.
Если каждый повтор запускает обычную бизнес-операцию заново, итог зависит от числа попыток. Простое сравнение полей запроса не решает проблему: два одинаковых платежа могут быть независимыми операциями, а один и тот же платёж может быть повторен с тем же содержимым.
Клиент генерирует уникальный ключ для одной логической операции и использует его во всех повторах. Сервер хранит соответствие примерно такого вида: ключ → отпечаток запроса, статус, результат.
Обработка должна быть атомарной:
Критична уникальность записи по ключу на уровне общего хранилища, а не только проверка в памяти одного экземпляра. Иначе два параллельных запроса попадут на разные реплики и оба пройдут проверку до фиксации результата.
Для одного ключа нужно запрещать изменение значимых параметров. Повтор с тем же ключом, но другой суммой должен завершаться ошибкой конфликта, иначе ключ перестаёт однозначно обозначать операцию.
Ключ обычно имеет ограниченный срок хранения, но его нельзя удалять раньше периода, в течение которого клиент или очередь могут повторить команду. Слишком короткий срок снова допускает двойное выполнение, а слишком долгий увеличивает объём хранилища и усложняет обслуживание.
Идемпотентность самого API не гарантирует атомарность внешнего платежа. Если запись о ключе и списание находятся в разных системах, нужен отдельный протокол согласования: например, платёжный провайдер тоже должен поддерживать идемпотентный ключ, либо операция выполняется через надёжный журнал и компенсирующие действия.
Сервис заказов отправлял платёжному провайдеру команду, но иногда получал таймаут. Повтор без ключа снижал число неподтверждённых заказов, однако создавал риск двух списаний; запрет повторов устранял дубли, но оставлял заказы в неопределённом состоянии.
Вариант с локальной блокировкой был простым, но не защищал от перезапуска процесса и не работал между несколькими экземплярами. Вариант с коротким TTL уменьшал хранилище, но позволял повторному запросу после истечения TTL повторить списание.
Выбрали общий уникальный реестр операций и передавали тот же ключ платёжному провайдеру. Для завершённых операций сохраняли ответ, для незавершённых запускали сверку статуса, а запросы с тем же ключом и отличающимися параметрами отклоняли.
Так повторы стали безопасными, а неопределённость после сетевого сбоя превратилась в проверяемое состояние операции. Цена решения — дополнительное хранилище, обработка зависших записей и необходимость согласовать семантику ключа с внешним провайдером.
1. Достаточно ли хранить только идентификатор ключа?
Нет. Нужно также проверять отпечаток значимых параметров запроса. Иначе клиент сможет повторно использовать существующий ключ для другой суммы или другого заказа и получить либо неверный старый ответ, либо неоднозначное поведение. При несовпадении параметров сервер должен явно вернуть ошибку конфликта.
2. Что возвращать, если первый запрос ещё выполняется?
Нельзя запускать вторую операцию параллельно. Сервер может вернуть состояние «операция выполняется», дождаться результата в пределах разумного таймаута или предложить клиенту проверить статус позже. Конкретный выбор зависит от API, но повторная команда не должна обходить блокировку ключа.
3. Почему идемпотентный ключ не решает проблему сам по себе при сбое между списанием и сохранением результата?
Если платёж уже выполнен, а сервер упал до сохранения результата, после перезапуска он может не найти ключ и повторить списание. Поэтому операция должна иметь идемпотентность на стороне платёжной системы либо надёжный журнал состояния, позволяющий сначала выяснить исход предыдущей попытки. Локальная таблица без согласования с внешним побочным эффектом не создаёт абсолютной атомарности.