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

Остановка дублирующихся побочных эффектов

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

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

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 subscriptions), облачным регионом и по-прежнему требует, чтобы клиенты отслеживали прогресс обработки, пока подтверждение не будет успешно выполнено.

Мое субъективное резюме простое. Считайте, что транспорт будет повторять попытки. Считайте, что операторы будут воспроизводить запросы. Считайте, что вебхуки придут с задержкой. Проектируйте путь записи так, чтобы повторное намерение не могло создать второй бизнес-эффект.

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

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

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

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

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

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

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

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

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

Почему 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)
);

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

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, и то же правило применяется в сервисах, использующих 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 доставки в качестве ключа дедупликации
  • сохраняйте квитанцию до побочных эффектов
  • делайте обработчики осведомленными о порядке, а не предполагающими порядок прибытия
  • ставьте тяжелую работу в очередь и быстро возвращайте ответ

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Подписаться

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