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

Предотвращайте повторные побочные эффекты

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

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

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

интеграционный поток сообщений: идемпотентность

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Используйте 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)
);

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

begin transaction

try insert (tenant_id, operation, idempotency_key, request_hash, state='pending')
on conflict do nothing

load row for (tenant_id, operation, idempotency_key) for update

if row.request_hash != incoming_request_hash
    fail with conflict or validation error

if row.state = 'completed'
    return stored response

if row.state = 'pending' and row was created by another live request
    either wait briefly, or fail fast with a retryable response

perform local business mutation

store stable result in idempotency row
set state = 'completed'

commit
return result

Важная часть — не синтаксис. Важная часть — атомарность. Запись ключа и выполнение мутации должны либо преуспеть вместе, либо потерпеть неудачу вместе. AWS говорит об этом явно для идемпотентности API, и то же правило применяется в сервисах, backed 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:

begin transaction

insert into processed_messages (subscriber_id, message_id)
values (?, ?)
on conflict do nothing

if no row inserted
    rollback
    ack and ignore duplicate

apply business mutation

commit
ack message

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

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

«Ровно один раз» обычно является локальной оптимизацией. Идемпотентные побочные эффекты — это системная гарантия.

Сочетайте дедупликацию с паттерном Outbox

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

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

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

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

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

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

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

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

Так что архитектура прямолинейна:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Те же правила напрямую применяются к фоновым AI-агентам. Агенты опроса, которые захватывают задачи, генерируют уведомления или запускают вызовы инструментов, нуждаются в ключах дедупликации и идемпотентных протоколах захвата так же, как и платежные API. Чтобы узнать, как паттерн захвата и дедупликации работает внутри продакшн AI-ассистентов, см. Агенты опроса в AI-ассистентах: 11 паттернов реализации.

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

Подписаться

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