Перейти к основному содержимому

Идемпотентность в распределённых системах

Идемпотентность — свойство операции, которое гарантирует, что повторное выполнение одной и той же операции приведёт к такому же результату, как и первое выполнение. Т.е. если отправить один и тот же запрос 10 раз, результат должен быть таким же, как если отправить его один раз.

Пример: Если повторное списание денег с карты не приводит к новому списанию, а возвращает результат первого списания — операция идемпотентна. Если же деньги спишутся дважды — это ошибка, нарушающая идемпотентность.

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

Примеры проблем обеспечения идемпотентности

  • Повторные сетевые запросы: клиент не получил ответ вовремя (из-за таймаута, сетевого сбоя) и отправил запрос повторно. Сервер же успешно обработал первый запрос
  • Дублирование сообщений в брокерах: брокеры (Kafka, RabbitMQ) гарантируют доставку как минимум одни раз (at-least-once). В случае сбоя потребителя сообщение может быть доставлено повторно
  • Неопределённость состояния при сбоях: cервис обработал запрос, но упал до того, как отправил подтверждение. Оркестратор (например, Kubernetes) перезапустит контейнер, и обработка начнётся заново

Возможные последствия отсутствия идемпотентности:

  • фин потери (двойные списания)
  • нарушение консистентности данных (два одинаковых пользователя в БД)
  • времязатратные отладки и исправления данных

Подходы к реализации идемпотентности

Уникальный идентификатор запроса (Request ID)

Клиент генерирует уникальный ID (UUID) для каждой бизнес-операции и передаёт его в каждом запросе (чаще всего в HTTP-заголовке, например, Idempotency-Key). Сервер, получив запрос, проверяет, не обрабатывался ли уже запрос с таким ID. Если нет — выполняет операцию и сохраняет результат (или просто факт выполнения) в быстрое хранилище (Redis, Memcached) с ключом = ID Если да — возвращает сохранённый ранее ответ, не выполняя операцию повторно

  • Плюсы: относительная простота, универсальность (подходит для REST, gRPC, Webhooks).
  • Минусы: требует наличия общего быстрого хранилища состояния для всех инстансов сервиса. Необходимо определять TTL для ключей

Сценарии: подходит для идемпотентных POST-запросов в API (например, создание платежа, заказа)

Журналирование и Outbox-паттерн

Паттерн для надежной отправки сообщений в брокер в контексте транзакции с БД. Сервис не отправляет сообщение в брокер напрямую. Вместо этого он в рамках одной транзакции с основным бизнес-действием записывает сообщение в специальную таблицу в БД (outbox). Отдельный процесс (CDC, Change Data Capture) считывает новые записи из outbox и публикует их в брокер. После успешной публикации запись из outbox удаляется.

  • Плюсы: гарантирует, что сообщение будет отправлено в брокер тогда и только тогда, когда бизнес-транзакция commit'ится. Решает проблему дублей на стороне отправителя.
  • Минусы: архитектурная сложность, необходимость настройки и поддержки CDC-процесса.

Сценарии: Микросервисные асинхронные интеграции, где критична гарантия доставки события после записи в БД

Exactly-Once на уровне брокеров (Kafka)

Брокеры предлагают встроенные механизмы для обеспечения семантики "точно один раз" Для продюсеров: Использование transactional.id и подтверждений от всех партиций (acks=all) гарантирует, что сообщение не будет потеряно и не будет записано дублем Для консьюмеров: Чтение сообщения и commit offset'а происходят атомарно. Консьюмер не получит одно и то же сообщение дважды после успешной обработки и коммита

  • Плюсы: высокая надёжность "из коробки".
  • Минусы: сложность конфигурации, накладные расходы на производительность, привязка к конкретной технологии.

Когда использовать: в проектах на Kafka, где требования к надёжности обработки потоков данных крайне высоки

Оптимистичная блокировка (CAS, Compare-and-swap) в хранилищах

Каждая версия данных (запись, документ) снабжается меткой версии (version number, timestamp, ETag). Клиент, читая данные, получает их текущую версию. При изменении он отправляет данные обратно вместе с этой версией. Система проверяет, не изменилась ли версия с момента чтения. Если не изменилась — операция выполняется, версия инкрементируется Если изменилась — операция отклоняется (клиент должен перечитать данные и повторить попытку)

  • Плюсы: не требует пессимистичных блокировок, хорошо масштабируется в распределённых БД (Cassandra, DynamoDB).
  • Минусы: клиент должен быть готов обрабатывать коллизии и делать ретраи. Плохо подходит для сценариев с высокой конкуренцией

Сценарии: высоконагруженные системы, где обновления одних и тех же данных происходят нечасто, но нужно гарантировать их консистентность

Сравнение подходов

ПодходСложностьПроизводительностьГарантииИдеальные сценарии
Request IDНизкаяВысокая (с быстрым кэшем)СильныеСинхронные API (платежи, заказы)
Outbox PatternВысокаяСредняяОчень сильныеАсинхронные интеграции между сервисами
Exactly-Once у брокеровСредняяСредняя/НизкаяОчень сильныеПотоковая обработка данных в экосистеме Kafka
Оптимистичная блокировкаНизкаяОчень высокаяЗависит от реализацииЧастые обновления в распределённых БД

Критерий выбора: какие последствия у дублирования операции?

  • Критично (финансы, заказы): можно использовать Request ID или Outbox + EOS
  • Некритично (логирование, аналитика): можно обойтись семантикой at-least-once
  • Частые обновления данных: оптимистичная блокировка

Уровни идемпотентности: Request-Level vs. Entity-Level

Request-Level Idempotency (идемпотентность на уровне запроса)

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

Пример: Клиент отправляет POST /payments с Idempotency-Key: abc123. Сервер создаёт платёж и сохраняет ключ. Второй точно такой же запрос с abc123 не создаст новый платёж, а вернет результат первого.

Что гарантирует: Не будет двух одинаковых платежей из-за дубля запроса

Entity-Level Idempotency (идемпотентность на уровне сущности)

Ключ генерируется на основе модели сущности (например, "payment-1234-refund"). Более строгий подход, который гарантирует, что бизнес-операция над конкретной сущностью будет выполнена только один раз, даже если запросы были разными.

Пример: Система пытается списать деньги со счёта ACC-123 в рамках разных запросов (например, из-за сбоя и ретрая с разными Idempotency-Key). Механизм на уровне сущности «Счёт» не даст списать средства дважды, проверив, что операция списание X для платежа Y уже была выполнена.

Что гарантирует: Не будет двух логически одинаковых списаний, даже если запросы технически разные

  • Request-Level защищает от технических дубликатов.
  • Entity-Level — от бизнес-дубликатов. Надежная система требует обоих уровней для критичных операций.

Важные особенности

Обязанности клиента в системе с идемпотентностью

Клиент - активный участник обеспечения идемпотентности и должен:

  • Генерировать и надежно хранить уникальные idempotency-key для каждой операции
  • Использовать один и тот же ключ для повторных попыток той же операции
  • Реализовывать стратегии повторных попыток (retry) с экспоненциальной backoff-задержкой и добавлением случайности (jitter), чтобы избежать "толпы"
  • Корректно обрабатывать успешные ответы, чтобы не повторять уже завершенные операции

Отказ от реплик БД для чтения состояния идемпотентности

  • Для проверки состояния обработки запроса по его ключу не стоит использовать реплики БД из-за потенциальной задержки репликации
  • Чтение должно осуществляться только из master-ноды БД для гарантии сильной согласованности (strong consistency). В противном случае есть риск, что второй запрос не увидит результат первого, еще не примененного на реплике, и выполнит операцию повторно

Жёсткое требование к неизменности полезной нагрузке

Полезная нагрузка (payload) запроса не должна изменяться между повторными попытками. Если клиент изменит payload при ретрае, это нарушит принцип идемпотентности и приведет к непредсказуемым результатам. Клиент обязан отправлять абсолютно идентичные данные

Payload (полезная нагрузка) — это фактические данные, которые передаются внутри запроса или сообщения, ради которых он и отправляется. Всё, что не является служебной информацией.

Аренда (Lease) и блокировки для ключей идемпотентности

Механизм временной монопольной блокировки ключа, предотвращает конкурентную обработку одинаковых запросов.

Используется для:

  • предотвращения Race Condition: без lease два параллельных одинаковых запросов могут одновременно не найти ключ в кеше, начать обработку и выполнить операцию дважды
  • контроль времени обработки: Lease timeout (например, 30 сек) не даст «зависшему» запросу блокировать ключ вечно. По истечении таймаута ключ освободится, и запрос можно будет обработать повторно

Примеры

1: REST API для создания платежа Проблема: Клиентское приложение из-за плохой связи отправило запрос на списание средств несколько раз.

Решение: Эндпоинт POST /api/v1/payments требует заголовок Idempotency-Key. Платёжный сервис использует Redis для проверки ключа. Все дублирующиеся запросы возвращают результат первого, без списания денег.

2: Интеграция через Kafka Проблема: Сервис заказов отправил в Kafka сообщение "OrderCreated", а сервис склада, обрабатывая его, упал перед коммитом offset. При перезапуске он получит то же сообщение снова и попытается дважды списать товар.

Решение: В обработчике сообщения в сервисе склада реализована проверка по order_id (который уникален). Перед списанием товара проверяется, не был ли уже обработан этот заказ. Альтернативно — использование Transactional ID в Kafka для обеспечения exactly-Once.

Рекомендации

В спецификациях API и интеграций указывать, какие методы/эндпоинты должны быть идемпотентными. Ввести обязательный заголовок Idempotency-Key для модифицирующих POST/PATCH-запросов

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

  • "Что произойдёт, если этот запрос уйдёт дважды?"
  • "Как будут определяться и обрабатываться дубли?"
  • "Какое хранилище использовать для проверки уникальности Request ID?"

Антипаттерны:

  • Не использовать для идемпотентности данные, которые могут меняться (например, timestamp)
  • Не полагаться на ретраи на транспортном уровне (HTTP, TCP) как на гарантию доставки. Это гарантия доставки запроса, но не бизнес-логики

Материалы

  1. Идемпотентность: что это, примеры и применение в API
  2. Идемпотентность: больше, чем кажется
  3. Идемпотентность: искусство не менять мир дважды
  4. Как сделать хорошую интеграцию? Часть 2. Идемпотентные операции – основа устойчивой интеграции
  5. История одного идемпотентного метода
  6. Идемпотентность в такси-приложении: кейс из практики
  7. Что такое идемпотентность HTTP-методов и почему она важна
  8. Стажёр Вася и его истории об идемпотентности API
  9. Важность идемпотентности в распределенных системах
  10. Борьба с дубликатами: делаем POST идемпотентным
  11. Идемпотентность микросервисов
  12. Атомарность и идемпотентность в Apache AirFlow
  13. Оптимистические и пессимистические блокировки на примере Hibernate (JPA)
  14. Change Data Capture (CDC) в Yandex Data Transfer: гид по технологии с примерами
  15. Паттерн Outbox: как не растерять сообщения в микросервисной архитектуре
  16. Transactional Outbox: от идеи до open-source
  17. Payload body в API: что это, форматы и как работать
  18. Idempotency Keys in RESTful APIs (eng)
  19. Обеспечение идемпотентности API

Видео

  1. Что такое ИДЕМПОТЕНТНОСТЬ, или история Васи и его приложения
  2. Алексей Окружко, Идемпотентность: что, где и как?
  3. Микросервисы: Идемпотентность операций
  4. Филипп Вагнер «Распределенные транзакции в условиях микросервисной архитектуры» (SAGA, Outbox)
  5. Паттерн Outbox - теория и практика | Архитектура Микросервисов
  6. Compare and Swap, aka CAS - оптимистичная блокировка наглядно

Конференции

  1. Analyst days: Event-driven архитектура: Outbox паттерн и варианты его имплементации

Книги

  1. Высоконагруженные приложения. Программирование, масштабирование, поддержка - Мартин Клеппман (Глава 11)
  2. Создание микросервисов - Сэм Ньюмен (Глава 12)