Идиомпотентность в распределённых системах, которая действительно работает
Предотвращение дублирования побочных эффектов
Идемпотентность в распределённых системах — это свойство, которое спасает вас, когда сеть выдаёт неверные данные, очередь выполняет повторную отправку, клиент паникует, а оператор запускает повторное воспроизведение. В производственных системах дублирование доставки — это норма. Дублирование побочных эффектов — это баг.
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 и возвращает последний статус предыдущего запроса с той же заголовком.
Это приводит к практическому контракту:
- Клиент генерирует ключ идемпотентности для бизнес-операции.
- Сервер ограничивает область действия этого ключа арендатором и именем операции.
- Сервер хранит хэш запроса, чтобы тот же ключ нельзя было использовать для другой полезной нагрузки.
- Сервер фиксирует состояние, такое как
pending(ожидание),completed(завершено) илиfailed(ошибка). - Повторные попытки с тем же ключом либо возвращают сохранённый результат, либо стабильный указатель на него.
- Повторные попытки с тем же ключом и другой полезной нагрузкой завершаются с ошибкой.
Существует черновик заголовка IETF Idempotency-Key, но по состоянию на 09.05.2026 он всё ещё числится в реестре IETF как истёкший черновик документа, а не как опубликованный 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)
);
И поток обработки должен выглядеть так:
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 table). Метка имеет меньшее значение, чем правило. Получатель должен сохранить доказательство того, что он уже обработал сообщение, прежде чем повторная попытка сможет безопасно ничего не сделать.
Минимальная форма выглядит так:
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)
Если ваш сервис обновляет локальное состояние и также публикует событие, одной идемпотентной обработки недостаточно. Вам также нужен безопасный способ отправить событие после фиксации локальной транзакции.
Вот почему важен паттерн транзакционной исходящей очереди (transactional outbox). Крис Ричардсон описывает базовую идею как запись события в таблицу исходящих сообщений в той же транзакции, что и бизнес-обновление, а затем асинхронную публикацию. Debezium говорит, что паттерн outbox избегает несоответствий между внутренним состоянием сервиса и событиями, потребляемыми другими сервисами. NServiceBus идёт дальше и показывает, как обработка outbox дедуплицирует входящие сообщения и избегает зомби-записей и призрачных сообщений.
Вот архитектура, которую я рекомендую для сервисов, которые владеют данными и публикуют события интеграции:
- Валидируйте и сохраняйте команду под ключом идемпотентности.
- Записывайте бизнес-состояние и событие outbox в одной локальной транзакции.
- Позвольте CDC или диспетчеру outbox опубликовать событие.
- Сделайте нижестоящих потребителей тоже идемпотентными.
Outbox не устраняет необходимость в идемпотентных потребителях. Он устраняет необходимость притворяться, что фиксация базы данных и публикация брокера могут быть одной магической распределённой транзакцией, когда обычно они не могут.
Вебхуки — это просто сообщения с лучшим брендингом
Относитесь к входящим вебхукам точно так же, как к сообщениям из ненадёжного сетевого края.
GitHub документирует, что доставки могут приходить в неправильном порядке, рекомендует использовать X-Hub-Signature-256 для проверки подлинности и предоставляет X-GitHub-Delivery в качестве уникального идентификатора доставки. Также отмечается, что повторные доставки используют тот же идентификатор доставки.
Таким образом, архитектура проста:
- сначала проверяйте подпись
- используйте GUID доставки как ключ дедупликации
- сохраняйте квитанцию до побочных эффектов
- делайте обработчики осведомлёнными о порядке, а не предполагающими порядок прибытия
- ставьте в очередь тяжёлую работу и быстро возвращайтесь
Если ваш обработчик вебхуков пишет напрямую в бизнес-таблицы, прежде чем зафиксировать получение, он не готов к продакшену. Он просто быстрее совершает дублирующие ошибки.
Саги и движки рабочих процессов всё ещё нуждаются в идемпотентности
Саги и устойчивые движки рабочих процессов не устраняют проблему. Они делают её видимой.
Temporal рекомендует писать Activities (активности) так, чтобы они были идемпотентными, потому что активности могут повторяться после сбоев или тайм-аутов. Его документация даже выделяет граничный случай, когда воркер успешно завершает внешний побочный эффект, но падает до отчёта о завершении, что заставляет Activity запуститься снова. Temporal также предлагает использовать комбинацию Workflow Run ID и Activity ID в качестве стабильного ключа идемпотентности при вызове нижестоящих сервисов. Если вы применяете это в оркестрации сервисов, Go Microservices for AI/ML Orchestration охватывает более широкие компромиссы рабочих процессов.
Это именно правильная ментальная модель. Движок рабочих процессов может сохранять историю выполнения и координировать повторные попытки. Он не может ретроактивно вернуть деньги с карты или отозвать отправленное электронное письмо, если ваше приложение не предоставляет ему идемпотентные шаги и идемпотентные компенсации.
То же самое применимо к сагам. Собственные рекомендации Temporal по сагам описывают компенсирующие действия, которые запускаются при сбое шага. Эти компенсации тоже должны быть идемпотентными. Если «возврат платежа» выполняется дважды, вы можете решить исходную ошибку, создав новую.
Моё правило здесь жестоко и просто. Каждая Activity, каждый обработчик команд и каждая компенсация, которая касается внешнего мира, должны быть либо естественно идемпотентными, либо нести реальный ключ идемпотентности в нижестоящую систему.
Как тестировать идемпотентность перед продакшеном
Большинство команд тестируют счастливые пути, а затем удивляются, когда происходят повторные попытки. Этого недостаточно.
У вас должны быть автоматизированные тесты как минимум для этих случаев:
- сервер фиксирует мутацию, но ответ никогда не достигает клиента
- два идентичных запроса гонятся с одним и тем же ключом идемпотентности
- тот же ключ повторно используется с другой полезной нагрузкой
- потребитель фиксирует работу с базой данных и падает до ack
- вебхук воспроизводится с тем же идентификатором доставки
- диспетчер outbox публикует одно и то же событие более одного раза
- Activity рабочего процесса завершает внешний вызов и падает до отчёта о завершении
- запись об идемпотентности истекает, и приходит реальная повторная попытка с задержкой
AWS явно рекомендует комплексные наборы тестов, включающие успешные запросы, неудачные запросы и дублирующиеся запросы. Этот совет банален и абсолютно верен.
Я бы добавил ещё один учения по сбоям. Убедитесь, что воспроизведённый ответ семантически эквивалентен первому результату. AWS обсуждает повторные попытки с задержкой и выступает за ответы, которые сохраняют исходный смысл, даже если базовое состояние изменилось. Это разница между «ни одного дополнительного побочного эффекта не произошло» и «вызывающая сторона всё ещё имеет согласованный контракт».
Субъективные правила, которые спасают реальные системы
Вот правила, которые я бы强制执行 (принудительно применял) в архитектурном ревью.
Во-первых, ключи идемпотентности принадлежат бизнес-намерению, а не попыткам транспорта.
Во-вторых, ограничивайте область действия каждого ключа арендатором и операцией. Глобальные пространства ключей — это то, как сталкиваются несвязанные запросы.
В-третьих, сохраняйте решение о дедупликации атомарно вместе с мутацией. Если это не так, дизайн неверен.
В-четвёртых, отклоняйте повторные попытки с тем же ключом, но разной полезной нагрузкой. Stripe и AWS делают это по веской причине.
В-пятых, храните ключи в течение полного горизонта воспроизведения бизнес-процесса, а не в течение самого короткого окна очереди.
В-шестых, сочетайте производителей с outbox, а потребителей — с отслеживанием идентификаторов сообщений. Одна сторона без другой — это половина дизайна.
В-седьмых, передавайте ту же идентичность операции вниз по потоку, когда бизнес-действие одно и то же. AWS явно рекомендует передавать токен идемпотентности вдоль цепочки обработки.
В-восьмых, никогда не предполагайте, что маркетинг «ровно один раз» устраняет необходимость в идемпотентных побочных эффектах.
Если это звучит строго, то хорошо. Идемпотентность — это место, где оптимистичная архитектура встречается с реальностью продакшена. Вам не нужна сложность повсюду. Но везде, где дублирование побочных эффектов повредит деньгам, состоянию или доверию, идемпотентность должна быть первоклассной частью контракта.