MAATRIX / Блог / Идемпотентность на пальцах: почему без неё повтор запроса списывает деньги дважды

Идемпотентность на пальцах: почему без неё повтор запроса списывает деньги дважды

MAATRIX

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

Что на самом деле означает идемпотентность

Идемпотентность — это свойство операции, а не свойство сети, очереди или HTTP-клиента. Операция идемпотентна, если её повторное выполнение с теми же входными данными приводит к тому же результату, что и однократное выполнение. Не «ничего не происходит при повторе» — а «состояние системы после N повторов такое же, как после одного вызова».

Классический пример из математики, который и дал термину название: умножение на 0 или 1 идемпотентно (0 × 5 = 0, и 0 × 0 × 5 тоже 0), а прибавление 5 — нет, потому что каждое применение меняет результат. В программировании то же самое, только вместо чисел — состояние базы, баланс счёта, файл на диске, письмо в почтовом ящике клиента.

Разберём на операциях, с которыми работает почти любой backend:

  • UPDATE users SET status = 'active' WHERE id = 42 — идемпотентно. Выполните хоть десять раз, статус останется active.
  • UPDATE accounts SET balance = balance - 100 WHERE id = 42 — не идемпотентно. Каждый повтор списывает ещё 100, потому что новое значение зависит от старого.
  • DELETE FROM sessions WHERE token = 'abc' — идемпотентно по факту (после первого удаления строки нет, повторный DELETE ничего не находит и ничего не меняет), но не идемпотентно по ответу, если код возвращает разные статусы для «удалено» и «не найдено» — об этом нюансе ниже.
  • Отправка письма «ваш заказ оформлен» — не идемпотентна по умолчанию: без защиты клиент получит письмо дважды при повторной отправке того же события.
  • Создание записи через INSERT без уникального ограничения — не идемпотентно: два одинаковых запроса создадут две строки с разными id.

Важно не путать идемпотентность с безопасностью (safety) в терминологии HTTP. Безопасный метод не меняет состояние сервера вообще (GET, HEAD) — это более сильное условие. Идемпотентный метод может менять состояние, но результат этого изменения после N одинаковых вызовов не отличается от результата после одного.

At-least-once: почему повтор — это контракт, а не баг

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

Из-за этого реальные системы выбирают одну из трёх стратегий доставки:

  • At-most-once — сообщение отправляется один раз, без повторов. Если оно потерялось — получатель никогда не узнает. Просто и предсказуемо, но данные теряются.
  • At-least-once — отправитель повторяет отправку, пока не получит подтверждение. Данные не теряются, но получатель может увидеть одно и то же сообщение несколько раз. Самая распространённая стратегия для очередей, HTTP-ретраев, вебхуков.
  • Exactly-once — сообщение обрабатывается ровно один раз. На уровне транспорта это недостижимо в общем случае; то, что маркетируется как exactly-once (например, в Kafka), на практике — at-least-once плюс идемпотентность обработки под капотом самой платформы.

Именно поэтому большинство брокеров сообщений — RabbitMQ, NATS JetStream, Kafka, SQS — честно документируют себя как at-least-once. Мы разбирали механику этого на примере устройства очереди сообщений: консьюмер должен подтвердить обработку сообщения (ack) за отведённое время, и если не успел — по любой причине, от сетевого сбоя до банальной медленной обработки — брокер решает, что сообщение потеряно, и отдаёт его снова. Это не баг брокера, а прямое следствие того, что брокер не может отличить «консьюмер упал» от «консьюмер просто долго работает».

Мы уже разбирали живой пример того, что происходит, когда эту гарантию не учли на стороне обработчика — повторная доставка списала деньги дважды у клиентов: под нагрузкой обработка одного сообщения стала занимать дольше, чем таймаут подтверждения, брокер честно передоставил сообщение, а обработчик списания не имел механизма понять, что это уже второй вызов одной и той же операции. At-least-once сработала штатно. Сломалась не доставка — сломалось предположение, что раз сообщение одно, значит и вызов один.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Идемпотентность — свойство операции, а не протокола

Ключевая мысль, которую легко упустить: идемпотентность нельзя получить бесплатно, выбрав «правильный» протокол или библиотеку. HTTP-спецификация формально помечает GET, PUT, DELETE, HEAD, OPTIONS как идемпотентные методы, а POST и PATCH — как неидемпотентные. Но это описание контракта, а не гарантия, которую реализует сервер сам по себе.

GET    /orders/42        — идемпотентен по спецификации: просто читает
PUT    /orders/42        — идемпотентен ЕСЛИ handler написан так, что
                            повторный PUT с тем же телом даёт то же состояние
DELETE /orders/42        — идемпотентен по факту результата,
                            но не по коду ответа (см. ниже)
POST   /orders           — НЕ идемпотентен по умолчанию:
                            каждый вызов создаёт новый заказ
PATCH  /orders/42        — зависит от того, что именно патчится:
                            {"status": "paid"} — идемпотентно,
                            {"amount": "+100"} — нет

PUT /orders/42 с телом {"status": "shipped"} идемпотентен, потому что результат — «статус заказа 42 равен shipped» — не зависит от того, сколько раз вы отправили этот запрос. Но стоит разработчику написать на сервере PUT, который внутри делает orders.shipped_count += 1, и метод перестаёт быть идемпотентным на практике, хотя формально остаётся PUT. Спецификация протокола не пишет код за вас — идемпотентность нужно закладывать в реализацию каждого обработчика, который меняет состояние.

Отдельно стоит сказать про DELETE. Если первый вызов возвращает 204 No Content, а повторный (не находя строку) — 404 Not Found, то по состоянию системы операция идемпотентна, а по коду ответа выглядит нестабильной. Поэтому многие API намеренно возвращают 204 и на повторный DELETE несуществующего ресурса — чтобы клиент видел идемпотентный контракт снаружи, а не только внутри базы.

Операции с внешними побочными эффектами — списание денег, отправка SMS, вызов стороннего платёжного шлюза, публикация в другую систему — по своей природе не идемпотентны, потому что каждый вызов инициирует реальное действие вовне. Их нельзя сделать идемпотентными, просто выбрав PUT вместо POST. Нужен явный механизм — ключ идемпотентности.

Ключ идемпотентности: что это и как его строить

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

Как выглядит ключ на практике:

POST /api/charges HTTP/1.1
Host: api.example.com
Idempotency-Key: 8f14e45f-ceea-467f-8f21-1e51b7ac3a4a
Content-Type: application/json

{"order_id": 42, "amount": 1500, "currency": "RUB"}

Заголовок Idempotency-Key — не выдуманная конвенция, а устоявшаяся практика: её используют Stripe, PayPal и многие другие платёжные API именно потому, что повтор без защиты дороже всего обходится там, где речь о реальных деньгах. Генерирует ключ клиент (приложение, фронтенд, другой сервис), а не сервер — только клиент знает, что эта попытка и тот повтор после таймаута относятся к одному и тому же действию пользователя.

Практические варианты, откуда брать ключ:

  • UUID, сгенерированный клиентом один раз перед первой попыткой. Самый универсальный вариант: клиент создаёт uuid4() при нажатии кнопки «Оплатить» и переиспользует его для всех ретраев этого конкретного клика, пока не получит финальный ответ.
  • Детерминированный хеш от бизнес-полей операции. Например, sha256(order_id + amount + billing_period) — подходит, когда клиент не может хранить сгенерированный UUID между попытками (cron-джоба перезапускается с нуля), но состав операции однозначно её определяет.
  • Естественный идентификатор источника события. Если операция инициируется сообщением из очереди, в качестве ключа можно использовать message_id этого сообщения — тогда две передоставки одного сообщения несут один и тот же ключ.

Ключ должен жить достаточно долго, чтобы пережить реалистичное окно повторов — от нескольких минут для HTTP-ретраев до суток и больше для асинхронных систем с ручными разборами инцидентов, — но не бесконечно: таблицу с ключами идемпотентности тоже нужно чистить по TTL, иначе она станет самой быстрорастущей таблицей в базе.

Как проверять ключ до выполнения операции

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

Правильный порядок — атомарно зарезервировать ключ до выполнения побочного эффекта, и только затем выполнять саму операцию:

CREATE TABLE idempotency_keys (
    key         TEXT PRIMARY KEY,
    status      TEXT NOT NULL DEFAULT 'processing',
    response    JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
1. Попытаться вставить ключ в таблицу с status='processing'
   (INSERT ... ON CONFLICT DO NOTHING).
2. Если вставка не удалась (ключ уже есть):
     - если status='completed' — вернуть сохранённый response,
       саму операцию НЕ выполнять;
     - если status='processing' — значит другая попытка ещё
       выполняется прямо сейчас, вернуть 409/202 и попросить подождать
       или повторить чуть позже.
3. Если вставка удалась — можно выполнять операцию.
4. После успешного выполнения — обновить строку:
   status='completed', response=<результат>.
5. При ошибке выполнения — удалить строку или пометить status='failed',
   чтобы следующая попытка с тем же ключом могла честно начать заново.

Шаг 1 работает именно потому, что уникальность ключа проверяется на уровне PRIMARY KEY в базе — это атомарная операция, а не два отдельных запроса «проверить, потом вставить», между которыми может проскочить конкурентный запрос. Мы разбирали похожую механику уникальных ограничений в статье про то, как смена collation сломала уникальный индекс: именно уникальный индекс — тот механизм, на который в конечном счёте опирается вся защита от двойного выполнения, поэтому важно быть уверенным, что он действительно ловит дубли в вашей базе и кодировке.

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

Частые ошибки в реализации

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

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

Чем идемпотентность отличается от дедупликации сообщений на уровне брокера?

Дедупликация брокера (например, message deduplication ID в SQS FIFO) не допускает повторную доставку в узком временном окне на стороне транспорта. Идемпотентность — защита на уровне бизнес-логики, которая работает независимо от того, справился брокер с дедупликацией, и покрывает более широкий класс повторов, включая ретраи клиента.

Нужно ли делать идемпотентными вообще все операции записи?

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

Можно ли обойтись без отдельной таблицы ключей?

Иногда да — если у операции уже есть естественное уникальное поле (UNIQUE(order_id, billing_period) для ежемесячного списания), INSERT ... ON CONFLICT DO NOTHING по нему решает ту же задачу. Отдельный ключ нужен, когда такого поля нет или операцию могут законно повторить с разными параметрами.

Идемпотентна ли операция, если она просто ничего не делает при повторном вызове?

Не обязательно то же самое, что нужно. Идемпотентность требует, чтобы клиент получил корректный, ожидаемый результат и при первом, и при повторном вызове — если сервер молча игнорирует повтор, ничего не возвращая, клиент может решить, что операция не выполнилась, и попытается снова другим способом.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →