Idempotency Keys

Как PayPal и Stripe предотвращают дублирование платежей

20 мая 2024

Сеть ненадёжна, но платёж обязан произойти ровно один раз. Между этими двумя фактами стоит инженерный приём, у которого почти 70-летняя математическая родословная и около 15 лет промышленной стандартизации в финтехе. Idempotency key - это не просто "уникальный заголовок, который Stripe положил в API", а конкретное соглашение между клиентом и сервером о том, как ответить на повторный запрос: вернуть ранее посчитанный результат, а не выполнить операцию снова. Статья разбирает математическую основу идемпотентности (от алгебры до Pat Helland), историю того, как платёжные системы конвергировались на header-based подход, реальные масштабы (Stripe, Adyen, AWS Lambda), антипаттерны с именами и интеграцию с at-least-once очередями (Kafka, outbox pattern), которая превращает идемпотентность из приёма в полноценный архитектурный слой.

Проблема: дублирование платежей

Представьте сценарий:

  1. Пользователь нажимает "Оплатить".
  2. Клиент отправляет запрос на создание платежа.
  3. Сервер обрабатывает запрос, списывает деньги.
  4. Сеть теряет ответ (таймаут, разрыв соединения, упавший proxy).
  5. Клиент не получает подтверждения и повторяет запрос.
  6. Сервер обрабатывает запрос снова - двойное списание.
Client                    Server                    Payment Provider
  |                          |                              |
  |--- POST /payments ------>|                              |
  |                          |--- Charge card ------------>|
  |                          |<-- Success -----------------|
  |     (timeout)            |                              |
  |                          |                              |
  |--- POST /payments ------>|  (retry)                     |
  |                          |--- Charge card ------------>|  (duplicate!)
  |                          |<-- Success -----------------|
  |<-- 200 OK ---------------|                              |
Последствия: пользователь заплатил дважды. Возврат средств, поддержка, потеря доверия, в регулируемых юрисдикциях - запрос объяснений от регулятора и репортинг по anti-fraud процедурам.

Принципиально важно: этот сценарий не редкий и не "когда-нибудь случится у больших". Любой клиент, у которого есть retry, и любой сетевой путь, у которого есть промежуточные узлы - а это вся современная сеть - воспроизводит эту картинку при достаточно большом потоке. Вопрос не "защитимся ли мы от дублей", а "когда мы их увидим и кто их будет разгребать". Idempotency key - это контракт, который превращает дубль из операционной проблемы в no-op на уровне платформы.

Математическая основа идемпотентности

Слово "идемпотентность" пришло в инженерию из абстрактной алгебры. В алгебре элемент e называется идемпотентным относительно операции *, если e * e = e. Например, единица в умножении (1 * 1 = 1), пустое множество относительно объединения, проекционная матрица в линейной алгебре. Термин ввёл Benjamin Peirce в работе "Linear Associative Algebra" (1870), и он на протяжении почти века жил исключительно в чистой математике.

В вычислениях понятие переехало в форму "функция f идемпотентна, если f(f(x)) = f(x)". Это сильнее простого "результат не меняется": сама композиция самой с собой совпадает с однократным применением. Установить лампу в положение "включена" - идемпотентно. Прибавить 1 к счётчику - не идемпотентно, потому что f(f(x)) = x + 2, а не x + 1. Удалить файл - идемпотентно (после первого вызова состояние "файла нет" сохраняется), послать email - не идемпотентно (каждый вызов добавляет письмо в очередь получателя).

Эта формальная разница важна, потому что в распределённой системе вы не можете гарантировать, что функция вызвана ровно один раз - сетевые сбои, retry, дубли в очередях, всё это превращает "ровно один раз" в иллюзию. Зато можно спроектировать API так, чтобы повторный вызов с тем же входом был эквивалентен однократному. Тогда retry - не риск, а штатный режим работы. Это перекладывает сложность с гарантий доставки (которые в распределённых системах принципиально слабые) на гарантии обработки (которые проектируются локально).

Pat Helland, бывший архитектор баз данных в Tandem, Microsoft и Amazon, сформулировал эту идею для индустрии в эссе "Idempotence Is Not a Medical Condition" (ACM Queue, 2012). Helland разводит две вещи, которые легко спутать: идемпотентность операции (свойство функции) и идемпотентность сообщения (свойство протокола). Сообщение становится идемпотентным, если у него есть стабильный идентификатор, по которому получатель может определить, "видел ли я это сообщение раньше". Сам по себе обработчик может оставаться неидемпотентным; идемпотентность даёт обёртка, которая дедуплицирует входящий поток. Idempotency key - это и есть стабильный идентификатор сообщения: клиент берёт на себя обязательство присвоить логической операции уникальный ключ, сервер берёт на себя обязательство дедуплицировать по этому ключу.

Gregor Hohpe в "Enterprise Integration Patterns" (Addison-Wesley, 2003) описал тот же механизм за десятилетие до Helland под названием "Idempotent Receiver". В книге это один из паттернов messaging-уровня: получатель сообщения должен уметь корректно обработать дубликат, потому что транспорт (JMS, MSMQ, любой message broker того времени) либо не гарантировал exactly-once, либо гарантировал слишком дорого. Hohpe предлагал три способа реализации: дедупликация по message ID, конструирование операции как идемпотентной по природе (UPSERT вместо INSERT), либо передача всей операции как полного нового состояния (PUT, а не PATCH). Все три подхода вошли в современный финтех почти без изменений - изменилось только название самого ключа.

Полезно держать в голове разделение Helland: "идемпотентная операция" - это про функцию, "идемпотентное сообщение" - это про протокол. Клиент-сервер с idempotency key реализует второе, не первое. Сама операция списания денег внутри Stripe не идемпотентна (charge меняет состояние); идемпотентным её делает обёртка, которая знает, что charge с этим ключом уже посчитан, и отдаёт сохранённый ответ.

Что такое идемпотентность

На уровне HTTP-протокола идемпотентность методов задана RFC 9110 (HTTP Semantics, 2022, наследник RFC 2616). Метод считается идемпотентным, если повторная отправка запроса с тем же содержимым приводит к тому же эффекту на сервере, что и однократная.

HTTP метод Идемпотентен? Пример
GET Да Получение данных не меняет состояние.
PUT Да Замена ресурса - результат одинаков при любом числе вызовов.
DELETE Да Удаление уже удалённого = 404, но без побочных эффектов.
POST Нет Создание ресурса - каждый вызов создаёт новый.

POST не идемпотентен по дизайну: семантика "создай новый ресурс" означает, что десять одинаковых POST создадут десять ресурсов. Но именно платежи, заказы, переводы - наиболее естественные POST по REST-семантике, и одновременно операции, для которых дубль недопустим. Решение - idempotency keys: внести в протокол явное соглашение, по которому повторный POST с тем же ключом эквивалентен первому.

История стандартизации в платёжных системах

Идея "уникального идентификатора запроса" появилась в платёжных протоколах задолго до REST. В стандарте ISO 8583, который с 1987 года описывает формат сообщений для карточных транзакций между банками и платёжными системами, поле 11 (System Trace Audit Number, STAN) и поле 37 (Retrieval Reference Number) выполняли ровно эту функцию: уникальный идентификатор, по которому получатель распознавал retry и не дублировал авторизацию. То есть к моменту, когда веб-разработчики начали формализовать idempotency keys, в acquiring-инфраструктуре эта идея жила уже два десятилетия - просто на уровне ISO-сообщений, а не HTTP-заголовков.

Переход в HTTP-мир шёл медленно и фрагментированно. Ранние REST-API платёжных провайдеров (середина 2000-х) полагались на серверную дедупликацию по бизнес-полям: сочетание merchant_id, order_id и amount служило идентификатором, и сервер отбрасывал повтор. Это работало, пока запросы шли строго через один merchant-канал, но ломалось при параллельных интеграциях и при операциях, где natural key неочевиден (refund по конкретному списанию, частичная отмена). Решения были ad hoc и не переносились между провайдерами.

Stripe, основанный братьями Collison в 2010 году, был среди первых, кто вынес идентификатор операции в явный HTTP-заголовок и сделал его частью публичного API-контракта. В 2014-2015 годах в документации Stripe появился раздел про Idempotency-Key, формализующий соглашение: клиент генерирует UUID, передаёт его в заголовке POST-запроса, Stripe гарантирует, что повторный запрос с тем же ключом в течение 24 часов вернёт сохранённый ответ. Brandur Leach, инженер Stripe и автор постов о дизайне их API (см. brandur.org), подробно описывал, как этот контракт реализован внутри: атомарная вставка ключа в Postgres с unique constraint, recovery point (запись намерения до начала операции, чтобы знать, на какой стадии прервался первый запрос), валидация request hash (чтобы один и тот же ключ с разными параметрами вернул ошибку, а не молча вернул старый ответ).

PayPal в API REST использовал чуть другое имя - PayPal-Request-Id - но семантику почти идентичную: уникальный идентификатор клиентского запроса, по которому сервер дедуплицирует. Adyen, ещё один крупный европейский PSP, реализовал свой механизм через поле reference в теле запроса, но тоже с серверной дедупликацией и публично документированным TTL. AWS пошёл другим путём: для большинства AWS API уникальность обеспечивается через client request token (отдельный параметр у каждого endpoint - например, ClientToken у EC2 RunInstances), и AWS API Reference прямо ссылается на этот механизм как на способ безопасного retry.

Постепенно стало очевидно, что отрасль конвергировалась на одной и той же идее, но реализовывала её под разными именами. В 2021 году в IETF был внесён draft "The Idempotency-Key HTTP Header Field" (см. datatracker.ietf.org), который пытается стандартизовать имя заголовка, формат значения (UUID или произвольная строка) и семантику ответов. Draft пока не RFC, но фактически закрепил де-факто стандарт, на который уже опирается большая часть финтех-индустрии. Любопытная особенность этой стандартизации - она пришла не сверху, не от ISO или IETF в форме новой семантики, а снизу, от практики Stripe и других PSP, которые эмпирически нащупали один и тот же контракт.

Параллельно идея проникла в платформенные слои AWS. Brandur Leach в посте "Implementing Stripe-like Idempotency Keys in Postgres" (brandur.org) описывал внутреннюю реализацию, и схожие подходы повторили в AWS Lambda Powertools (модуль idempotency для Python и TypeScript), DynamoDB (через conditional writes по ключу) и SQS (через MessageDeduplicationId в FIFO-очередях). К 2024 году "выставлять Idempotency-Key" перестало быть особенностью топовых PSP и стало ожидаемой характеристикой любого платёжного API.

Как работают idempotency keys

Концепция

Клиент генерирует уникальный ключ для каждой логической операции (не для каждого retry, не для каждого HTTP-вызова) и передаёт его в заголовке. Сервер:

  1. Проверяет, есть ли уже результат для этого ключа.
  2. Если есть - возвращает сохранённый результат.
  3. Если нет - выполняет операцию и сохраняет результат.

Пример Stripe API

curl https://api.stripe.com/v1/charges \
  -u sk_test_xxx: \
  -H "Idempotency-Key: AGJ6FJMkGQIpHUTX" \
  -d amount=2000 \
  -d currency=usd \
  -d description="Charge for order #1234" \
  -d customer=cus_A8Z5MHwQS7jUmZ

Если запрос с тем же Idempotency-Key повторится - Stripe вернёт результат первого запроса, не создавая новый charge.

Диаграмма с idempotency key

Client                    Server                    Payment Provider
  |                          |                              |
  |--- POST /payments ------>|                              |
  |    Key: abc-123          |                              |
  |                          |--- Check key abc-123         |
  |                          |    (not found)               |
  |                          |--- Charge card ------------>|
  |                          |<-- Success -----------------|
  |                          |--- Save result for abc-123   |
  |     (timeout)            |                              |
  |                          |                              |
  |--- POST /payments ------>|  (retry)                     |
  |    Key: abc-123          |                              |
  |                          |--- Check key abc-123         |
  |                          |    (found!)                  |
  |<-- 200 OK (cached) ------|  (no duplicate charge)       |

Реализация на практике

Серверная сторона

class PaymentService {
  async createPayment(request, idempotencyKey) {
    // 1. Проверяем, есть ли уже результат
    const cached = await this.cache.get(idempotencyKey);
    if (cached) {
      return cached; // Возвращаем сохранённый результат
    }

    // 2. Проверяем, не выполняется ли операция прямо сейчас
    const lock = await this.acquireLock(idempotencyKey);
    if (!lock) {
      throw new ConflictError('Request in progress');
    }

    try {
      // 3. Выполняем операцию
      const result = await this.processPayment(request);

      // 4. Сохраняем результат
      await this.cache.set(idempotencyKey, result, TTL_24_HOURS);

      return result;
    } finally {
      await this.releaseLock(idempotencyKey);
    }
  }
}

Клиентская сторона

class PaymentClient {
  async createPayment(order) {
    // Генерируем ключ один раз для логической операции
    const idempotencyKey = `payment-${order.id}-${order.version}`;

    return this.retryWithBackoff(async () => {
      const response = await fetch('/api/payments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(order),
      });

      if (response.status === 409) {
        // Конфликт - запрос ещё выполняется, подождём
        throw new RetryableError('Request in progress');
      }

      return response.json();
    });
  }
}

Схема базы данных

CREATE TABLE idempotency_keys (
  key VARCHAR(255) PRIMARY KEY,
  request_hash VARCHAR(64) NOT NULL,  -- Для валидации параметров
  response_code INT,
  response_body JSONB,
  created_at TIMESTAMP DEFAULT NOW(),
  locked_at TIMESTAMP,  -- Для конкурентных запросов

  -- TTL: автоматическое удаление старых записей
  expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);

CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);

Brandur Leach в посте про Stripe-like idempotency указывает на тонкость, которую легко упустить: если запрос длинный (списание + запись в две внутренние БД + событие в Kafka), и сервер падает посередине, при retry надо знать, на какой стадии остановились. Stripe для этого вводит концепт recovery point: внутри одной транзакции, обёрнутой ключом, сохраняется текущая стадия (started → charge_created → notification_enqueued → done), и retry начинает с последней успешно зафиксированной стадии. Для большинства приложений это избыточно; для PSP с миллионами транзакций в день - необходимо.

Масштаб на практике

Чтобы понять, зачем платёжные системы платят за дополнительный round-trip к Postgres за каждый запрос, полезно посмотреть на реальные масштабы.

Stripe в годовых отчётах публикует объёмы platform volume: в 2023 году компания обработала более 1 триллиона долларов платежей суммарно. Точную долю запросов, которые приходят с retry, Stripe не раскрывает, но в инженерных постах и выступлениях многократно повторяется тезис: при таком объёме даже доля retry в 0.1% - это сотни миллионов запросов в год, любой из которых без idempotency key мог бы превратиться в дубль. Cost проблемы не только в деньгах: каждый дубль - это manual reconciliation в bookkeeping, обращение в поддержку, потенциальный chargeback. Документация Stripe (docs.stripe.com) явно позиционирует idempotency-key как обязательную часть протокола, а не как optional оптимизацию.

Adyen - европейский PSP, который обслуживает крупных merchants (Uber, Spotify, eBay). В их публичной документации (docs.adyen.com) idempotency описана как стандартный механизм для всех модифицирующих API; рекомендуемое время хранения ключа - до 24 часов, после чего сервер не гарантирует дедупликацию. Adyen обрабатывает сотни миллиардов евро в год через десятки финансовых сетей, и их платформа спроектирована вокруг предположения, что network failures - норма, а не исключение.

AWS закладывает идемпотентность в фундамент собственных API. В AWS API Reference у большинства create-операций (RunInstances, CreateQueue, CreateBucket) есть параметр client request token, и SDK для большинства языков автоматически генерирует UUID для каждого вызова, так что разработчик получает идемпотентность по умолчанию. AWS Lambda Powertools (docs.powertools.aws.dev) идёт дальше: декоратор @idempotent в Python поднимает поверх DynamoDB полноценный idempotency-слой, который ловит дубли event-driven вызовов Lambda, валидирует hash payload и возвращает ранее посчитанный ответ. Это та же идея Stripe, но завёрнутая в платформенный примитив.

Стилизованный пример на основе типичных паттернов: e-commerce компания среднего размера (5-10 миллионов транзакций в год) внедряет idempotency keys в свой checkout. До внедрения отдел поддержки разбирает в среднем 30-50 случаев двойных списаний в месяц - все они приходят от пользователей с нестабильным мобильным интернетом или старыми браузерами, в которых кнопка "Оплатить" срабатывала дважды. После внедрения число случаев падает до единиц в месяц (остаются только сценарии, когда клиент случайно сгенерировал два разных ключа для одной операции - например, из двух вкладок). Возврат инвестиций - сэкономленное время поддержки и сохранённое доверие пользователей. Это типовой паттерн, не задокументированный конкретный кейс.

Best practices

1. Генерация ключей на клиенте

Ключ должен генерироваться клиентом и быть привязан к намерению пользователя, а не к запросу.

// Хорошо: привязка к бизнес-сущности
const key = `order-${orderId}-payment`;

// Плохо: случайный ключ при каждом retry
const key = crypto.randomUUID(); // Не защитит от дублей!

2. Валидация параметров запроса

Сохраняйте хэш параметров вместе с ключом. Если повторный запрос приходит с другими параметрами - это ошибка клиента.

const requestHash = hash(JSON.stringify(request.body));
const stored = await getIdempotencyRecord(key);

if (stored && stored.requestHash !== requestHash) {
  throw new BadRequestError(
    'Idempotency key reused with different parameters'
  );
}

3. TTL для ключей

Не храните ключи вечно. 24 часа - разумный TTL для большинства случаев, и именно столько использует Stripe. Однако TTL - это компромисс: слишком короткий и retry клиента после downtime превращается в дубль; слишком длинный и таблица ключей пухнет, конкурируя за storage и cache.

Граничный случай: retry после истечения TTL. Если клиент повторяет запрос спустя 25 часов, ключ уже удалён. Сервер обработает запрос как новый - и создаст дубль. Для критических операций (платежи) рекомендуется дополнительная защита: уникальный constraint на бизнес-идентификатор (например, order_id + payment_intent), который предотвратит дубликат даже после истечения TTL. Это второй слой обороны - tradeoff между строгостью и гибкостью бизнес-схемы.

4. Обработка конкурентных запросов

Два запроса с одинаковым ключом могут прийти одновременно - типичный сценарий, когда клиент быстро retry после короткого таймаута, а первый запрос всё ещё обрабатывается. Используйте distributed lock:

// Redis lock с TTL
const lockKey = `lock:${idempotencyKey}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 30);

if (!acquired) {
  // Другой запрос уже выполняется
  return res.status(409).json({ error: 'Request in progress' });
}

5. Retry стратегия

Используйте exponential backoff с jitter:

async function retryWithBackoff(fn, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!isRetryable(err) || i === maxRetries - 1) throw err;

      const delay = Math.min(1000 * Math.pow(2, i), 10000);
      const jitter = Math.random() * 1000;
      await sleep(delay + jitter);
    }
  }
}

6. Какие операции защищать

Операция Нужен idempotency key?
Создание платежа Да
Возврат средств Да
Отправка email Да
Создание заказа Да
Обновление профиля Опционально (PUT идемпотентен)
Получение данных Нет (GET идемпотентен)

Антипаттерны и их последствия

Idempotency key - это обманчиво простой механизм: концепция влезает в один абзац, и кажется, что после внедрения проблема дублей закрыта. На практике большинство команд проходят через серию одних и тех же ошибок, каждая из которых имеет имя и характерные симптомы. Ниже - наиболее частые failure modes, с которыми сталкиваются команды при первой production-эксплуатации.

1. Nullable key (отсутствующий ключ). Сервер принимает запросы и с ключом, и без него: с ключом - дедуплицирует, без ключа - обрабатывает как новый. Клиент, забывший передать заголовок, обходит всю защиту. Вариант той же проблемы: SDK старой версии не передаёт ключ, и через эту дыру летят дубли. Лечение - на сервере отбрасывать запросы без ключа (400 Bad Request) для всех мутирующих эндпоинтов, а не "опционально" принимать их.

2. Reused key across endpoints (один ключ на разные операции). Клиент генерирует один UUID и использует его подряд для POST /payments, POST /refunds, POST /notifications. Сервер либо не проверяет, к какому endpoint привязан ключ, и возвращает кэшированный ответ от предыдущей операции (типичная картина: refund возвращает ответ от charge), либо привязка сделана только к ключу, но не к URL, и retry на refund отдаёт сохранённый payment. Лечение - либо генерировать новый ключ на каждую логическую операцию, либо хранить (key, endpoint) как составной идентификатор.

3. TTL too short (replay после истечения). Сервер хранит ключ 1 час, клиент уходит в офлайн на полтора и возвращается с retry. Ключ уже удалён, сервер обрабатывает запрос как новый, создаётся дубль. Этот сценарий особенно злой в B2B-интеграциях с batch-обработкой по ночам: клиентский cron упал в 3 утра, перезапустился в 5, ключи ушли вместе с ночной записью. Лечение - TTL не короче типичного клиентского retry-окна (часы, не минуты) плюс дополнительный business-level constraint (см. п. 3 best practices).

4. Row-level race condition (параллельные запросы без unique constraint). Два запроса с одним ключом приходят одновременно. Сервер делает SELECT по ключу, видит "нет", оба процесса делают INSERT и обрабатывают платёж дважды. Без unique constraint на колонке key в таблице idempotency_keys этот race выигрывает время от времени. Лечение - PRIMARY KEY (key) или UNIQUE INDEX в схеме (см. SQL выше) и обработка conflict-ошибки на уровне приложения как индикатора "другой запрос уже взял ключ, мне нужно подождать или вернуть его результат".

5. Ignored response replay (тот же ключ, новые параметры). Клиент по ошибке отправляет повторный запрос с тем же ключом, но другим amount (опечатка в форме, race в UI). Сервер видит ключ и молча возвращает старый ответ - пользователь думает, что заплатил 500, но реально списано 250. Лечение - валидация request hash (см. п. 2 best practices), которая возвращает 422 Unprocessable Entity на конфликт.

6. Clock skew (расхождение часов клиент/сервер). Если TTL вычисляется по client-side timestamp, а у клиента часы убежали на час вперёд, сервер посчитает ключ "устаревшим" и обработает retry как новый запрос. Реже встречается, но в распределённых клиентах (mobile в самолёте, IoT-устройства без NTP) - реальный кейс. Лечение - TTL всегда вычисляется на сервере, а timestamp ключа берётся из server time.

7. No idempotency for cleanup/delete. Команда защитила POST-эндпоинты, но решила, что DELETE "и так идемпотентен по HTTP-семантике". Это правда для самой операции, но не для её побочных эффектов: DELETE может триггерить отправку email "ваш заказ отменён", запись в audit log, refund. Каждый retry удаления - дополнительный email и дополнительный refund. Лечение - применять idempotency key и к DELETE, особенно если у него есть наблюдаемые побочные эффекты.

8. UUID collision и слабые идентификаторы. Теоретический риск: два клиента независимо сгенерировали один и тот же UUID v4, и сервер ошибочно вернул один ответ другому. На практике для UUID v4 (122 random bits) вероятность такой коллизии пренебрежимо мала. Реальный риск возникает, когда ключ генерируется не через UUID, а через md5(timestamp + user_id) или похожую слабую конструкцию: коллизии на уровне миллионов запросов в день становятся видимыми. Лечение - использовать UUID v4 или ULID, не самописные хэш-функции.

Полезное упражнение для команды: возьмите свой production-эндпоинт с idempotency key и пройдитесь по этому списку. Если на любом пункте ответ "у нас этого нет, потому что не задумались" - это техдолг с понятной стоимостью первого инцидента дублирования.

Интеграция в event-driven системы

Если HTTP API - один сценарий, где нужна идемпотентность, то event-driven архитектуры - другой, более жёсткий. Очереди сообщений (Kafka, RabbitMQ, SQS, Pub/Sub) почти всегда дают at-least-once гарантию доставки: сообщение точно дойдёт хотя бы один раз, но может прийти и дважды (broker не получил подтверждение consumer-а, retry, rebalance). Это значит, что любой обработчик событий должен быть либо идемпотентен по природе, либо защищён дедупликацией - иначе любая нормальная операционная ситуация (рестарт consumer pod-а в Kubernetes, например) превращается в дубли.

Producer-side idempotence в Kafka. С версии Kafka 0.11 (2017, KIP-98 "Exactly Once Delivery and Transactional Messaging") в protocol появилась поддержка идемпотентного producer-а. Если producer запущен с enable.idempotence=true, broker присваивает ему producer ID, нумерует сообщения sequence number-ами и автоматически дедуплицирует ретраи на уровне партиции. То есть retry от producer-а к broker-у уже не превращается в дубль в логе - ровно одна запись на одно отправленное сообщение, даже при сетевых сбоях. Документация Confluent (confluent.io) подробно разбирает, как это работает технически.

Consumer-side dedup. Producer idempotence закрывает только половину проблемы: дубли могут прийти и из-за consumer rebalance, и из-за того, что consumer прочитал сообщение, обработал его, но не успел закоммитить offset до падения. Поэтому даже с enable.idempotence=true у producer-а consumer должен сам дедуплицировать. Стандартный приём - таблица обработанных событий с unique constraint на event_id:

async function handlePaymentEvent(event) {
  // Используем event ID как idempotency key
  const processed = await db.query(
    'SELECT 1 FROM processed_events WHERE event_id = $1',
    [event.id]
  );
  if (processed.rows.length > 0) return; // Уже обработано

  await db.transaction(async (tx) => {
    await processPayment(tx, event.data);
    await tx.query(
      'INSERT INTO processed_events (event_id) VALUES ($1)',
      [event.id]
    );
  });
}

Принципиально, что обработка платежа и запись в processed_events идут в одной транзакции. Иначе возможна ситуация: платёж обработан, запись об обработке не зафиксирована, consumer падает, при восстановлении событие приходит снова - и обрабатывается повторно. Транзакционная граница - не тонкая оптимизация, а базовое условие корректности.

Outbox pattern. Если сервис должен и записать данные в свою БД, и опубликовать событие в Kafka, прямолинейная реализация ("записал, потом отправил") уязвима: если падение случилось между двумя действиями, состояния расходятся. Outbox-паттерн решает это так: в той же транзакции, где сервис меняет бизнес-данные, он записывает событие в специальную таблицу outbox. Отдельный процесс (либо CDC через Debezium, либо polling) читает outbox и публикует в Kafka. На стороне consumer-а событие имеет стабильный ID, и дедупликация по описанному выше шаблону гарантирует, что повторная публикация (которая в outbox + at-least-once Kafka неизбежна) не приведёт к повторной обработке. Outbox + producer idempotence + consumer dedup - это полный стек гарантий, который превращает at-least-once транспорт в effectively-once бизнес-логику.

Истинная exactly-once семантика в распределённых системах принципиально дорога и редко достижима в чистом виде: для transactional producer/consumer Kafka требует, чтобы и read-from, и write-to, и commit offset происходили внутри одной Kafka-транзакции, что ограничивает архитектуру и серьёзно ухудшает throughput. На практике проще и надёжнее - at-least-once доставка плюс идемпотентная обработка. Это та же логика, что и в HTTP-мире: гарантии транспорта намеренно слабые, гарантии обработки достраиваются на стороне приложения через стабильный идентификатор сообщения.

Резюме

Реализация в популярных PSP:
  • Stripe: заголовок Idempotency-Key, TTL 24 часа.
  • PayPal: заголовок PayPal-Request-Id.
  • Adyen: поле reference в теле запроса.
  • AWS: client request token в каждом мутирующем API (например, ClientToken у EC2 RunInstances).

Источники

Концепция и теория

Документация платёжных систем

Платформенные слои и event-driven