Идемпотентность в распределенных системах, которая действительно работает

Избегайте дублирования побочных эффектов

Содержимое страницы

Идемпотентность в распределённых системах — это свойство, которое спасает вас, когда сеть врёт, очередь повторяет отправку, клиент паникует, а оператор нажимает «повтор». В продакшен-системах дублированная доставка — это норма. Дублирование побочных эффектов — это ошибка.

HTTP определяет идемпотентный метод как тот, при котором несколько идентичных запросов оказывают на сервер такое же намеренное воздействие, как один запрос. Именно поэтому методы PUT, DELETE и безопасные методы являются идемпотентными в семантике протокола и могут быть автоматически повторены после сбоя связи.

integration message flow: idempotency

Это определение полезно, но его недостаточно. В реальных архитектурах идемпотентность — это не просто ответ на вопрос из учебника по HTTP. Это бизнес-гарантия. Если клиент нажимает «оплатить» один раз, вы не имеете права списать деньги дважды из-за тайм-аута между коммитом и ответом. Если воркер обновляет инвентарь и падает до подтверждения (ack) сообщения, вы не имеете права уменьшить остатки дважды из-за того, что брокер повторно доставил то же сообщение. Вот это и есть планка.

Ошибка, которую я вижу снова и снова, — это восприятие идемпотентности как функции транспорта, а не свойства системы. Дедупликация очередей, HTTP-методы и повторные попытки клиента помогают, но ни одна из них не спасёт архитектуру, которая позволяет одному бизнес-намерению создать второй побочный эффект. Если вам нужна более широкая картина того, как эти решения по интеграции вписываются в границы сервисов и компромиссы хранения данных, начните с Архитектура приложений в продакшене: шаблоны интеграции, дизайн кода и доступ к данным.

Откуда берутся дубли в продакшене

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

Клиент может отправить запрос на создание, сервер может его закоммитить, а ответ всё равно может исчезнуть в канале связи. Именно поэтому HTTP различает идемпотентные методы и почему платёжные API, такие как Stripe и PayPal, предоставляют явные механизмы идемпотентности для небезопасных методов, таких как POST.

Брокеры сообщений делают проблему ещё очевиднее. Гарантированная доставка (at-least-once) означает, что потребитель может быть вызван несколько раз для одного и того же сообщения, а обработчик может успешно обновить базу данных, но упасть до подтверждения, заставив брокер снова доставить то же сообщение.

Вебхуки ничем не отличаются. GitHub заявляет, что доставки вебхуков могут приходить вне порядка, неудачные доставки не повторяются автоматически, и каждая доставка содержит уникальный GUID X-GitHub-Delivery, который следует использовать для защиты от повторов (replay). Для практического взгляда на чат-эндпоинты как на границы взаимодействия см. Чат-платформы как системные интерфейсы в современных системах.

Даже системы, рекламирующие более сильные гарантии, всё равно требуют от вас работы. Kafka может предотвратить дублирование записей в логах Kafka с помощью идемпотентных продюсеров и обеспечить ровно одну доставку (exactly-once delivery) для потоков «чтение-обработка-запись», которые остаются внутри Kafka с использованием транзакций и потребителей read_committed. Но в собственных документации Kafka чётко сказано, что внешним системам всё равно требуется координация с офсетами и выводами. Гарантированная ровно одна доставка в Google Cloud Pub/Sub ограничена pull-подписками, работает только в пределах одного облачного региона и всё равно требует от клиентов отслеживания прогресса обработки до успешного подтверждения.

Мой субъективный вывод прост. Предполагайте, что транспорт будет повторять отправку. Предполагайте, что операторы будут воспроизводить события. Предполагайте, что вебхуки будут приходить с задержкой. Проектируйте путь записи так, чтобы повторное намерение не могло создать второй бизнес-эффект. Дизайн обработки ошибок тесно связан с этим: то, как ошибки оборачиваются, транслируются и классифицируются как повторяемые или неповторяемые, является частью той же дисциплины границ — Архитектура обработки ошибок в Go: границы и шаблоны охватывает классификацию повторяемых ошибок, транслирование на границах и сентинель-шаблоны, которые позволяют логике повторов принимать обоснованные решения. Когда повторные попытки продолжают налетать на нештатную зависимость, предохранитель на границе интеграции срабатывает быстро, предотвращая шторм повторов, который усиливает дублирование работы.

Контракт API, которому я действительно доверяю

Как ключи идемпотентности предотвращают дублирование запросов к API

Единственный контракт API, которому я доверяю для изменяющих операций, — это намерение, предоставляемое вызывающей стороной, плюс сохранение на стороне сервера.

AWS рекомендует использовать идентификатор запроса, предоставленный вызывающей стороной, и предупреждает, что сервис должен атомарно записать токен идемпотентности вместе с изменяющей работой. Stripe сохраняет первый код статуса и тело ответа для ключа, сравнивает последующие параметры с оригинальным запросом и возвращает тот же результат для повторов. PayPal использует заголовок PayPal-Request-Id для поддерживаемых POST-API и возвращает последний статус предыдущего запроса с этим же заголовком.

Это приводит к практическому контракту:

  1. Клиент генерирует ключ идемпотентности для бизнес-операции.
  2. Сервер ограничивает этот ключ арендатором (tenant) и именем операции.
  3. Сервер сохраняет хеш запроса, чтобы один и тот же ключ не мог быть использован повторно для другого полезного нагрузки.
  4. Сервер записывает состояние, такое как pending, completed или failed.
  5. Повторы с тем же ключом либо возвращают сохранённый результат, либо стабильную ссылку на него.
  6. Повторы с тем же ключом и другой полезной нагрузкой вызывают резкий отказ.

Существует черновик IETF Idempotency-Key, но по состоянию на 2026-05-09 он всё ещё указан в IETF Datatracker как истёкший Internet-Draft, а не как опубликованный RFC. На практике имя заголовка всё ещё широко полезно как конвенция по умолчанию, но вы должны задокументировать контракт в своём собственном API, а не притворяться, что стандарт завершён.

Что должен представлять собой ключ? Намерение. Не попытку HTTP. Не TCP-соединение. Не счётчик повторов. Если пользователь хочет «создать заказ 123 один раз», каждый повтор для этой же команды должен использовать тот же ключ. Если пользователь хочет «разместить второй заказ», он должен использовать другой ключ.

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

Почему PUT недостаточно

Нет, HTTP PUT недостаточно, чтобы сделать операцию идемпотентной.

Да, RFC 9110 предоставляет семантику идемпотентности для PUT. Но если ваш обработчик PUT генерирует новое событие во внешней системе, отправляет электронное письмо при каждой повторной попытке или снова списывает средства у внешнего провайдера, то ваша реализация нарушает бизнес-контракт, даже если имя маршрута выглядит уважительно.

Выбор метода помогает клиентам понять намерение. Он не реализует намерение за вас.

Используйте PUT, когда модель ресурса действительно подходит для полной замены или операции upsert. Используйте POST, когда вы создаёте команды или действия. Но для любых изменений, которые могут быть повторены через сетевые границы, документируйте явный контракт идемпотентности. Если ваши изменяющие действия запускаются из чат-рабочих процессов, тот же контракт применяется в Шаблон интеграции Slack для оповещений и рабочих процессов и Шаблон интеграции Discord для оповещений и контуров управления. Скрытые побочные эффекты — это то место, где архитектура умирает.

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

Дольше, чем хочет ваша транспортная команда.

Stripe говорит, что ключи можно удалять через 24 часа. PayPal говорит, что срок хранения зависит от API и приводит примеры, которые могут длиться до 45 дней. Amazon SQS FIFO дедуплицирует только в течение 5-минутного окна. GitHub хранит недавние доставки в течение 3 дней для ручного повторного воспроизведения. Эти цифры настолько различаются, потому что правильный период хранения — это бизнес-решение, а не протокольный параметр по умолчанию.

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

Храните записи идемпотентности как минимум в течение максимума из этих окон:

  • горизонт повторов клиента
  • горизонт повторной доставки очереди
  • горизонт воспроизведения вебхуков
  • горизонт воспроизведения оператором
  • горизонт расчётов или компенсаций для операций с денежными средствами

Для платежей, бронирований и предоставления услуг это часто означает часы или дни, а не минуты.

AWS также выделяет два антипаттерна, с которыми я полностью согласен. Не используйте временные метки в качестве ключа, потому что рассинхронизация часов и коллизии делают их ненадёжными. Не храните слепо все тело запроса в качестве записи дедупликации для каждого запроса, потому что это вредит производительности и масштабируемости. Сохраняйте нормализованный хеш запроса плюс минимальное состояние ответа, необходимое для безопасного воспроизведения. Если вы должны воспроизвести первый ответ побайтово, храните каноническое тело ответа так, как это делает Stripe.

Паттерны базы данных, которые делают идемпотентность реальной

Идемпотентность становится реальной, когда слой сохранения может выиграть гонку ровно один раз.

PostgreSQL предоставляет здесь два критических примитива. Уникальные ограничения обеспечивают уникальность одного или более столбцов, а INSERT ... ON CONFLICT позволяет определить альтернативное действие вместо отказа при нарушении уникальности. PostgreSQL также документирует, что ON CONFLICT DO UPDATE гарантирует атомарный результат вставки или обновления при конкурентности.

Это означает, что ваш слой идемпотентности обычно должен начинаться с такой таблицы:

create table api_idempotency (
    tenant_id text not null,
    operation text not null,
    idempotency_key text not null,
    request_hash text not null,
    state text not null,
    status_code integer,
    response_body jsonb,
    resource_type text,
    resource_id text,
    created_at timestamptz not null default now(),
    expires_at timestamptz not null,
    primary key (tenant_id, operation, idempotency_key)
);

И поток обработки должен выглядеть так:

начать транзакцию

попытаться вставить (tenant_id, operation, idempotency_key, request_hash, state='pending')
при конфликте ничего не делать

загрузить строку для (tenant_id, operation, idempotency_key) для обновления

если row.request_hash != incoming_request_hash
    отказать с ошибкой конфликта или валидации

если row.state = 'completed'
    вернуть сохранённый ответ

если row.state = 'pending' и строка создана другим активным запросом
    либо подождать кратко, либо быстро отказать с повторяемым ответом

выполнить локальную бизнес-мутацию

сохранить стабильный результат в строке идемпотентности
установить state = 'completed'

закоммитить
вернуть результат

Важна не синтаксис. Важна атомарность. Запись ключа и выполнение мутации должны завершаться успехом или неудачей вместе. AWS говорит об этом явно для API-идемпотентности, и то же правило применяется в сервисах на базе SQL.

Не делайте наивную последовательность «проверка-затем-действие», такую как «выбрать ключ; если отсутствует, то вставить заказ». При конкурентности два запроса могут пройти проверку и оба создать побочный эффект. Уникальное ограничение не является опциональным. Это механизм, который превращает вашу архитектуру из оптимистичного фольклора во что-то, что можно доказать под нагрузкой.

Правило, которое я использую при ревью. Если решение о дедупликации не защищено той же транзакционной границей, что и мутация, у вас нет идемпотентности. У вас есть надежда.

Сообщения, события и вебхуки требуют собственных границ

Как потребители обрабатывают дубли событий и сообщений

Для потребителей сообщений классический паттерн всё ещё является правильным. Записывайте идентификаторы обработанных сообщений в той же транзакции базы данных, что и бизнес-обновление. Крис Ричардсон описывает подход с таблицей PROCESSED_MESSAGES напрямую, используя первичный ключ по подписчику и идентификатору сообщения, так что дубли отклоняются чисто и могут быть проигнорированы.

Многие команды называют это явное хранилище processed_messages таблицей входящих сообщений (inbox). Метка менее важна, чем правило. Получатель должен сохранить доказательство того, что он уже обработал сообщение, прежде чем повторная попытка сможет безопасно ничего не делать.

Минимальная форма выглядит так:

create table processed_messages (
    subscriber_id text not null,
    message_id text not null,
    processed_at timestamptz not null default now(),
    primary key (subscriber_id, message_id)
);

И поток потребителя столь же строг, как и поток HTTP:

начать транзакцию

вставить в processed_messages (subscriber_id, message_id)
значения (?, ?)
при конфликте ничего не делать

если строка не вставлена
    откатить
    подтвердить (ack) и проигнорировать дубликат

применить бизнес-мутацию

закоммитить
подтвердить сообщение

Этот паттерн скучный. Хорошо. Идемпотентность должна быть скучной.

Это также обычно лучше, чем пытаться полагаться на маркетинговые термины брокеров.Exactly-once поддержка Kafka отличная, когда вы остаётесь внутри собственной транзакционной модели Kafka, но документация Kafka всё равно предупреждает, что внешние пункты назначения требуют сотрудничества. SQS FIFO уменьшает дублирование отправлений только в пределах своего 5-минутного окна дедупликации. Exactly-once в Pub/Sub всё ещё ожидает, что подписчик будет отслеживать прогресс и избегать дублирования работы при сбое подтверждений.

Exactly-once — это обычно локальная оптимизация. Идемпотентные побочные эффекты — это гарантия системы.

Сопаривайте дедупликацию с паттерном исходящих сообщений (outbox)

Если ваш сервис обновляет локальное состояние и также публикует событие, одной идемпотентной обработки недостаточно. Вам также нужен безопасный способ получить событие после коммита локальной транзакции.

Именно поэтому паттерн транзакционного исходящего сообщения имеет значение. Крис Ричардсон описывает основную идею как запись события в таблицу исходящих сообщений (outbox) в той же транзакции, что и бизнес-обновление, а затем его асинхронную публикацию. Debezium говорит, что паттерн outbox предотвращает несогласованность между внутренним состоянием сервиса и событиями, потребляемыми другими сервисами. NServiceBus идёт дальше и показывает, как обработка outbox дедуплицирует входящие сообщения и избегает зомби-записей и призрачных сообщений.

Это архитектура, которую я рекомендую для сервисов, которые владеют данными и публикуют интеграционные события:

  1. Проверьте и сохраните команду под ключом идемпотентности.
  2. Запишите бизнес-состояние и событие outbox одной локальной транзакцией.
  3. Позвольте CDC или диспетчеру outbox опубликовать событие.
  4. Сделайте downstream-потребителей тоже идемпотентными.

Outbox не убирает необходимость в идемпотентных потребителях. Он убирает необходимость притворяться, что коммит базы данных и публикация брокера могут быть одним магическим распределённым транзакциями, когда обычно они не могут.

Вебхуки — это просто сообщения с лучшим брендингом

Относитесь ко входящим вебхукам точно так же, как к сообщениям от ненадёжного сетевого края.

GitHub документирует, что доставки могут приходить вне порядка, рекомендует использовать X-Hub-Signature-256 для проверки подлинности и предоставляет X-GitHub-Delivery в качестве уникального идентификатора доставки. Он также отмечает, что повторные доставки используют тот же идентификатор доставки.

Таким образом, архитектура проста:

  • сначала проверьте подпись
  • используйте GUID доставки как ключ дедупликации
  • сохраните подтверждение получения перед побочными эффектами
  • сделайте обработчики aware относительно порядка, а не предполагайте порядок прибытия
  • поставьте тяжёлую работу в очередь и верните ответ быстро

Если ваш обработчик вебхуков пишет напрямую в бизнес-таблицы до того, как запишет подтверждение получения, он не готов к продакшену. Он просто быстрее допускает ошибки дублирования.

Саги и движки рабочих процессов всё равно нуждаются в идемпотентности

Саги и долговечные движки рабочих процессов не удаляют проблему. Они делают её видимой.

Temporal рекомендует писать Активности (Activities) так, чтобы они были идемпотентными, потому что Активности могут быть повторены после сбоев или тайм-аутов. В его документации даже выделяется крайний случай, когда воркер успешно завершает внешний побочный эффект, но падает до сообщения о завершении, что вызывает повторное выполнение Активности. Temporal также предлагает использовать комбинацию Workflow Run ID и Activity ID в качестве стабильного ключа идемпотентности при вызове downstream-сервисов. Если вы применяете это в оркестрации сервисов, Микросервисы Go для оркестрации ИИ/ML охватывает более широкие компромиссы рабочих процессов.

Это именно правильная ментальная модель. Движок рабочего процесса может сохранять историю выполнения и координировать повторы. Он не может задним числом отменить списание с карты или отменить отправку электронного письма, если ваше приложение не предоставляет ему идемпотентные шаги и идемпотентные компенсации.

То же самое относится к сагам. Собственное руководство Temporal по сагам описывает компенсирующие действия, которые запускаются при сбое шага. Эти компенсации тоже должны быть идемпотентными. Если «возврат платежа» запускается дважды, вы можете решить оригинальную ошибку, создав новую.

Моё правило здесь жестоко и просто. Каждая Активность, каждый обработчик команд и каждая компенсация, которая взаимодействует с внешним миром, должна либо быть естественно идемпотентной, либо иметь реальный ключ идемпотентности для downstream-системы.

Как тестировать идемпотентность до выхода в продакшен

Большинство команд тестируют сценарии успеха, а затем удивляются, когда случаются повторы. Этого недостаточно. Для команд Go Тестирование конкурентного кода Go с testing/synctest охватывает, как писать быстрые, детерминированные тесты для циклов повторов и поведения с тайм-аутами контекста без сна через искусственные задержки.

У вас должны быть автоматизированные тесты как минимум для следующих случаев:

  • сервер коммитит мутацию, но ответ никогда не достигает клиента
  • два идентичных запроса соревнуются с тем же ключом идемпотентности
  • тот же ключ используется повторно с другой полезной нагрузкой
  • потребитель коммитит свою работу в базе данных и падает до подтверждения (ack)
  • вебхук воспроизводится с тем же идентификатором доставки
  • диспетчер outbox публикует одно и то же событие более одного раза
  • Активность рабочего процесса завершает внешний вызов и падает до сообщения о завершении
  • запись идемпотентности истекает, и приходит настоящий поздний повтор

AWS явно рекомендует комплексные наборы тестов, которые включают успешные запросы, неудачные запросы и дублированные запросы. Этот совет банален и абсолютно верен.

Я бы добавил ещё один сценарий отказа. Проверьте, что воспроизведённый ответ семантически эквивалентен первому результату. AWS обсуждает опоздавшие повторы и аргументирует необходимость ответов, которые сохраняют первоначальный смысл, даже после изменения базового состояния. Это разница между «дополнительный побочный эффект не произошёл» и «вызывающая сторона всё ещё имеет согласованный контракт».

Субъективные правила, которые спасают реальные системы

Вот правила, которые я бы внедрил при архитектурном ревью.

Во-первых, ключи идемпотентности относятся к бизнес-намерению, а не к попыткам транспорта.

Во-вторых, ограничивайте каждый ключ арендатором и операцией. Глобальные пространства ключей — это то, как несвязанные запросы сталкиваются.

В-третьих, сохраняйте решение о дедупликации атомарно с мутацией. Если это не так, дизайн неверен.

В-четвёртых, отклоняйте повторы с тем же ключом и другой полезной нагрузкой. Stripe и AWS делают это по уважительной причине.

В-пятых, храните ключи на полный горизонт воспроизведения бизнес-процесса, а не для самого короткого окна очереди.

В-шестых, сопоставляйте продюсеров с outbox, а потребителей — с отслеживанием идентификаторов сообщений. Одна сторона без другой — это половина дизайна.

В-седьмых, передавайте одно и то же действие операции downstream, когда бизнес-действие одинаково. AWS явно рекомендует передавать токен идемпотентности по всей цепочке обработки.

В-восьмых, никогда не assume, что маркетинговое «exactly-once» убирает необходимость в идемпотентных побочных эффектах.

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

Эти же правила напрямую применяются к фоновым ИИ-агентам. Агенты с опросом (polling agents), которыеclaim задачи, генерируют уведомления или запускают вызовы инструментов, нуждаются в ключах дедупликации и идемпотентных протоколах claim так же, как и платёжные API. О том, как работает паттерн claim-and-dedupe внутри ИИ-ассистентов в продакшене, см. Агенты с опросом в ИИ-ассистентах: 11 шаблонов реализации.

Полезные ссылки

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.