коротко

В сети запросы теряются и повторяются — это норма, а не сбой. В обычном чтении повтор безвреден, в платеже повтор означает второе списание у живого человека. Защита — идемпотентный ключ (Idempotency-Key): уникальный идентификатор операции, который генерирует клиент и прикладывает к запросу. Сервер запоминает ключ вместе с результатом: первый запрос выполняет и сохраняет, на повтор с тем же ключом возвращает тот же результат, не списывая повторно. Под капотом — уникальный индекс в базе, который ловит дубль даже при гонке. Exactly-once — иллюзия: честно бывает только «доставка хотя бы раз» (at-least-once) плюс идемпотентность поверх неё. Самый коварный случай — таймаут: «платёж завис — прошёл или нет?»; правильный ответ даёт повтор с тем же ключом, а не новый запрос.

Самый дорогой инцидент в моей практике выглядел так: пользователь оформил заказ на 1500 рублей, увидел крутилку, не дождался и нажал «оплатить» ещё раз. Списалось 3000. Технически всё работало «правильно»: два запроса — два платежа. Виноват был не код, а модель — мы спроектировали платёж так, будто запрос приходит ровно один раз. Я тогда выучил правило, которое теперь пишу в каждую платёжную спеку первой строкой: запрос придёт дважды, вопрос только когда. И базу идемпотентности — что это и зачем — я разбирал отдельно в записи про синхронную и асинхронную интеграцию; здесь не повторяю основы, а копаю вглубь, применительно к деньгам.

Почему в платежах повтор смертелен

В записи про интеграции я объяснял идемпотентность на лифте: нажми кнопку пять раз — лифт приедет один раз. Для чтения данных это очевидно безобидно: GET /orders/123 хоть сто раз — ничего не изменится. Проблема начинается там, где запрос меняет состояние и стоит денег.

Платёж — худший возможный кандидат на повтор по трём причинам сразу. Во-первых, эффект необратим в глазах пользователя: списанные деньги нужно возвращать отдельной операцией, с задержкой, и человек уже зол. Во-вторых, операция идёт через внешний сервис (банк, эквайринг), который сам по себе медленный — секунды, иногда десятки секунд, — а значит таймауты и неопределённость тут не редкость, а ежедневная реальность. В-третьих, на той стороне реальные деньги, и регулятор не примет объяснение «у нас сеть моргнула».

Где именно рождается повтор

Их три источника, и аналитик должен держать в голове все. Пользователь: нажал кнопку дважды, обновил страницу, вернулся назад. Клиент-приложение: не дождалось ответа по таймауту и повторило автоматически (ретрай). Сеть и инфраструктура: балансировщик или прокси переотправил запрос. Защита нужна от всех трёх — и она одна на всех. Та же машинерия защищает и приём вебхуков, где провайдер тоже повторяет доставку.

Идемпотентный ключ: кто, что, как долго

Механика в двух словах разобрана в записи про интеграции: клиент кладёт уникальный ключ в заголовок, сервер по нему распознаёт повтор. Теперь — детали, которые на собесе и в спеке отличают джуна от мидла.

Кто генерирует. Ключ генерирует клиент, а не сервер. Это принципиально: сервер не может отличить «новый платёж» от «повтор того же» по содержимому тела — два платежа на 1500 рублей за один заказ выглядят одинаково. Уникальность операции знает только тот, кто её инициировал. Поэтому клиент создаёт ключ один раз на одно намерение заплатить и переиспользует его при всех ретраях этого намерения.

Формат. Любая строка, гарантированно уникальная: обычно UUID v4 или случайные 32+ символа. Важно не как он выглядит, а чтобы клиент не сгенерировал новый ключ на повтор — иначе вся защита рассыпается.

Где живёт. В заголовке Idempotency-Key, а не в теле. Так его видно в логах и на промежуточных узлах, и он не смешивается с бизнес-данными.

TTL — сколько сервер хранит ключ. Не вечно. Типично от 24 часов до нескольких суток. Логика: ретраи происходят в пределах минут-часов, держать ключ годами бессмысленно и дорого. Но слишком короткий TTL опасен — если ключ протух, а клиент повторил, защиты уже нет. Stripe, например, хранит ключи 24 часа. Это число — требование, которое аналитик обязан проставить явно.

Реальный пример: первый запрос и повтор

Первый запрос. Клиент создаёт платёж, прикладывает свежий ключ:

POST /payments
Idempotency-Key: 7f3e9a91-c20a-4f42-b1d3-0042aabbccdd
Content-Type: application/json

{ "orderId": 123, "amount": 1500, "currency": "RUB" }

Сервер видит ключ впервые, проводит платёж, сохраняет результат под этим ключом и отвечает:

HTTP/1.1 201 Created

{ "paymentId": "pay_9981", "status": "succeeded", "amount": 1500 }

Теперь сеть моргнула, клиент не получил ответ и повторяет — с тем же ключом и тем же телом:

POST /payments
Idempotency-Key: 7f3e9a91-c20a-4f42-b1d3-0042aabbccdd
Content-Type: application/json

{ "orderId": 123, "amount": 1500, "currency": "RUB" }

Сервер узнаёт ключ, не проводит платёж заново и возвращает тот самый сохранённый результат:

HTTP/1.1 200 OK
Idempotent-Replayed: true

{ "paymentId": "pay_9981", "status": "succeeded", "amount": 1500 }

На повтор возвращают результат, а не ошибку

Частая ошибка проектирования — на повтор отдать 409 Conflict «такой ключ уже был». Это вынуждает клиента обрабатывать особый случай и гадать, прошёл платёж или нет. Правильно — вернуть тот же успешный ответ, как будто запрос был первым. Для клиента повтор должен быть неотличим от исходного успеха: он спросил «прошло?» — получил «да, вот результат». Код может быть 200 вместо 201, опционально с пометкой о повторе — но тело то же.

А если повтор с тем же ключом, но другим телом?

Клиент прислал ключ 7f3e..., но в теле уже amount: 5000. Это не повтор — это ошибка на стороне клиента (переиспользовал ключ для другой операции). Сервер должен это поймать и вернуть 422 «ключ уже использован с другими параметрами», а не списать втихую. Сверка тела (или его хеша) с сохранённым — часть честной реализации идемпотентности.

Дедупликация на уровне базы: уникальный индекс

Хранить ключ в памяти приложения недостаточно — приложений обычно несколько, и два повтора могут прийти на разные инстансы одновременно. Тогда оба проверят «ключа ещё нет», оба решат провести платёж — классическая гонка. Спасает база: на колонку с идемпотентным ключом ставят уникальный индекс.

CREATE UNIQUE INDEX idx_payments_idem_key
  ON payments (idempotency_key);

Теперь дублирование физически невозможно: первый запрос вставит строку, второй при вставке той же строки получит от базы ошибку нарушения уникальности — и обработчик поймёт, что это повтор, и вернёт уже существующий результат. База становится финальным арбитром, потому что она одна на все инстансы. Это та страховка, которая держит даже тогда, когда два запроса столкнулись в одну миллисекунду.

Exactly-once — иллюзия. Таймауты и неопределённость

Хочется верить, что бывает «доставка ровно один раз» (exactly-once). На практике в распределённой системе её нет — есть только «хотя бы один раз» (at-least-once, про неё я писал в записи про интеграции) плюс идемпотентность поверх. Сеть в любой момент может не донести ответ, и отправитель не знает, что случилось.

Вот тот самый коварный случай: клиент отправил платёж и не дождался ответа по таймауту. Вопрос на миллион: платёж прошёл или нет? Возможны оба варианта — запрос мог не дойти до сервера, а мог дойти, выполниться, и потерялся уже ответ. Угадывать нельзя. И вот тут идемпотентный ключ из приятной мелочи превращается в единственный безопасный выход: клиент повторяет запрос с тем же ключом. Если первый прошёл — сервер вернёт его результат и второго списания не будет. Если не прошёл — сервер проведёт платёж сейчас. В обоих исходах деньги спишутся ровно раз. Без ключа повтор после таймаута — это русская рулетка.

sequenceDiagram
  participant C as Клиент
  participant S as Сервер платежей
  participant B as Банк
  C->>S: POST /payments (Idempotency-Key: K7f3e)
  S->>B: провести списание 1500
  B-->>S: успех, pay_9981
  S-->>C: 201 Created (ответ потерян по таймауту) 
  Note over C,S: C не знает: прошло или нет?
  C->>S: повтор POST /payments (тот же ключ K7f3e)
  S->>S: ключ K7f3e уже есть → не списываю заново
  S-->>C: 200 OK { pay_9981, succeeded }

Схема показывает самый опасный сценарий и почему ключ его закрывает. Клиент отправляет платёж с ключом K7f3e, сервер успешно проводит списание через банк, но ответ 201 теряется по дороге — клиент в неведении. Он повторяет тот же запрос с тем же ключом. Сервер видит, что K7f3e уже обработан, и вместо повторного похода в банк возвращает сохранённый результат. Деньги списались один раз, клиент получил подтверждение, неопределённость снята — и всё это без единого лишнего рубля.

Откуда это взялось

Идея идемпотентного запроса стара как HTTP — методы GET, PUT, DELETE по спецификации идемпотентны изначально. Но именно для небезопасных операций вроде POST-платежа практику популяризировал Stripe около 2015 года, введя заголовок Idempotency-Key как часть публичного API и подробно описав его в документации. После этого ключ с тем же названием появился у большинства платёжных провайдеров и закрепился как де-факто стандарт. В 2021–2022 идею даже понесли в черновик IETF (Idempotency-Key как стандартный HTTP-заголовок) — так практика одной компании стала отраслевой нормой.

Как это спрашивают на собесе

Это топовый вопрос на платёжных и интеграционных позициях. Формулировки: «Как сделать так, чтобы повторный запрос не списал деньги дважды?», «Что вернуть на повтор с тем же ключом?», «Кто генерирует идемпотентный ключ и почему не сервер?», «Платёж завис по таймауту — ваши действия?», «Бывает ли exactly-once?». Сильный ответ держит четыре опоры: ключ генерирует клиент один раз на намерение; на повтор сервер возвращает тот же результат, а не ошибку; на уровне БД стоит уникальный индекс против гонок; exactly-once — это at-least-once + идемпотентность, отдельной гарантии не существует. Если добавите про TTL ключа и про сценарий таймаута — собеседующий поставит галочку «мидл».

Частые вопросы

Кто должен генерировать идемпотентный ключ — клиент или сервер?

Клиент. Только инициатор операции знает, что «вот этот платёж» и «его повтор» — одно и то же намерение; по телу запроса сервер их не различит, ведь два платежа на одну сумму за один заказ выглядят одинаково. Клиент генерирует ключ один раз на одно намерение заплатить и переиспользует его при всех ретраях. Если на повтор клиент сгенерирует новый ключ — защита не сработает и спишется дважды.

Что сервер должен вернуть на повторный запрос с тем же ключом?

Тот же результат, что и на первый, — как будто запрос пришёл впервые. Не ошибку и не 409 Conflict. Для клиента повтор должен быть неотличим от исходного успеха: он фактически спрашивает «прошло ли?», и ответ — «да, вот результат». Код может быть 200 вместо 201, опционально с пометкой о повторе, но тело идентично. Исключение — повтор того же ключа с другим телом: это ошибка клиента, на неё отвечают 422.

Существует ли доставка ровно один раз (exactly-once)?

На уровне сети — нет, это иллюзия. Честно достижимо только «хотя бы один раз» (at-least-once): сообщение/запрос точно не потеряется, но может прийти повторно. Эффект «ровно один раз» получают, складывая at-least-once с идемпотентностью на стороне получателя: повтор приходит, но обработчик распознаёт его по ключу и не выполняет действие дважды. Поэтому в платежах нельзя полагаться на «доставится один раз» — нужно проектировать так, чтобы повтор был безопасен.