Нажали «оплатить». Крутится колёсико. Связь моргнула, приложение молчит. И палец сам тянется нажать ещё раз.

Вопрос на сотку: спишут один раз или два?

Зависит от одного занудного слова. Идемпотентность. На деньгах без неё лучше не лезть. Слово пугает, по факту фигня: повторил операцию — и ничего не поменялось. Дёрнул перевод один раз, дёрнул пять раз подряд тем же запросом — деньги уйдут ровно один раз.

Почему вообще ломается

Сеть ненадёжна. Вы отправили запрос, сервер его принял, списал деньги, отправил «ок» — а «ок» потерялся по дороге. С вашей стороны ответа просто нет. Прошло или нет — непонятно. Вы жмёте снова, а сервер видит второй запрос как новенький. И списывает ещё раз.

Так вот, чинят это ключом. Клиент к каждой операции цепляет уникальный код — обычно UUID, длинную случайную строку. Кладёт его в HTTP-заголовок Idempotency-Key. Сервер по нему понимает, видел он эту операцию или нет. Впервые — выполняет и кладёт рядом с ключом результат: тело ответа и статус. Тот же ключ снова — ничего не делает, отдаёт сохранённое.

И вот тут все думают — всё, готово. Ага, щас.

Гонка двух запросов

Представьте: два запроса с одним ключом прилетели одновременно. Двойной тап. Или клиент сам ретрайнул по таймауту, пока первый ещё в полёте. Оба лезут в базу, оба видят «ключа нет», оба идут списывать. И вот оно, двойное списание (то самое, из-за которого потом не спишь). А теперь умножьте на чёрную пятницу и тысячи человек. К этому вернёмся.

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

Нюансы, на которых горят ТЗ

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

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

Ключ живёт не вечно. У Stripe, например, сутки. Срок жизни надо проставить в спеке, иначе таблица ключей пухнет, а клиент через месяц ретрайнет «старую» операцию и получит сюрприз.

И почему ключ нужен именно на POST. Смотрите: повторный GET просто ещё раз покажет баланс, повторный DELETE снесёт уже снесённое — этим ретрай не страшен, они идемпотентны сами по себе. А POST каждый раз создаёт новое. Вот ему и нужна страховка.

И да, это не только про оплату. Очереди вроде Kafka и RabbitMQ доставляют сообщение «хотя бы один раз» — значит, одно и то же прилетит дважды штатно, без всякой моргнувшей сети. Вебхуки провайдер тоже шлёт с повторами. Везде, где есть ретрай, нужен ключ и дедуп — отсев повторов.

Так вот про чёрную пятницу. Нагрузка, один пропущенный уникальный индекс — и за ночь система дважды списывает с тысяч человек. Прикиньте: по 3000₽ с восьми тысяч — это под 24 миллиона, которые утром надо возвращать. А утром с этим прибегут к вам. Инцидент, разбор, начальство.

Чеклист: проверить контракт за минуту

  • есть Idempotency-Key на всех POST, что двигают деньги или необратимы?
  • вставка ключа атомарна (защита от гонки)?
  • что сохраняем по ключу и отдаём на повтор?
  • что отвечаем на тот же ключ с другим телом?
  • сколько ключ живёт?

Душнила-режим, да. Но этот чеклист экономит недели разгребания.

Хоть на один пункт ответа в спеке нет — там дыра. А через дыры утекают деньги.

Гляньте свой контракт. Есть там POST на платёж без ключа? Вот с него и начинайте.